├── .circleci └── config.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── build ├── icon.icns └── icon.ico ├── package.json ├── src ├── declarations.d.ts ├── main │ ├── main.ts │ ├── positioner.ts │ ├── shortcuts.ts │ ├── tray.ts │ ├── winState.ts │ └── window.ts └── renderer │ ├── actions │ ├── InitActions.ts │ ├── LibraryActions.ts │ ├── PlayerActions.ts │ ├── PlaylistActions.ts │ ├── SearchActions.ts │ └── SettingsActions.ts │ ├── components │ ├── elements │ │ ├── Button.tsx │ │ ├── ButtonGroup.tsx │ │ ├── Checkbox.tsx │ │ ├── ContextMenu.tsx │ │ ├── EditableTextbox.tsx │ │ ├── Icon.tsx │ │ ├── MessageBox.tsx │ │ ├── Modal.tsx │ │ ├── Slider.tsx │ │ ├── TabBar.tsx │ │ └── Textbox.tsx │ ├── header │ │ ├── DragRegion.tsx │ │ ├── HeaderActions.tsx │ │ ├── ModeTabs.tsx │ │ ├── Search.tsx │ │ └── WindowControls.tsx │ ├── library │ │ ├── EmptyState.tsx │ │ └── Songs.tsx │ ├── player │ │ ├── Controls.tsx │ │ ├── Details.tsx │ │ └── Queue.tsx │ ├── playlist │ │ ├── AddTrack.tsx │ │ ├── EmptyPlaylist.tsx │ │ ├── SidePanel.tsx │ │ └── Tracks.tsx │ └── settings │ │ ├── Columns.tsx │ │ ├── Library.tsx │ │ ├── Playlist.tsx │ │ └── System.tsx │ ├── containers │ ├── HeaderContainer.tsx │ ├── LibraryContainer.tsx │ ├── NotificationContainer.tsx │ ├── PlayerContainer.tsx │ ├── PlaylistContainer.tsx │ ├── SettingsContainer.tsx │ └── ShellContainer.tsx │ ├── database │ ├── PlaylistsDatabase.ts │ ├── StateDatabase.ts │ └── TracksDatabase.ts │ ├── index.html │ ├── libraries │ ├── Notifications.ts │ └── Player.ts │ ├── renderer.tsx │ ├── stores │ ├── AppContext.tsx │ ├── AppStore.ts │ ├── AppStoreModel.ts │ └── LayoutContext.tsx │ ├── styles │ ├── app.scss │ ├── base │ │ ├── _animate.scss │ │ ├── _base.scss │ │ ├── _index.scss │ │ ├── _normalize.scss │ │ ├── _typography.scss │ │ └── _variables.scss │ ├── containers │ │ ├── _header.scss │ │ ├── _index.scss │ │ ├── _library.scss │ │ ├── _panel.scss │ │ ├── _player.scss │ │ ├── _playlist.scss │ │ └── _settings.scss │ └── elements │ │ ├── _buttons.scss │ │ ├── _checkbox.scss │ │ ├── _context.scss │ │ ├── _icons.scss │ │ ├── _index.scss │ │ ├── _messagebox.scss │ │ ├── _modal.scss │ │ ├── _notifications.scss │ │ ├── _popover.scss │ │ ├── _scrollbars.scss │ │ ├── _slider.scss │ │ ├── _table.scss │ │ ├── _tabs.scss │ │ └── _textbox.scss │ └── utilities │ ├── LibraryUtils.ts │ ├── QueueUtils.ts │ └── TimeUtils.ts ├── static ├── readme │ └── win-image.jpg ├── tray │ ├── mac.png │ ├── mac@2x.png │ └── windows.ico └── vectors │ ├── defaultAlbumArt.svg │ └── emptyState.svg ├── tsconfig.json ├── tslint.json ├── webpack.dev.js ├── webpack.prod.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10.1 11 | # Specify service dependencies here if necessary 12 | # CircleCI maintains a library of pre-built images 13 | # documented at https://circleci.com/docs/2.0/circleci-images/ 14 | # - image: circleci/mongo:3.4.4 15 | 16 | working_directory: ~/nighthawk-ci 17 | 18 | steps: 19 | - checkout 20 | 21 | - run: 22 | name: Display node and npm informations 23 | command: | 24 | echo "node version $(node -v) running" 25 | echo "yarn version $(yarn --version) running" 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | name: Restore Yarn Package Cache 30 | keys: 31 | - yarn-packages-{{ .Branch }}-{{ checksum "yarn.lock" }} 32 | - yarn-packages-{{ .Branch }} 33 | - yarn-packages-master 34 | - yarn-packages- 35 | 36 | # Install Depedencies 37 | - run: 38 | name: Install dependencies 39 | command: yarn install 40 | 41 | # Build webpack-production 42 | - run: 43 | name: Generate Production Build 44 | command: yarn run production 45 | 46 | # Lint Source Code 47 | - run: 48 | name: Lint Typescript 49 | command: yarn run lint 50 | 51 | # Save Cache for future use 52 | - save_cache: 53 | name: Save Yarn Package Cache 54 | key: yarn-packages-{{ .Branch }}-{{ checksum "yarn.lock" }} 55 | paths: 56 | - node_modules/ 57 | 58 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # folder for compiled files 64 | dist/ 65 | pkgs/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | package.json 3 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 4, 5 | "jsxBracketSameLine": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "editor.detectIndentation": false, 4 | "editor.tabSize": 4, 5 | "editor.formatOnSave": true, 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tarak Sharma 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Nighthawk 4 |
5 |

6 | 7 | [![license](https://img.shields.io/github/license/quantumkv/nighthawk.svg?style=flat-square)](https://github.com/quantumkv/nighthawk) 8 | [![CircleCI](https://img.shields.io/circleci/project/github/quantumkv/nighthawk.svg?style=flat-square)](https://github.com/quantumkv/nighthawk) 9 | 10 |

A stealthy, simple, unobtrusive music player that stays out of your way in your Menubar/Taskbar

11 | 12 |

13 | Key Features • 14 | How to Use • 15 | Download • 16 | Credits • 17 | License 18 |

19 | 20 | ![screenshot](https://raw.githubusercontent.com/quantumkv/nighthawk/master/static/readme/win-image.jpg) 21 | 22 | ## Key Features 23 | 24 | - Works completely in the tray. Hides automatically when it looses focus. 25 | - Does not appear in the taskbar and window lists when hidden. 26 | - Supports keyboard media keys to control the player. 27 | - A powerful queue manager to manage your playing queue. 28 | - Saves your state so you can continue from where you left off last time. 29 | - A simple and efficient playlist system. 30 | - Automatically creates playlists based on the Library Sub-Folder Structure 31 | - Highly Efficient. 32 | - More Features to come soon. 33 | 34 | ## How To Use 35 | 36 | - Download the appropriate setup and install. 37 | - The app only lives in the tray. You have to manipulate it from there. - For Windows, the icon will in the tray next to the clock on the taskbar. Click to show the player. Right click will open up the tray menu with extra options. - For Ubuntu and MacOS, the icon will be in the top-right corner of the screen. 38 | - You can change the app to behave as regular windowed app in the settings if you want. 39 | 40 | #### For First Time Users: 41 | 42 | - Click the settings button on the top-right of the window and select the folder with your music files. 43 | - Click of refresh button and wait for the tracks to be loaded. 44 | 45 | ## Download 46 | 47 | You can download latest installers from your OS [from the releases page](https://github.com/quantumkv/nighthawk/releases) 48 | 49 | Current only the following OS are supported: 50 | 51 | - Windows 7 and greater 52 | - Ubuntu 14.04 and greater (64 bit) [Tested, should work on other debian based distros] 53 | - MacOS X 10.10 (Yosemite) and greater 54 | 55 | ## Credits 56 | 57 | - [Electron](http://electronjs.org/) 58 | - [Immer](https://github.com/mweststrate/immer) 59 | - [Dexie](http://dexie.org/) 60 | - [Electron Builder](https://www.electron.build/) 61 | - [React Virtualized](https://bvaughn.github.io/react-virtualized/) 62 | - [music-metadata](https://github.com/borewit/music-metadata) 63 | 64 | ## License 65 | 66 | MIT 67 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/build/icon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nighthawk", 3 | "version": "2.1.1", 4 | "author": "Tarak Sharma ", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/quantumkv/nighthawk" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/quantumkv/nighthawk/issues" 12 | }, 13 | "description": "A Stealthy,Cross-Platform, Simple Music Player that stays out of your way", 14 | "main": "./dist/main.bundle.js", 15 | "scripts": { 16 | "start": "electron ./dist/main.bundle.js", 17 | "development": "rimraf dist && webpack --watch --config ./webpack.dev.js --progress --colors", 18 | "production": "rimraf dist && webpack --config ./webpack.prod.js --progress --colors", 19 | "lint": "tslint --project .", 20 | "pack": "yarn production && electron-builder --dir", 21 | "dist": "yarn production && electron-builder -mwl -c.mac.identity=null" 22 | }, 23 | "keywords": [ 24 | "music", 25 | "player", 26 | "electron", 27 | "tray", 28 | "desktop", 29 | "background" 30 | ], 31 | "build": { 32 | "appId": "com.quantumkv.nighthawk", 33 | "productName": "Nighthawk", 34 | "compression": "normal", 35 | "artifactName": "${name}-${os}-v${version}-${arch}.${ext}", 36 | "directories": { 37 | "output": "pkgs" 38 | }, 39 | "files": [ 40 | "dist/**/*" 41 | ], 42 | "mac": { 43 | "category": "public.app-category.music", 44 | "target": [ 45 | "zip" 46 | ] 47 | }, 48 | "nsis": { 49 | "oneClick": false, 50 | "allowToChangeInstallationDirectory": true, 51 | "deleteAppDataOnUninstall": true, 52 | "artifactName": "${name}Setup-${os}-v${version}-${arch}.${ext}" 53 | }, 54 | "linux": { 55 | "category": "AudioVideo", 56 | "target": [ 57 | { 58 | "target": "deb", 59 | "arch": [ 60 | "x64" 61 | ] 62 | } 63 | ], 64 | "executableName": "nighthawk" 65 | }, 66 | "win": { 67 | "target": [ 68 | { 69 | "target": "nsis", 70 | "arch": [ 71 | "x64" 72 | ] 73 | } 74 | ] 75 | } 76 | }, 77 | "devDependencies": { 78 | "@types/classnames": "^2.2.7", 79 | "@types/electron-is-dev": "^0.3.0", 80 | "@types/electron-store": "^1.3.0", 81 | "@types/lodash": "^4.14.116", 82 | "@types/react": "^16.8.14", 83 | "@types/react-dom": "^16.8.4", 84 | "@types/react-modal": "^3.8.2", 85 | "@types/react-transition-group": "^2.9.0", 86 | "@types/react-virtualized": "^9.21.1", 87 | "@types/recursive-readdir": "^2.2.0", 88 | "css-loader": "^2.1.1", 89 | "electron": "5.0.0", 90 | "electron-builder": "^20.39.0", 91 | "file-loader": "^3.0.1", 92 | "html-webpack-plugin": "^3.2.0", 93 | "mini-css-extract-plugin": "^0.6.0", 94 | "node-sass": "^4.11.0", 95 | "prettier": "1.14.3", 96 | "rimraf": "^2.6.3", 97 | "sass-loader": "^7.1.0", 98 | "ts-loader": "^5.3.3", 99 | "tslint": "^5.16.0", 100 | "tslint-config-airbnb": "^5.11.1", 101 | "tslint-config-prettier": "^1.18.0", 102 | "typescript": "^3.4.4", 103 | "webpack": "^4.30.0", 104 | "webpack-cli": "^3.3.0" 105 | }, 106 | "dependencies": { 107 | "@mdi/font": "^3.6.95", 108 | "classnames": "^2.2.6", 109 | "dexie": "^3.0.0-alpha.3", 110 | "electron-is-dev": "^1.1.0", 111 | "electron-log": "^3.0.5", 112 | "electron-store": "^2.0.0", 113 | "immer": "^1.6.0", 114 | "lodash": "^4.17.11", 115 | "moment": "^2.24.0", 116 | "music-metadata": "3.5.4", 117 | "react": "^16.8.6", 118 | "react-dom": "^16.8.6", 119 | "react-hint": "^3.2.0", 120 | "react-modal": "^3.8.1", 121 | "react-tiny-popover": "^3.4.2", 122 | "react-transition-group": "^4.0.0", 123 | "react-virtualized": "^9.21.0", 124 | "recursive-readdir": "^2.2.2" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: any; 3 | export = value; 4 | } 5 | 6 | declare module '*.png' { 7 | const value: any; 8 | export = value; 9 | } 10 | 11 | declare module '*.ico' { 12 | const value: any; 13 | export = value; 14 | } 15 | 16 | declare module '*.icns' { 17 | const value: any; 18 | export = value; 19 | } 20 | 21 | declare module 'react-hint'; 22 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, globalShortcut } from 'electron'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import * as url from 'url'; 5 | // tslint:disable-next-line:import-name 6 | import createTray from './tray'; 7 | // tslint:disable-next-line:import-name 8 | import createMainWindow from './window'; 9 | // tslint:disable-next-line:import-name 10 | import registerShortcuts from './shortcuts'; 11 | import positioner from './positioner'; 12 | import electronStore from 'electron-store'; 13 | import electronLog from 'electron-log'; 14 | 15 | let mainWindow: Electron.BrowserWindow; 16 | let tray: Electron.Tray; 17 | let isDialogOpen: boolean = false; 18 | 19 | electronLog.transports.file.level = 'warn'; 20 | 21 | let store = new electronStore({ 22 | name: 'settings', 23 | }); 24 | 25 | function createWindow() { 26 | // Create the browser window. 27 | mainWindow = createMainWindow(); 28 | 29 | // attach tray to window 30 | tray = createTray(mainWindow); 31 | 32 | // Register Global Shortcuts 33 | registerShortcuts(mainWindow); 34 | 35 | if ( 36 | !store.has('system.unobtrusive') || 37 | store.get('system.unobtrusive') === true 38 | ) { 39 | // reposition when showing window 40 | mainWindow.on('show', () => { 41 | // reposition window here 42 | positioner(mainWindow, tray.getBounds()); 43 | }); 44 | 45 | mainWindow.on('blur', () => { 46 | if (!isDialogOpen) { 47 | mainWindow.hide(); 48 | } 49 | }); 50 | } 51 | } 52 | 53 | // This method will be called when Electron has finished 54 | // initialization and is ready to create browser windows. 55 | // Some APIs can only be used after this event occurs. 56 | app.on('ready', createWindow); 57 | 58 | // Quit when all windows are closed. 59 | app.on('window-all-closed', () => { 60 | // On OS X it is common for applications and their menu bar 61 | // to stay active until the user quits explicitly with Cmd + Q 62 | if (process.platform !== 'darwin') { 63 | app.quit(); 64 | } 65 | }); 66 | 67 | // Unregister media key shortcuts. 68 | app.on('will-quit', () => { 69 | globalShortcut.unregisterAll(); 70 | }); 71 | 72 | app.on('activate', () => { 73 | // On OS X it"s common to re-create a window in the app when the 74 | // dock icon is clicked and there are no other windows open. 75 | if (mainWindow === null) { 76 | createWindow(); 77 | } 78 | }); 79 | 80 | // In this file you can include the rest of your app"s specific main process 81 | // code. You can also put them in separate files and require them here. 82 | 83 | ipcMain.on('WINDOW_QUIT', () => { 84 | app.quit(); 85 | }); 86 | 87 | ipcMain.on('WINDOW_MINIMIZE', () => { 88 | if (mainWindow) { 89 | mainWindow.minimize(); 90 | } 91 | }); 92 | 93 | ipcMain.on('WINDOW_MAXIMIZE', () => { 94 | if (mainWindow) { 95 | mainWindow.isMaximized() 96 | ? mainWindow.unmaximize() 97 | : mainWindow.maximize(); 98 | } 99 | }); 100 | 101 | ipcMain.on('SET_DIALOG_SHOW', (event: any, arg: boolean) => { 102 | // If the app in not in unobtrusive mode, returns because this fix is not required. 103 | if (store.get('system.unobtrusive') === false) { 104 | return; 105 | } 106 | // Sends the window level below the dialog and prevents it from loosing focus. 107 | // Use this only when showing a dialog. 108 | mainWindow.setAlwaysOnTop(!arg); 109 | isDialogOpen = arg; 110 | }); 111 | -------------------------------------------------------------------------------- /src/main/positioner.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Rectangle, screen } from 'electron'; 2 | 3 | export default function positioner( 4 | window: BrowserWindow, 5 | trayBounds: Rectangle 6 | ) { 7 | let position = calculate(window.getSize(), window.getBounds(), trayBounds); 8 | window.setPosition(position.x, position.y); 9 | } 10 | 11 | function calculate( 12 | windowSize: number[], 13 | windowBounds: Rectangle, 14 | trayBounds: Rectangle 15 | ) { 16 | let screenSize = getScreenSize(); 17 | let taskbarPosition = getTaskbarPosition(); 18 | let x: number; 19 | let y: number; 20 | 21 | switch (taskbarPosition) { 22 | case 'left': 23 | x = screenSize.x; 24 | y = Math.floor(screenSize.height - (windowSize[1] - screenSize.y)); 25 | break; 26 | 27 | case 'right': 28 | x = Math.floor(screenSize.x + (screenSize.width - windowSize[0])); 29 | y = Math.floor(screenSize.height - (windowSize[1] - screenSize.y)); 30 | break; 31 | 32 | case 'bottom': 33 | x = Math.floor(screenSize.x + (screenSize.width - windowSize[0])); 34 | y = Math.floor(screenSize.height - (windowSize[1] - screenSize.y)); 35 | break; 36 | 37 | case 'top': 38 | x = Math.floor(screenSize.x + (screenSize.width - windowSize[0])); 39 | y = screenSize.y; 40 | break; 41 | } 42 | 43 | return { x, y }; 44 | } 45 | 46 | function getTaskbarPosition(): string { 47 | const display = screen.getDisplayNearestPoint( 48 | screen.getCursorScreenPoint() 49 | ); 50 | let retval: string; 51 | if (display.workArea.y > 0) { 52 | retval = 'top'; 53 | } else if (display.workArea.x > 0) { 54 | retval = 'left'; 55 | } else if (display.workArea.width === display.bounds.width) { 56 | retval = 'bottom'; 57 | } else { 58 | retval = 'right'; 59 | } 60 | 61 | return retval; 62 | } 63 | 64 | function getScreenSize() { 65 | return screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) 66 | .workArea; 67 | } 68 | -------------------------------------------------------------------------------- /src/main/shortcuts.ts: -------------------------------------------------------------------------------- 1 | import { globalShortcut, BrowserWindow } from 'electron'; 2 | 3 | export default function registerShortcuts(window: BrowserWindow) { 4 | globalShortcut.register('MediaPlayPause', () => { 5 | window.webContents.send('PLAYER_CONTROLS_TOGGLE_PLAY'); 6 | }); 7 | 8 | globalShortcut.register('MediaPreviousTrack', () => { 9 | window.webContents.send('PLAYER_CONTROLS_PREV_TRACK'); 10 | }); 11 | 12 | globalShortcut.register('MediaNextTrack', () => { 13 | window.webContents.send('PLAYER_CONTROLS_NEXT_TRACK'); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/tray.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Tray, 3 | nativeImage, 4 | Menu, 5 | BrowserWindow, 6 | MenuItem, 7 | app, 8 | } from 'electron'; 9 | import * as os from 'os'; 10 | import * as path from 'path'; 11 | 12 | import * as macImg from '../../static/tray/mac.png'; 13 | import * as macRetinaImg from '../../static/tray/mac@2x.png'; 14 | import * as winImg from '../../static/tray/windows.ico'; 15 | 16 | export default function createTray(window: BrowserWindow) { 17 | let tempTray: Tray; 18 | 19 | if (os.platform() === 'darwin') { 20 | // Weird and Horrific Hack to get the mac retina images loaded by webpack without triggering typescript not used errors. 21 | const image: nativeImage = nativeImage.createFromPath( 22 | true 23 | ? path.join(__dirname, macImg) 24 | : path.join(__dirname, macRetinaImg) 25 | ); 26 | image.setTemplateImage(true); 27 | tempTray = new Tray(image); 28 | } else if (os.platform() === 'win32') { 29 | const image: nativeImage = nativeImage.createFromPath( 30 | path.join(__dirname, winImg) 31 | ); 32 | tempTray = new Tray(image); 33 | } else { 34 | const image: nativeImage = nativeImage.createFromPath( 35 | path.join(__dirname, macImg) 36 | ); 37 | tempTray = new Tray(image); 38 | } 39 | 40 | const onQuit = ( 41 | menuItem: MenuItem, 42 | browserWindow: BrowserWindow, 43 | event: Event 44 | ) => { 45 | app.quit(); 46 | }; 47 | 48 | const handlePlayPause = () => { 49 | window.webContents.send('PLAYER_CONTROLS_TOGGLE_PLAY'); 50 | }; 51 | 52 | const handlePrevTrack = () => { 53 | window.webContents.send('PLAYER_CONTROLS_PREV_TRACK'); 54 | }; 55 | 56 | const handleNextTrack = () => { 57 | window.webContents.send('PLAYER_CONTROLS_NEXT_TRACK'); 58 | }; 59 | 60 | const handleShowPlayer = ( 61 | menuItem: MenuItem, 62 | browserWindow: BrowserWindow, 63 | event: Event 64 | ) => { 65 | window.show(); 66 | }; 67 | 68 | const handleHidePlayer = ( 69 | menuItem: MenuItem, 70 | browserWindow: BrowserWindow, 71 | event: Event 72 | ) => { 73 | window.hide(); 74 | }; 75 | 76 | const handleTrayClick = (e: any) => { 77 | if (e.altKey || e.shiftKey || e.ctrlKey || e.metaKey) { 78 | return window.hide(); 79 | } 80 | 81 | if (window && window.isVisible()) { 82 | return window.hide(); 83 | } 84 | 85 | window.show(); 86 | }; 87 | 88 | const trayMenu: Menu = Menu.buildFromTemplate([ 89 | { label: 'Show Player', type: 'normal', click: handleShowPlayer }, 90 | { label: 'Hide Player', type: 'normal', click: handleHidePlayer }, 91 | { type: 'separator' }, 92 | { label: 'Play/Pause', type: 'normal', click: handlePlayPause }, 93 | { label: 'Prev Track', type: 'normal', click: handlePrevTrack }, 94 | { label: 'Next Track', type: 'normal', click: handleNextTrack }, 95 | { type: 'separator' }, 96 | { label: 'Quit Nighthawk', type: 'normal', click: onQuit }, 97 | ]); 98 | 99 | tempTray.setToolTip('Click to Open'); 100 | tempTray.setContextMenu(trayMenu); 101 | 102 | tempTray.on('click', handleTrayClick); 103 | 104 | return tempTray; 105 | } 106 | -------------------------------------------------------------------------------- /src/main/winState.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, screen } from 'electron'; 2 | // tslint:disable-next-line:import-name 3 | import Store from 'electron-store'; 4 | import debounce from 'lodash/debounce'; 5 | 6 | export default function manageWindowState(defaults: WindowState) { 7 | // Grab initial bounds here 8 | let store = new Store({ name: 'window-state' }); 9 | let state: WindowState = store.store; 10 | 11 | if (state.displayBounds !== undefined) { 12 | let bounds = screen.getDisplayMatching({ 13 | x: state.x!, 14 | y: state.y!, 15 | width: state.width!, 16 | height: state.height!, 17 | }).bounds; 18 | 19 | if ( 20 | bounds.height < state.displayBounds.height! || 21 | bounds.width < state.width! 22 | ) { 23 | state.height = defaults.height; 24 | state.width = defaults.width; 25 | } else { 26 | state.height = 27 | state.height === undefined ? defaults.height : state.height; 28 | state.width = 29 | state.width === undefined ? defaults.width : state.width; 30 | } 31 | } else { 32 | state.height = 33 | state.height === undefined ? defaults.height : state.height; 34 | state.width = state.width === undefined ? defaults.width : state.width; 35 | } 36 | 37 | const trackResize = (win: BrowserWindow) => { 38 | let stateChangeTimer: NodeJS.Timer; 39 | win.on( 40 | 'resize', 41 | debounce(() => { 42 | store.store = Object.assign({}, win.getBounds(), { 43 | displayBounds: screen.getDisplayMatching(win.getBounds()) 44 | .bounds, 45 | }); 46 | }, 1000) 47 | ); 48 | }; 49 | 50 | return { 51 | trackResize, 52 | width: state.width, 53 | height: state.height, 54 | }; 55 | } 56 | 57 | export interface WindowState { 58 | x?: number; 59 | y?: number; 60 | width?: number; 61 | height?: number; 62 | 63 | displayBounds?: Electron.Rectangle; 64 | } 65 | -------------------------------------------------------------------------------- /src/main/window.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, 3 | NativeImage, 4 | nativeImage, 5 | ipcMain, 6 | screen, 7 | } from 'electron'; 8 | 9 | import * as os from 'os'; 10 | import * as url from 'url'; 11 | import * as path from 'path'; 12 | 13 | import electronIsDev from 'electron-is-dev'; 14 | import electronStore from 'electron-store'; 15 | // tslint:disable-next-line:import-name 16 | import manageWindowState from './winState'; 17 | 18 | export default function createMainWindow() { 19 | let mainWindowState = manageWindowState({ width: 992, height: 558 }); 20 | // Construct new BrowserWindow 21 | let mainWindow: Electron.BrowserWindow; 22 | 23 | let store = new electronStore({ 24 | name: 'settings', 25 | }); 26 | 27 | let unobtusive: boolean = 28 | !store.has('system.unobtrusive') || 29 | store.get('system.unobtrusive') === true 30 | ? true 31 | : false; 32 | 33 | // Create the browser window. 34 | mainWindow = new BrowserWindow({ 35 | frame: os.platform() === 'darwin' ? true : false, 36 | titleBarStyle: 'hiddenInset', 37 | maximizable: !unobtusive, 38 | minimizable: !unobtusive, 39 | show: !unobtusive, 40 | minHeight: 558, 41 | minWidth: 992, 42 | height: mainWindowState.height, 43 | width: mainWindowState.width, 44 | webPreferences: { 45 | nodeIntegration: true, 46 | }, 47 | }); 48 | 49 | // and load the index.html of the app. 50 | mainWindow.loadURL( 51 | url.format({ 52 | pathname: path.join(__dirname, './index.html'), 53 | protocol: 'file:', 54 | slashes: true, 55 | }) 56 | ); 57 | 58 | if (electronIsDev) { 59 | // Open the DevTools. 60 | mainWindow.webContents.openDevTools(); 61 | } 62 | 63 | // Emitted when the window is closed. 64 | mainWindow.on('closed', () => { 65 | // Dereference the window object, usually you would store windows 66 | // in an array if your app supports multi windows, this is the time 67 | // when you should delete the corresponding element. 68 | mainWindow = null; 69 | }); 70 | 71 | if (unobtusive) { 72 | mainWindow.setAlwaysOnTop(true); 73 | } 74 | mainWindowState.trackResize(mainWindow); 75 | return mainWindow; 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/actions/InitActions.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import produce from 'immer'; 3 | import electronLog from 'electron-log'; 4 | import { sortTracks } from '../utilities/LibraryUtils'; 5 | 6 | import { AppStoreModel } from '../stores/AppStoreModel'; 7 | import { TrackModel, TracksDatabase } from '../database/TracksDatabase'; 8 | import { StateDatabase } from '../database/StateDatabase'; 9 | import Player from '../libraries/Player'; 10 | import Notifications from '../libraries/Notifications'; 11 | import { 12 | PlaylistsDatabase, 13 | PlaylistModel, 14 | } from '../database/PlaylistsDatabase'; 15 | 16 | export async function init(state?: AppStoreModel) { 17 | const db: TracksDatabase = new TracksDatabase('library'); 18 | const statedb: StateDatabase = new StateDatabase('state'); 19 | const playlistdb: PlaylistsDatabase = new PlaylistsDatabase('playlists'); 20 | 21 | try { 22 | let data = await db.library.toArray(); 23 | db.close(); 24 | 25 | let queueState = await statedb.queue.get(1); 26 | statedb.close(); 27 | 28 | let folderPlaylists: PlaylistModel[]; 29 | 30 | if (state.settings.playlist.folder) { 31 | folderPlaylists = await playlistdb.folders.toArray(); 32 | } else { 33 | folderPlaylists = []; 34 | } 35 | 36 | let playlists = await playlistdb.playlists.toArray(); 37 | playlistdb.close(); 38 | 39 | return produce(state, draft => { 40 | draft.originalLibrary = data; 41 | draft.library = sortTracks( 42 | state.settings.library.sortBy, 43 | state.settings.library.sortDirection, 44 | draft.originalLibrary 45 | ); 46 | 47 | draft.playlist.playlists = folderPlaylists.concat(playlists); 48 | if (draft.playlist.playlists.length > 0) { 49 | draft.playlist.currentPlaylist = draft.playlist.playlists[0]; 50 | draft.playlist.currentTracks = draft.playlist.playlists[0].tracks.map( 51 | value => { 52 | return draft.library.find(x => x.id === value); 53 | } 54 | ); 55 | } 56 | 57 | draft.player.queue = queueState.queue.map(value => { 58 | return draft.library.find(x => x.id === value); 59 | }); 60 | 61 | draft.player.originalQueue = queueState.originalQueue.map(value => { 62 | return draft.library.find(x => x.id === value); 63 | }); 64 | 65 | if (queueState.cursor !== -2) { 66 | Player.setAudioSrc( 67 | draft.player.queue[queueState.cursor].source 68 | ); 69 | } 70 | 71 | draft.player.cursor = queueState.cursor; 72 | }); 73 | } catch (error) { 74 | electronLog.error(error); 75 | Notifications.addNotification( 76 | 'initerror', 77 | 'Encountered an Error While Initializing Data. Please check the logs.', 78 | true 79 | ); 80 | db.close(); 81 | statedb.close(); 82 | return state; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/renderer/actions/LibraryActions.ts: -------------------------------------------------------------------------------- 1 | import recursiveReaddir from 'recursive-readdir'; 2 | import { parseFile } from 'music-metadata'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import { promisify } from 'util'; 6 | const fstat = promisify(fs.stat); 7 | 8 | import { IAudioMetadata } from 'music-metadata/lib/type'; 9 | 10 | // tslint:disable-next-line:import-name 11 | import produce from 'immer'; 12 | import electronLog from 'electron-log'; 13 | 14 | import { sortTracks } from '../utilities/LibraryUtils'; 15 | import { AppStoreModel } from '../stores/AppStoreModel'; 16 | import { TrackModel, TracksDatabase } from '../database/TracksDatabase'; 17 | import Notifications from '../libraries/Notifications'; 18 | 19 | export async function refreshLibrary(state?: AppStoreModel) { 20 | Notifications.addNotification( 21 | 'library', 22 | 'Refreshing Library... Please Wait' 23 | ); 24 | const db: TracksDatabase = new TracksDatabase('library'); 25 | 26 | if (state.settings.library.path === '') { 27 | return state; 28 | } 29 | 30 | try { 31 | let files: string[] = await recursiveReaddir( 32 | state.settings.library.path 33 | ); 34 | 35 | // filter out the unrequired files 36 | files = files.filter(file => { 37 | return ( 38 | path.extname(file) === '.mp3' || 39 | path.extname(file) === '.wma' || 40 | path.extname(file) === '.ogg' || 41 | path.extname(file) === '.wav' || 42 | path.extname(file) === '.m4a' || 43 | path.extname(file) === '.aac' || 44 | path.extname(file) === '.flac' 45 | ); 46 | }); 47 | 48 | // Grab the metadata and exract the required files 49 | const tracks: TrackModel[] = await Promise.all( 50 | files.map(async (file, index) => { 51 | const data: IAudioMetadata = await parseFile(file, { 52 | skipCovers: true, 53 | }); 54 | 55 | const filestats: fs.Stats = await fstat(file); 56 | 57 | if ( 58 | data.common.title === '' || 59 | data.common.title === undefined 60 | ) { 61 | data.common.title = path.basename(file, path.extname(file)); 62 | } 63 | 64 | if ( 65 | data.common.artist === '' || 66 | data.common.artist === undefined 67 | ) { 68 | data.common.artist = 'Unknown Artist'; 69 | } 70 | 71 | if ( 72 | data.common.album === '' || 73 | data.common.album === undefined 74 | ) { 75 | data.common.album = 'Unknown Album'; 76 | } 77 | 78 | return { 79 | id: index, 80 | source: file, 81 | common: data.common, 82 | format: data.format, 83 | stats: filestats, 84 | }; 85 | }) 86 | ); 87 | 88 | // Clear all existing tracks from the database 89 | await db.library.clear(); 90 | 91 | // Write the new tracks into database 92 | await db.transaction('rw', db.library, () => { 93 | for (let i = 0, n = tracks.length; i < n; i++) { 94 | db.library.add(tracks[i]); 95 | } 96 | }); 97 | 98 | db.close(); 99 | Notifications.removeNotification('library'); 100 | 101 | return produce(state, draft => { 102 | draft.originalLibrary = tracks; 103 | draft.library = sortTracks( 104 | draft.settings.library.sortBy, 105 | draft.settings.library.sortDirection, 106 | draft.originalLibrary 107 | ); 108 | }); 109 | } catch (error) { 110 | electronLog.error(error); 111 | Notifications.removeNotification('library'); 112 | Notifications.addNotification( 113 | 'libraryerror', 114 | 'Encountered an Error While Refreshing Library. Please check the logs.', 115 | true 116 | ); 117 | db.close(); 118 | return state; 119 | } 120 | } 121 | 122 | export async function sortLibrary( 123 | sortBy: string, 124 | sortDirection: 'ASC' | 'DESC', 125 | state?: AppStoreModel 126 | ) { 127 | return produce(state, draft => { 128 | draft.library = sortTracks(sortBy, sortDirection, draft.library); 129 | }); 130 | } 131 | -------------------------------------------------------------------------------- /src/renderer/actions/PlayerActions.ts: -------------------------------------------------------------------------------- 1 | import { AppStoreModel, PlayerStoreModel } from '../stores/AppStoreModel'; 2 | import Player from '../libraries/Player'; 3 | import { 4 | shuffleList, 5 | updatePlayerStateCursor, 6 | updatePlayerState, 7 | } from '../utilities/QueueUtils'; 8 | // tslint:disable-next-line:import-name 9 | import produce from 'immer'; 10 | 11 | export async function togglePlayPause( 12 | state?: AppStoreModel 13 | ): Promise { 14 | return produce(state, draft => { 15 | draft.player.playing ? Player.pause() : Player.play(); 16 | draft.player.playing = !draft.player.playing; 17 | }); 18 | } 19 | 20 | export async function nextSong(state?: AppStoreModel): Promise { 21 | return produce(state, draft => { 22 | let cursor; 23 | if (draft.settings.player.repeat) { 24 | Player.replay(); 25 | } else { 26 | cursor = draft.player.cursor + 1; 27 | cursor = cursor === draft.player.queue.length ? 0 : cursor; 28 | Player.setAudioSrc(draft.player.queue[cursor].source); 29 | Player.play(); 30 | draft.player.cursor = cursor; 31 | updatePlayerStateCursor(cursor); 32 | } 33 | }); 34 | } 35 | 36 | export async function prevSong(state?: AppStoreModel): Promise { 37 | return produce(state, draft => { 38 | let cursor; 39 | if (draft.settings.player.repeat) { 40 | Player.replay(); 41 | } else { 42 | cursor = draft.player.cursor - 1; 43 | cursor = cursor === -1 ? draft.player.queue.length - 1 : cursor; 44 | Player.setAudioSrc(draft.player.queue[cursor].source); 45 | Player.play(); 46 | draft.player.cursor = cursor; 47 | updatePlayerStateCursor(cursor); 48 | } 49 | }); 50 | } 51 | 52 | export async function shuffleToggle( 53 | state?: AppStoreModel 54 | ): Promise { 55 | return produce(state, draft => { 56 | // Shuffle Code here. Use Fisher-Yates Shuffle Algorithm. 57 | const trackId = draft.player.queue[draft.player.cursor].id; 58 | 59 | if (draft.settings.player.shuffle) { 60 | const shuffled = shuffleList([...draft.player.queue]); 61 | draft.player.queue = shuffled; 62 | draft.player.cursor = shuffled.findIndex(i => i.id === trackId); 63 | } else { 64 | draft.player.queue = draft.player.originalQueue; 65 | draft.player.cursor = draft.player.originalQueue.findIndex( 66 | i => i.id === trackId 67 | ); 68 | } 69 | updatePlayerStateCursor(draft.player.cursor); 70 | }); 71 | } 72 | 73 | export async function seekSong( 74 | index: number, 75 | state?: AppStoreModel 76 | ): Promise { 77 | return produce(state, draft => { 78 | Player.setAudioSrc(draft.player.queue[index].source); 79 | Player.play(); 80 | draft.player.cursor = index; 81 | updatePlayerStateCursor(index); 82 | }); 83 | } 84 | 85 | export async function createPlayerQueue( 86 | index: number, 87 | state?: AppStoreModel 88 | ): Promise { 89 | return produce(state, draft => { 90 | const trackId = draft.library[index].id; 91 | if (draft.settings.player.shuffle) { 92 | let shuffled = shuffleList([...draft.library]); 93 | let newindex = shuffled.findIndex(i => i.id === trackId); 94 | Player.setAudioSrc(shuffled[newindex].source); 95 | Player.play(); 96 | 97 | // Data Assignments 98 | draft.player.queue = shuffled; 99 | draft.player.originalQueue = draft.library; 100 | draft.player.cursor = newindex; 101 | draft.player.playing = true; 102 | } else { 103 | Player.setAudioSrc(draft.library[index].source); 104 | Player.play(); 105 | // Data Assignments 106 | draft.player.queue = draft.library; 107 | draft.player.originalQueue = draft.library; 108 | draft.player.cursor = index; 109 | draft.player.playing = true; 110 | } 111 | 112 | let queue: number[] = draft.player.queue.map(value => value.id); 113 | let originalQueue: number[] = draft.player.originalQueue.map( 114 | value => value.id 115 | ); 116 | updatePlayerState(queue, originalQueue, draft.player.cursor); 117 | }); 118 | } 119 | 120 | export async function newQueue(index: number, state?: AppStoreModel) { 121 | return produce(state, draft => { 122 | draft.player.queue = []; 123 | draft.player.originalQueue = []; 124 | 125 | draft.player.originalQueue.push(draft.library[index]); 126 | draft.player.queue.push(draft.library[index]); 127 | draft.player.cursor = 0; 128 | draft.player.playing = true; 129 | 130 | Player.setAudioSrc(draft.player.queue[0].source); 131 | Player.play(); 132 | updatePlayerState( 133 | [draft.player.queue[0].id], 134 | [draft.player.queue[0].id], 135 | 0 136 | ); 137 | }); 138 | } 139 | 140 | export async function existingQueue(index: number, state?: AppStoreModel) { 141 | return produce(state, draft => { 142 | draft.player.originalQueue.push(draft.library[index]); 143 | draft.player.queue.push(draft.library[index]); 144 | 145 | if (draft.settings.player.shuffle && draft.player.queue.length > 1) { 146 | const trackId = draft.player.queue[draft.player.cursor].id; 147 | let shuffled = shuffleList([...draft.player.queue]); 148 | let newindex = shuffled.findIndex(i => i.id === trackId); 149 | 150 | draft.player.queue = shuffled; 151 | draft.player.cursor = newindex; 152 | } 153 | 154 | if (draft.player.queue.length === 1) { 155 | draft.player.cursor = 0; 156 | draft.player.playing = true; 157 | 158 | Player.setAudioSrc(draft.player.queue[0].source); 159 | Player.play(); 160 | } 161 | 162 | let queue: number[] = draft.player.queue.map(value => value.id); 163 | let originalQueue: number[] = draft.player.originalQueue.map( 164 | value => value.id 165 | ); 166 | updatePlayerState(queue, originalQueue, draft.player.cursor); 167 | }); 168 | } 169 | 170 | export async function createPlayerQueueFromPlaylist( 171 | index: number, 172 | state?: AppStoreModel 173 | ): Promise { 174 | return produce(state, draft => { 175 | const trackId = draft.playlist.currentTracks[index].id; 176 | if (draft.settings.player.shuffle) { 177 | let shuffled = shuffleList([...draft.playlist.currentTracks]); 178 | let newindex = shuffled.findIndex(i => i.id === trackId); 179 | Player.setAudioSrc(shuffled[newindex].source); 180 | Player.play(); 181 | 182 | // Data Assignments 183 | draft.player.queue = shuffled; 184 | draft.player.originalQueue = draft.playlist.currentTracks; 185 | draft.player.cursor = newindex; 186 | draft.player.playing = true; 187 | } else { 188 | Player.setAudioSrc(draft.playlist.currentTracks[index].source); 189 | Player.play(); 190 | // Data Assignments 191 | draft.player.queue = draft.playlist.currentTracks; 192 | draft.player.originalQueue = draft.playlist.currentTracks; 193 | draft.player.cursor = index; 194 | draft.player.playing = true; 195 | } 196 | 197 | let queue: number[] = draft.player.queue.map(value => value.id); 198 | let originalQueue: number[] = draft.player.originalQueue.map( 199 | value => value.id 200 | ); 201 | updatePlayerState(queue, originalQueue, draft.player.cursor); 202 | }); 203 | } 204 | 205 | export async function newQueueFromPlaylist(index: number, state?: AppStoreModel) { 206 | return produce(state, draft => { 207 | draft.player.queue = []; 208 | draft.player.originalQueue = []; 209 | 210 | draft.player.originalQueue.push(draft.playlist.currentTracks[index]); 211 | draft.player.queue.push(draft.playlist.currentTracks[index]); 212 | draft.player.cursor = 0; 213 | draft.player.playing = true; 214 | 215 | Player.setAudioSrc(draft.player.queue[0].source); 216 | Player.play(); 217 | updatePlayerState( 218 | [draft.player.queue[0].id], 219 | [draft.player.queue[0].id], 220 | 0 221 | ); 222 | }); 223 | } 224 | 225 | export async function existingQueueFromPlaylist(index: number, state?: AppStoreModel) { 226 | return produce(state, draft => { 227 | draft.player.originalQueue.push(draft.playlist.currentTracks[index]); 228 | draft.player.queue.push(draft.playlist.currentTracks[index]); 229 | 230 | if (draft.settings.player.shuffle && draft.player.queue.length > 1) { 231 | const trackId = draft.player.queue[draft.player.cursor].id; 232 | let shuffled = shuffleList([...draft.player.queue]); 233 | let newindex = shuffled.findIndex(i => i.id === trackId); 234 | 235 | draft.player.queue = shuffled; 236 | draft.player.cursor = newindex; 237 | } 238 | 239 | if (draft.player.queue.length === 1) { 240 | draft.player.cursor = 0; 241 | draft.player.playing = true; 242 | 243 | Player.setAudioSrc(draft.player.queue[0].source); 244 | Player.play(); 245 | } 246 | 247 | let queue: number[] = draft.player.queue.map(value => value.id); 248 | let originalQueue: number[] = draft.player.originalQueue.map( 249 | value => value.id 250 | ); 251 | updatePlayerState(queue, originalQueue, draft.player.cursor); 252 | }); 253 | } 254 | 255 | export async function removeFromQueue(index: number, state?: AppStoreModel) { 256 | return produce(state, draft => { 257 | let ind = draft.player.originalQueue.findIndex( 258 | element => element.id === draft.player.queue[index].id 259 | ); 260 | if (index < draft.player.cursor) { 261 | draft.player.cursor -= 1; 262 | } else if (index === draft.player.cursor) { 263 | Player.setAudioSrc(draft.player.queue[index + 1].source); 264 | Player.play(); 265 | } 266 | draft.player.originalQueue.splice(ind, 1); 267 | draft.player.queue.splice(index, 1); 268 | 269 | let queue: number[] = draft.player.queue.map(value => value.id); 270 | let originalQueue: number[] = draft.player.originalQueue.map( 271 | value => value.id 272 | ); 273 | updatePlayerState(queue, originalQueue, draft.player.cursor); 274 | }); 275 | } 276 | 277 | export async function clearQueue(state?: AppStoreModel) { 278 | return produce(state, draft => { 279 | draft.player.queue = []; 280 | draft.player.originalQueue = []; 281 | draft.player.playing = false; 282 | draft.player.cursor = -2; 283 | Player.reset(); 284 | updatePlayerState([], [], -2); 285 | }); 286 | } 287 | -------------------------------------------------------------------------------- /src/renderer/actions/PlaylistActions.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import produce from 'immer'; 3 | import * as path from 'path'; 4 | import { AppStoreModel } from '../stores/AppStoreModel'; 5 | import { PlaylistsDatabase } from '../database/PlaylistsDatabase'; 6 | import Notifications from '../libraries/Notifications'; 7 | 8 | export async function createFolderPlaylists(state?: AppStoreModel) { 9 | let db = new PlaylistsDatabase('playlists'); 10 | 11 | let folders = new Set(); 12 | let playlists: any = { root: [] }; 13 | let paths: string[]; 14 | state.library.forEach(track => { 15 | paths = track.source 16 | .slice(state.settings.library.path.length + 1) 17 | .split(path.sep); 18 | 19 | if (paths.length > 1) { 20 | if (folders.has(paths[0])) { 21 | playlists[paths[0]].push(track.id); 22 | } else { 23 | folders.add(paths[0]); 24 | playlists[paths[0]] = [track.id]; 25 | } 26 | } else { 27 | playlists['root'].push(track.id); 28 | } 29 | }); 30 | 31 | await db.folders.clear(); 32 | await db.transaction('rw', db.folders, async () => { 33 | for (let key of Object.keys(playlists)) { 34 | await db.folders.add({ 35 | name: key, 36 | tracks: playlists[key], 37 | type: 'folder', 38 | }); 39 | } 40 | }); 41 | 42 | playlists = await db.folders.toArray(); 43 | 44 | db.close(); 45 | 46 | return produce(state, draft => { 47 | draft.playlist.playlists = playlists; 48 | draft.playlist.currentPlaylist = playlists[0]; 49 | draft.playlist.currentTracks = playlists[0].tracks.map( 50 | (value: number) => { 51 | return draft.library.find(x => x.id === value); 52 | } 53 | ); 54 | }); 55 | } 56 | 57 | export async function changeActivePlaylist( 58 | index: number, 59 | state?: AppStoreModel 60 | ) { 61 | return produce(state, draft => { 62 | draft.playlist.currentPlaylist = draft.playlist.playlists[index]; 63 | draft.playlist.currentIndex = index; 64 | 65 | // prettier-ignore 66 | draft.playlist.currentTracks = draft.playlist.playlists[index].tracks.map(value => { 67 | return draft.library.find(x => x.id === value); 68 | }); 69 | }); 70 | } 71 | 72 | export async function renamePlaylist( 73 | index: number, 74 | name: string, 75 | state?: AppStoreModel 76 | ) { 77 | let id: number = state.playlist.playlists[index].id; 78 | let db = new PlaylistsDatabase('playlists'); 79 | if (state.playlist.currentPlaylist.type === 'folder') { 80 | await db.folders.update(id, { name }); 81 | } else { 82 | await db.playlists.update(id, { name }); 83 | } 84 | db.close(); 85 | 86 | return produce(state, draft => { 87 | draft.playlist.playlists[index].name = name; 88 | }); 89 | } 90 | 91 | export async function addNewPlaylist(state?: AppStoreModel) { 92 | let db = new PlaylistsDatabase('playlists'); 93 | let id = await db.playlists.add({ 94 | name: 'New Playlist', 95 | tracks: [], 96 | type: 'normal', 97 | }); 98 | db.close(); 99 | 100 | return produce(state, draft => { 101 | draft.playlist.playlists.push({ 102 | id, 103 | name: 'New Playlist', 104 | tracks: [], 105 | type: 'normal', 106 | }); 107 | }); 108 | } 109 | 110 | export async function deletePlaylist(state?: AppStoreModel) { 111 | if (state.playlist.currentPlaylist.type === 'normal') { 112 | let db = new PlaylistsDatabase('playlists'); 113 | 114 | await db.playlists.delete(state.playlist.currentPlaylist.id); 115 | 116 | db.close(); 117 | 118 | Notifications.addNotification( 119 | 'deletePlaylist', 120 | `Deleted Playlist: ${state.playlist.currentPlaylist.name}`, 121 | true 122 | ); 123 | 124 | // TODO: Reset the current playlist here. 125 | 126 | return produce(state, draft => { 127 | if (draft.playlist.playlists.length === 1) { 128 | draft.playlist = { 129 | currentPlaylist: { 130 | name: 'none', 131 | tracks: [], 132 | type: 'folder', 133 | }, 134 | currentIndex: 0, 135 | currentTracks: [], 136 | playlists: [], 137 | }; 138 | } else { 139 | let newIndex: number = draft.playlist.currentIndex - 1; 140 | draft.playlist.currentIndex = newIndex; 141 | draft.playlist.currentPlaylist = 142 | draft.playlist.playlists[newIndex]; 143 | // prettier-ignore 144 | draft.playlist.currentTracks = draft.playlist.playlists[newIndex].tracks.map( 145 | value => { 146 | return draft.library.find(x => x.id === value); 147 | } 148 | ); 149 | draft.playlist.playlists.splice(state.playlist.currentIndex, 1); 150 | } 151 | }); 152 | } 153 | return state; 154 | } 155 | 156 | export async function addTrackPlaylist( 157 | index: number, 158 | track: number, 159 | state?: AppStoreModel 160 | ) { 161 | let db = new PlaylistsDatabase('playlists'); 162 | await db.playlists.update(state.playlist.playlists[index].id, { 163 | tracks: [...state.playlist.playlists[index].tracks, track], 164 | }); 165 | db.close(); 166 | 167 | Notifications.addNotification( 168 | 'addSong', 169 | `Added Song to Playlist '${state.playlist.playlists[index].name}'`, 170 | true 171 | ); 172 | 173 | return produce(state, draft => { 174 | draft.playlist.playlists[index].tracks.push(track); 175 | }); 176 | } 177 | 178 | export async function removeTrackPlaylist( 179 | index: number, 180 | state?: AppStoreModel 181 | ) { 182 | let trackid = state.playlist.currentTracks[index].id; 183 | let playlistIndex = state.playlist.playlists.findIndex( 184 | x => x.id === state.playlist.currentPlaylist.id 185 | ); 186 | // prettier-ignore 187 | let trackIndex = state.playlist.playlists[playlistIndex].tracks.findIndex(x => x === trackid); 188 | 189 | let db = new PlaylistsDatabase('playlists'); 190 | let tracks = [...state.playlist.playlists[playlistIndex].tracks]; 191 | tracks.splice(trackIndex, 1); 192 | await db.playlists.update(state.playlist.currentPlaylist.id, { 193 | tracks, 194 | }); 195 | db.close(); 196 | 197 | Notifications.addNotification( 198 | 'removeSong', 199 | `Removed Song from the Playlist`, 200 | true 201 | ); 202 | 203 | return produce(state, draft => { 204 | draft.playlist.playlists[playlistIndex].tracks.splice(trackIndex, 1); 205 | 206 | draft.playlist.currentTracks.splice(index, 1); 207 | }); 208 | } 209 | -------------------------------------------------------------------------------- /src/renderer/actions/SearchActions.ts: -------------------------------------------------------------------------------- 1 | import { AppStoreModel } from '../stores/AppStoreModel'; 2 | // tslint:disable-next-line:import-name 3 | import produce from 'immer'; 4 | import { TrackModel } from '../database/TracksDatabase'; 5 | 6 | export async function searchLibrary(value: string, state?: AppStoreModel) { 7 | if (value === '') { 8 | return produce(state, draft => { 9 | draft.library = draft.originalLibrary; 10 | }); 11 | } 12 | return produce(state, draft => { 13 | draft.library = draft.originalLibrary.filter((row: TrackModel) => { 14 | // Match each of the three columns. Return if true 15 | if (row.common.title !== undefined) { 16 | if ( 17 | row.common.title 18 | .toLowerCase() 19 | .search(value.toLowerCase()) !== -1 20 | ) { 21 | return true; 22 | } 23 | } 24 | if (row.common.artist !== undefined) { 25 | if ( 26 | row.common.artist 27 | .toLowerCase() 28 | .search(value.toLowerCase()) !== -1 29 | ) { 30 | return true; 31 | } 32 | } 33 | if (row.common.album !== undefined) { 34 | if ( 35 | row.common.album 36 | .toLowerCase() 37 | .search(value.toLowerCase()) !== -1 38 | ) { 39 | return true; 40 | } 41 | } 42 | 43 | // If nothing matches 44 | return false; 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/actions/SettingsActions.ts: -------------------------------------------------------------------------------- 1 | import electronStore from 'electron-store'; 2 | // tslint:disable-next-line:import-name 3 | import produce, { DraftArray } from 'immer'; 4 | import { SettingsStoreModel, AppStoreModel } from '../stores/AppStoreModel'; 5 | import { remote, ipcRenderer } from 'electron'; 6 | const { dialog } = remote; 7 | 8 | export function getSettings(): SettingsStoreModel { 9 | const store = new electronStore({ 10 | name: 'settings', 11 | defaults: { 12 | library: { 13 | path: '', 14 | sortBy: 'artist', 15 | sortDirection: 'ASC', 16 | }, 17 | player: { 18 | shuffle: false, 19 | repeat: false, 20 | volume: 1.0, 21 | mute: false, 22 | }, 23 | columns: { 24 | columns: [ 25 | ['Title', true], 26 | ['Artist', true], 27 | ['Album', true], 28 | ['Album Artist', false], 29 | ['Duration', true], 30 | ['Genre', false], 31 | ['Bitrate', false], 32 | ['Added At', false], 33 | ['Modified At', false], 34 | ], 35 | }, 36 | system: { 37 | unobtrusive: true, 38 | zoomFactor: 1.0, 39 | }, 40 | playlist: { 41 | folder: true, 42 | }, 43 | }, 44 | }); 45 | 46 | return store.store; 47 | } 48 | 49 | export async function setLibraryPath( 50 | state?: AppStoreModel 51 | ): Promise { 52 | // Force the window to remain on top to work correctly when 53 | // the dialog is open 54 | ipcRenderer.send('SET_DIALOG_SHOW', true); 55 | return produce(state, draft => { 56 | const store = new electronStore({ 57 | name: 'settings', 58 | }); 59 | const path = dialog.showOpenDialog({ 60 | title: 'Select Music Library Folder', 61 | properties: ['openDirectory'], 62 | }); 63 | 64 | // Allow the window to be hidden after dialog close 65 | ipcRenderer.send('SET_DIALOG_SHOW', false); 66 | 67 | if (path !== undefined) { 68 | draft.settings.library.path = path[0]; 69 | store.store = draft.settings; 70 | } 71 | }); 72 | } 73 | 74 | export async function setShuffleMode( 75 | state?: AppStoreModel 76 | ): Promise { 77 | return produce(state, draft => { 78 | const store = new electronStore({ 79 | name: 'settings', 80 | }); 81 | draft.settings.player.shuffle = !draft.settings.player.shuffle; 82 | store.store = draft.settings; 83 | }); 84 | } 85 | 86 | export async function setRepeatMode( 87 | state?: AppStoreModel 88 | ): Promise { 89 | return produce(state, draft => { 90 | const store = new electronStore({ 91 | name: 'settings', 92 | }); 93 | draft.settings.player.repeat = !draft.settings.player.repeat; 94 | store.store = draft.settings; 95 | }); 96 | } 97 | 98 | export async function setLibrarySort( 99 | sortBy: string, 100 | sortDirection: 'ASC' | 'DESC', 101 | state?: AppStoreModel 102 | ): Promise { 103 | return produce(state, draft => { 104 | const store = new electronStore({ 105 | name: 'settings', 106 | }); 107 | draft.settings.library.sortBy = sortBy; 108 | draft.settings.library.sortDirection = sortDirection; 109 | store.store = draft.settings; 110 | }); 111 | } 112 | 113 | export async function setVolume( 114 | volume: number, 115 | state?: AppStoreModel 116 | ): Promise { 117 | return produce(state, draft => { 118 | const store = new electronStore({ 119 | name: 'settings', 120 | }); 121 | draft.settings.player.volume = volume; 122 | store.store = draft.settings; 123 | }); 124 | } 125 | 126 | export async function setMute( 127 | value: boolean, 128 | state?: AppStoreModel 129 | ): Promise { 130 | return produce(state, draft => { 131 | const store = new electronStore({ 132 | name: 'settings', 133 | }); 134 | draft.settings.player.mute = value; 135 | store.store = draft.settings; 136 | }); 137 | } 138 | 139 | export async function setUnobtrusiveMode( 140 | value: boolean, 141 | state?: AppStoreModel 142 | ): Promise { 143 | return produce(state, draft => { 144 | const store = new electronStore({ 145 | name: 'settings', 146 | }); 147 | draft.settings.system.unobtrusive = value; 148 | store.store = draft.settings; 149 | }); 150 | } 151 | 152 | export async function setFolderPlaylistMode( 153 | value: boolean, 154 | state?: AppStoreModel 155 | ): Promise { 156 | return produce(state, draft => { 157 | const store = new electronStore({ 158 | name: 'settings', 159 | }); 160 | draft.settings.playlist.folder = value; 161 | store.store = draft.settings; 162 | }); 163 | } 164 | 165 | export async function setZoomFactor( 166 | value: number, 167 | state?: AppStoreModel 168 | ): Promise { 169 | return produce(state, draft => { 170 | const store = new electronStore({ 171 | name: 'settings', 172 | }); 173 | draft.settings.system.zoomFactor = value; 174 | store.store = draft.settings; 175 | }); 176 | } 177 | 178 | export async function setColumns( 179 | value: Map, 180 | state?: AppStoreModel 181 | ): Promise { 182 | return produce(state, draft => { 183 | const store = new electronStore({ 184 | name: 'settings', 185 | }); 186 | draft.settings.columns.columns = >( 187 | (Array.from(value)) 188 | ); 189 | store.store = draft.settings; 190 | }); 191 | } 192 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface ButtonProps extends React.HTMLProps { 5 | type: 'default' | 'primary' | 'link'; 6 | icon?: boolean; 7 | } 8 | 9 | export class Button extends React.PureComponent { 10 | render() { 11 | const { type, className, icon, ...others } = this.props; 12 | 13 | const buildClassNames: string = classNames( 14 | 'btn', 15 | className, 16 | `btn-${type}`, 17 | { 'btn-icon': icon } 18 | ); 19 | 20 | return ( 21 | 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/components/elements/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface ButtonGroupProps extends React.HTMLProps {} 5 | 6 | export class ButtonGroup extends React.PureComponent { 7 | render() { 8 | const { className, ...others } = this.props; 9 | 10 | const buildClassNames: string = classNames('btn-group', className); 11 | 12 | return ( 13 |
14 | {this.props.children} 15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | import { Icon } from './Icon'; 4 | 5 | export interface CheckboxProps extends React.HTMLProps { 6 | handleCheckedState: (checked: boolean) => void; 7 | checked: boolean; 8 | } 9 | 10 | export interface CheckboxState { 11 | checked: boolean; 12 | } 13 | 14 | export class Checkbox extends React.PureComponent< 15 | CheckboxProps, 16 | CheckboxState 17 | > { 18 | constructor(props: CheckboxProps) { 19 | super(props); 20 | 21 | this.state = { 22 | checked: props.checked, 23 | }; 24 | } 25 | 26 | handleChange = () => { 27 | this.setState({ checked: !this.state.checked }, () => { 28 | this.props.handleCheckedState(this.state.checked); 29 | }); 30 | }; 31 | 32 | render() { 33 | return ( 34 |
35 | 41 | 42 |
43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/components/elements/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface ContextMenuProps { 5 | x: number; 6 | y: number; 7 | } 8 | 9 | interface ContextMenuState { 10 | isActive: boolean; 11 | x: number; 12 | y: number; 13 | prevProps: ContextMenuProps; 14 | } 15 | 16 | interface Position { 17 | top?: number; 18 | left?: number; 19 | } 20 | 21 | export class ContextMenu extends React.Component< 22 | ContextMenuProps, 23 | ContextMenuState 24 | > { 25 | contextMenu: HTMLDivElement | null; 26 | 27 | constructor(props: ContextMenuProps) { 28 | super(props); 29 | 30 | this.state = { 31 | isActive: false, 32 | x: 0, 33 | y: 0, 34 | prevProps: props, 35 | }; 36 | } 37 | 38 | hideMenu = () => { 39 | this.setState({ isActive: false, x: 0, y: 0 }); 40 | document.removeEventListener('click', this.handleClick); 41 | }; 42 | 43 | showMenu = (style: Position) => { 44 | this.contextMenu.style.top = `${style.top}px`; 45 | this.contextMenu.style.left = `${style.left}px`; 46 | 47 | document.addEventListener('click', this.handleClick); 48 | }; 49 | 50 | handleClick = (e: MouseEvent) => { 51 | if (this.contextMenu != null) { 52 | this.hideMenu(); 53 | } 54 | }; 55 | 56 | /* 57 | The Below Lifecycle methods contain the magic to make the context 58 | menu work. 59 | This is very dark and obscure magic. Even I do not what it is 60 | or what it does. 61 | Please do not interfere wtih this. Or you will loose you soul. 62 | */ 63 | static getDerivedStateFromProps( 64 | nextProps: ContextMenuProps, 65 | prevState: ContextMenuState 66 | ): any { 67 | if ( 68 | nextProps.x === prevState.prevProps.x && 69 | nextProps.y === prevState.prevProps.y && 70 | !prevState.isActive 71 | ) { 72 | return null; 73 | } 74 | 75 | return { 76 | x: nextProps.x, 77 | y: nextProps.y, 78 | isActive: true, 79 | prevProps: nextProps, 80 | }; 81 | } 82 | 83 | shouldComponentUpdate( 84 | nextProps: ContextMenuProps, 85 | nextState: ContextMenuState 86 | ) { 87 | if ( 88 | this.props.x === nextProps.x && 89 | this.props.y === nextProps.y && 90 | nextState.isActive 91 | ) { 92 | return false; 93 | } 94 | 95 | if ( 96 | nextState.x === this.state.x && 97 | nextState.y === this.state.y && 98 | !this.state.isActive 99 | ) { 100 | return false; 101 | } 102 | return true; 103 | } 104 | 105 | componentDidUpdate() { 106 | if (this.state.isActive) { 107 | let w = window.innerWidth; 108 | let h = window.innerHeight; 109 | 110 | let position: Position = { top: undefined, left: undefined }; 111 | 112 | if (this.props.x > w - this.contextMenu.offsetWidth) { 113 | position.left = this.props.x - this.contextMenu.offsetWidth; 114 | } else { 115 | position.left = this.props.x; 116 | } 117 | 118 | if (this.props.y > h - this.contextMenu.offsetHeight) { 119 | position.top = this.props.y - this.contextMenu.offsetHeight; 120 | } else { 121 | position.top = this.props.y; 122 | } 123 | this.showMenu(position); 124 | } 125 | } 126 | 127 | render() { 128 | let buildClassNames: string = classNames('context-menu', { 129 | active: this.state.isActive, 130 | }); 131 | 132 | return ( 133 |
{ 136 | this.contextMenu = elem; 137 | }} 138 | tabIndex={0}> 139 | {React.Children.map(this.props.children, child => { 140 | return React.cloneElement( 141 | child as React.ReactElement, 142 | { hideContextMenu: this.handleClick } 143 | ); 144 | })} 145 |
146 | ); 147 | } 148 | } 149 | 150 | export interface ContextMenuItemProps { 151 | readonly data: string; 152 | readonly onClick: (data: string) => void; 153 | readonly hideContextMenu?: () => void; 154 | } 155 | 156 | export class ContextMenuItem extends React.Component< 157 | ContextMenuItemProps, 158 | any 159 | > { 160 | handleClick = (event: React.MouseEvent) => { 161 | this.props.onClick(this.props.data); 162 | if (this.props.hideContextMenu !== undefined) { 163 | this.props.hideContextMenu(); 164 | } 165 | event.stopPropagation(); 166 | }; 167 | 168 | stopBlurBubbling = (e: React.FocusEvent) => { 169 | e.stopPropagation(); 170 | }; 171 | 172 | render() { 173 | return ( 174 |
178 | {this.props.children} 179 |
180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/renderer/components/elements/EditableTextbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | interface EditableTextboxProps { 5 | className?: string; 6 | text: string; 7 | onChange: (value: string) => void; 8 | editable?: boolean; 9 | } 10 | 11 | interface EditableTextboxState { 12 | value: string; 13 | editable: boolean; 14 | } 15 | 16 | export class EditableTextbox extends React.PureComponent< 17 | EditableTextboxProps, 18 | EditableTextboxState 19 | > { 20 | constructor(props: EditableTextboxProps) { 21 | super(props); 22 | 23 | this.state = { 24 | value: this.props.text, 25 | editable: false, 26 | }; 27 | } 28 | 29 | handleEditableDoubleClick = () => { 30 | this.setState({ editable: true }); 31 | }; 32 | 33 | onChange = (e: any) => { 34 | this.setState({ value: e.target.value }); 35 | }; 36 | 37 | handleSubmit = () => { 38 | this.props.onChange(this.state.value); 39 | this.setState({ editable: false }); 40 | }; 41 | 42 | handleKeyDown = (event: any) => { 43 | if (event.which === 27) { 44 | this.setState({ value: this.props.text, editable: false }); 45 | } else if (event.which === 13) { 46 | this.handleSubmit(); 47 | } 48 | }; 49 | 50 | render() { 51 | if (this.state.editable && this.props.editable) { 52 | const buildClassNames: string = classnames( 53 | 'editable-textbox', 54 | this.props.className 55 | ); 56 | return ( 57 | 65 | ); 66 | } 67 | 68 | return ( 69 |

72 | {this.state.value} 73 |

74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface IconProps { 5 | size?: string; 6 | className?: string; 7 | icon: string; 8 | } 9 | 10 | export class Icon extends React.PureComponent { 11 | public static defaultProps: Partial = { 12 | size: '16', 13 | className: '', 14 | }; 15 | constructor(props: IconProps) { 16 | super(props); 17 | } 18 | render() { 19 | const buildClassNames: string = classNames( 20 | 'mdi', 21 | `mdi-${this.props.size}px`, 22 | `mdi-${this.props.icon}`, 23 | this.props.className 24 | ); 25 | 26 | return ; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/components/elements/MessageBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from './Button'; 3 | import { Icon } from './Icon'; 4 | // tslint:disable-next-line:import-name 5 | import ReactModal from 'react-modal'; 6 | import { ButtonGroup } from './ButtonGroup'; 7 | 8 | interface MessageBoxProps { 9 | heading: string; 10 | message: string; 11 | actionPerformed: (action: string) => void; 12 | } 13 | 14 | interface MessageBoxState { 15 | isOpen: boolean; 16 | } 17 | 18 | export class MessageBox extends React.Component< 19 | MessageBoxProps, 20 | MessageBoxState 21 | > { 22 | constructor(props: MessageBoxProps) { 23 | super(props); 24 | ReactModal.setAppElement('#app'); 25 | 26 | this.state = { 27 | isOpen: false, 28 | }; 29 | } 30 | 31 | open = () => { 32 | this.setState({ isOpen: true }); 33 | }; 34 | 35 | private close = (action: string) => { 36 | this.setState({ isOpen: false }); 37 | this.props.actionPerformed(action); 38 | }; 39 | 40 | render() { 41 | return ( 42 | this.close('no')} 47 | className="message-box-container" 48 | overlayClassName="message-box-overlay"> 49 |
50 |
{this.props.heading}
51 | 57 |
58 |
59 |

{this.props.message}

60 |
61 |
62 |
63 | 64 | 69 | 72 | 73 |
74 | 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from './Button'; 3 | import { Icon } from './Icon'; 4 | // tslint:disable-next-line:import-name 5 | import ReactModal from 'react-modal'; 6 | 7 | export interface ModalProps { 8 | isOpen: boolean; 9 | heading: string; 10 | body: React.ReactNode; 11 | footer?: React.ReactNode; 12 | onRequestClose: () => void; 13 | } 14 | 15 | export class Modal extends React.Component { 16 | constructor(props: ModalProps) { 17 | super(props); 18 | ReactModal.setAppElement('#app'); 19 | } 20 | 21 | render() { 22 | return ( 23 | 29 |
30 |
{this.props.heading}
31 | 37 |
38 |
{this.props.body}
39 |
{this.props.footer}
40 |
41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from './Button'; 3 | 4 | export interface SliderProps { 5 | min?: number; 6 | max?: number; 7 | value?: number; 8 | step?: number; 9 | position?: string; 10 | readonly onChange?: (value: number) => void; 11 | } 12 | 13 | interface SliderState { 14 | value: number; 15 | displayValue: number; 16 | } 17 | 18 | class Slider extends React.Component { 19 | public static defaultProps: Partial = { 20 | step: 1, 21 | min: 0, 22 | max: 100, 23 | value: 0, 24 | position: 'top', 25 | }; 26 | 27 | constructor(props: SliderProps) { 28 | super(props); 29 | 30 | this.state = { 31 | value: this.props.value!, 32 | displayValue: this.roundValue( 33 | (this.props.value! - this.props.min!) / 34 | (this.props.max! - this.props.min!) * 35 | 100 36 | ), 37 | }; 38 | } 39 | 40 | roundValue = (value: number): number => { 41 | return Math.round(value); 42 | }; 43 | 44 | roundActualValue = (value: number): number => { 45 | let v: number = 46 | value * (this.props.max! - this.props.min!) / 100 + this.props.min!; 47 | return Math.round(v); 48 | }; 49 | 50 | handleTrackClick = (e: React.MouseEvent) => { 51 | let val = e.nativeEvent.offsetX / e.currentTarget.clientWidth * 100; 52 | 53 | this.props.onChange(this.roundActualValue(val)); 54 | }; 55 | 56 | handleThumbClick = (e: any) => { 57 | e.stopPropagation(); 58 | }; 59 | 60 | static getDerivedStateFromProps(nextProps: SliderProps) { 61 | return { 62 | value: nextProps.value!, 63 | displayValue: Math.round( 64 | (nextProps.value! - nextProps.min!) / 65 | (nextProps.max! - nextProps.min!) * 66 | 100 67 | ), 68 | }; 69 | } 70 | 71 | render() { 72 | return ( 73 |
74 |
81 |
88 |
89 | ); 90 | } 91 | } 92 | 93 | export default Slider; 94 | -------------------------------------------------------------------------------- /src/renderer/components/elements/TabBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface TabBarProps { 5 | className?: string; 6 | onTabClicked: (index: number) => void; 7 | } 8 | 9 | interface TabBarState { 10 | selectedIndex: number; 11 | } 12 | 13 | export class TabBar extends React.PureComponent { 14 | constructor(props: TabBarProps) { 15 | super(props); 16 | 17 | this.state = { 18 | selectedIndex: 0, 19 | }; 20 | } 21 | 22 | renderTabItems() { 23 | const children = this.props.children as ReadonlyArray< 24 | JSX.Element 25 | > | null; 26 | 27 | if (!children) { 28 | return null; 29 | } 30 | 31 | return children.map((child, index) => { 32 | const selected: boolean = this.state.selectedIndex === index; 33 | return ( 34 | 39 | {child} 40 | 41 | ); 42 | }); 43 | } 44 | 45 | onTabClicked = (index: number) => { 46 | // Perform Tab Active Swithcing Logic Here 47 | this.setState({ selectedIndex: index }); 48 | 49 | // Callback to parent class 50 | this.props.onTabClicked(index); 51 | }; 52 | 53 | render() { 54 | const buildClassNames: string = classNames( 55 | 'tab-bar', 56 | this.props.className 57 | ); 58 | 59 | return
{this.renderTabItems()}
; 60 | } 61 | } 62 | 63 | export interface TabItemProps { 64 | className?: string; 65 | onTabClicked: (index: number) => void; 66 | index: number; 67 | selected: boolean; 68 | } 69 | 70 | export class TabItem extends React.PureComponent { 71 | handleTabClicked = () => { 72 | this.props.onTabClicked(this.props.index); 73 | }; 74 | render() { 75 | const buildClassNames: string = classNames( 76 | 'tab-bar-item', 77 | { selected: this.props.selected }, 78 | this.props.className 79 | ); 80 | 81 | return ( 82 |
83 | {this.props.children} 84 |
85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer/components/elements/Textbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | export interface TextboxProps extends React.HTMLProps {} 5 | 6 | export class Textbox extends React.PureComponent { 7 | render() { 8 | const { className, ...others } = this.props; 9 | 10 | const buildClassNames: string = classNames('textbox', className); 11 | 12 | return ; 13 | } 14 | } 15 | 16 | export interface TextboxAddonProps extends React.HTMLProps { 17 | direction: 'left' | 'right'; 18 | } 19 | 20 | export class TextboxAddon extends React.PureComponent { 21 | render() { 22 | const { className, direction, children, ...others } = this.props; 23 | 24 | const buildClassNames: string = classNames( 25 | 'textbox-addon', 26 | className, 27 | direction 28 | ); 29 | 30 | return ( 31 |
32 | {children} 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/components/header/DragRegion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface DragRegionProps { 4 | drag: boolean; 5 | } 6 | 7 | export interface DragRegionState { 8 | drag: boolean | undefined; 9 | } 10 | 11 | export default class DragRegion extends React.Component< 12 | DragRegionProps, 13 | DragRegionState 14 | > { 15 | constructor(props: DragRegionProps) { 16 | super(props); 17 | 18 | this.state = { 19 | drag: undefined, 20 | }; 21 | } 22 | 23 | static getDerivedStateFromProps( 24 | nextProps: DragRegionProps, 25 | prevState: DragRegionState 26 | ) { 27 | if (prevState.drag === undefined) { 28 | return { drag: nextProps.drag }; 29 | } 30 | return null; 31 | } 32 | 33 | shouldComponentUpdate( 34 | nextProps: DragRegionProps, 35 | nextState: DragRegionState 36 | ) { 37 | if (this.state.drag === undefined) { 38 | return true; 39 | } 40 | return false; 41 | } 42 | 43 | render() { 44 | return ( 45 | <> 46 |
47 | 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/renderer/components/header/HeaderActions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from '../elements/Button'; 3 | import { Icon } from '../elements/Icon'; 4 | import { Modal } from '../elements/Modal'; 5 | import SettingsContainer from '../../containers/SettingsContainer'; 6 | 7 | export interface HeaderActionsProps {} 8 | 9 | export interface HeaderActionsState { 10 | isSettingsOpen: boolean; 11 | } 12 | 13 | export default class Shell extends React.PureComponent< 14 | HeaderActionsProps, 15 | HeaderActionsState 16 | > { 17 | constructor(props: HeaderActionsProps) { 18 | super(props); 19 | 20 | this.state = { 21 | isSettingsOpen: false, 22 | }; 23 | } 24 | 25 | handleOpenModal = () => { 26 | this.setState({ isSettingsOpen: true }); 27 | }; 28 | 29 | handleCloseModal = () => { 30 | this.setState({ isSettingsOpen: false }); 31 | }; 32 | 33 | render() { 34 | return ( 35 | <> 36 | } 41 | /> 42 | 48 | 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/renderer/components/header/ModeTabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { TabBar } from '../elements/TabBar'; 3 | import AppStore from '../../stores/AppStore'; 4 | 5 | interface ModeTabsProps { 6 | changeMainTab: (activeMainTab: number) => void; 7 | } 8 | 9 | interface ModeTabsState {} 10 | 11 | export default class ModeTabs extends React.Component< 12 | ModeTabsProps, 13 | ModeTabsState 14 | > { 15 | constructor(props: ModeTabsProps) { 16 | super(props); 17 | } 18 | 19 | handleTabClick = (index: number) => { 20 | this.props.changeMainTab(index); 21 | }; 22 | 23 | render() { 24 | return ( 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/components/header/Search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Textbox, TextboxAddon } from '../elements/Textbox'; 3 | import { Icon } from '../elements/Icon'; 4 | import AppStore from '../../stores/AppStore'; 5 | 6 | interface SearchProps { 7 | store: AppStore; 8 | } 9 | 10 | interface SearchState { 11 | value: string; 12 | } 13 | 14 | export default class Search extends React.Component { 15 | constructor(props: SearchProps) { 16 | super(props); 17 | 18 | this.state = { 19 | value: '', 20 | }; 21 | } 22 | 23 | handleChange = (event: any) => { 24 | this.setState({ value: event.target.value }); 25 | this.props.store.search.searchLibrary(event.target.value); 26 | }; 27 | 28 | render() { 29 | return ( 30 | <> 31 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/components/header/WindowControls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as os from 'os'; 3 | import { ButtonGroup } from '../elements/ButtonGroup'; 4 | import { Button } from '../elements/Button'; 5 | import { ipcRenderer } from 'electron'; 6 | 7 | export interface WindowControlsProps { 8 | unobtrusive: boolean; 9 | } 10 | 11 | export interface WindowControlsState { 12 | unobtrusive: boolean | undefined; 13 | } 14 | 15 | export default class Shell extends React.Component< 16 | WindowControlsProps, 17 | WindowControlsState 18 | > { 19 | constructor(props: WindowControlsProps) { 20 | super(props); 21 | this.state = { 22 | unobtrusive: undefined, 23 | }; 24 | } 25 | 26 | handleMinimizeClick = () => { 27 | ipcRenderer.send('WINDOW_MINIMIZE'); 28 | }; 29 | 30 | handleMaximizeClick = () => { 31 | ipcRenderer.send('WINDOW_MAXIMIZE'); 32 | }; 33 | 34 | handleCloseClick = () => { 35 | ipcRenderer.send('WINDOW_QUIT'); 36 | }; 37 | 38 | static getDerivedStateFromProps( 39 | nextProps: WindowControlsProps, 40 | prevState: WindowControlsState 41 | ) { 42 | if (prevState.unobtrusive === undefined) { 43 | return { unobtrusive: nextProps.unobtrusive }; 44 | } 45 | return null; 46 | } 47 | 48 | shouldComponentUpdate() { 49 | if (os.platform() === 'darwin') { 50 | return false; 51 | } 52 | return true; 53 | } 54 | 55 | renderControls = () => { 56 | if (os.platform() === 'darwin') { 57 | return
; 58 | } 59 | 60 | return ( 61 |
62 | 63 | {this.state.unobtrusive && ( 64 | 72 | )} 73 | {this.state.unobtrusive && ( 74 | 89 | )} 90 | 99 | 100 |
101 | ); 102 | }; 103 | 104 | render() { 105 | return this.renderControls(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/renderer/components/library/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as emptyState from '../../../../static/vectors/emptyState.svg'; 3 | 4 | export default class EmptyState extends React.Component { 5 | shouldComponentUpdate() { 6 | return false; 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 |
13 | 14 |
15 |

16 | Your Music Library is Empty. 17 |

18 |
19 | Please Goto Settings > Library on Top-Right Corner and 20 | Select the Folder which contains your music files. 21 |
22 |
23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/components/player/Controls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Queue from './Queue'; 3 | import { ButtonGroup } from '../elements/ButtonGroup'; 4 | import { Button } from '../elements/Button'; 5 | import { Icon } from '../elements/Icon'; 6 | import Slider from '../elements/Slider'; 7 | import AppStore from '../../stores/AppStore'; 8 | import Player from '../../libraries/Player'; 9 | import { parseToMinutes } from '../../utilities/TimeUtils'; 10 | import { ipcRenderer } from 'electron'; 11 | 12 | export interface ControlsProps { 13 | store: AppStore; 14 | } 15 | 16 | interface ControlsState { 17 | duration: number; 18 | isQueueEmpty: boolean; 19 | } 20 | 21 | export default class Controls extends React.Component< 22 | ControlsProps, 23 | ControlsState 24 | > { 25 | constructor(props: ControlsProps) { 26 | super(props); 27 | 28 | this.state = { 29 | duration: 0, 30 | isQueueEmpty: true, 31 | }; 32 | } 33 | 34 | onPlayerTimeUpdate = () => { 35 | this.setState({ 36 | duration: Math.floor(Player.getCurrentTime()), 37 | }); 38 | }; 39 | 40 | onPlayerEnded = () => { 41 | this.props.store.player.nextSong(); 42 | }; 43 | 44 | handleDurationChange = (value: number) => { 45 | if (this.state.isQueueEmpty) return; 46 | 47 | Player.setCurrentTime(value); 48 | }; 49 | 50 | handleVolumeChange = (value: number) => { 51 | Player.setVolume(value / 10); 52 | this.props.store.settings.setVolume(value / 10); 53 | }; 54 | 55 | handleMuteChange = () => { 56 | Player.isMuted() ? Player.unmute() : Player.mute(); 57 | this.props.store.settings.setMute(Player.isMuted()); 58 | }; 59 | 60 | handleNextTrack = () => { 61 | if (this.state.isQueueEmpty) return; 62 | 63 | this.props.store.player.nextSong(); 64 | }; 65 | 66 | handlePrevTrack = () => { 67 | if (this.state.isQueueEmpty) return; 68 | 69 | if (this.state.duration > 15) { 70 | Player.replay(); 71 | } else { 72 | this.props.store.player.prevSong(); 73 | } 74 | }; 75 | 76 | handlePlayPause = () => { 77 | if (this.state.isQueueEmpty) return; 78 | 79 | this.props.store.player.togglePlayPause(); 80 | }; 81 | 82 | handleShuffle = () => { 83 | this.props.store.settings.setShuffleMode().then(() => { 84 | if (!this.state.isQueueEmpty) { 85 | this.props.store.player.shuffleToggle(); 86 | } 87 | }); 88 | }; 89 | 90 | handleRepeat = () => { 91 | this.props.store.settings.setRepeatMode(); 92 | }; 93 | 94 | componentDidMount() { 95 | Player.getInstance().addEventListener( 96 | 'timeupdate', 97 | this.onPlayerTimeUpdate 98 | ); 99 | Player.getInstance().addEventListener('ended', this.onPlayerEnded); 100 | 101 | ipcRenderer.addListener( 102 | 'PLAYER_CONTROLS_TOGGLE_PLAY', 103 | this.handlePlayPause 104 | ); 105 | ipcRenderer.addListener( 106 | 'PLAYER_CONTROLS_PREV_TRACK', 107 | this.handlePrevTrack 108 | ); 109 | ipcRenderer.addListener( 110 | 'PLAYER_CONTROLS_NEXT_TRACK', 111 | this.handleNextTrack 112 | ); 113 | } 114 | 115 | componentWillUnmount() { 116 | Player.getInstance().removeEventListener( 117 | 'timeupdate', 118 | this.onPlayerTimeUpdate 119 | ); 120 | Player.getInstance().removeEventListener('ended', this.onPlayerEnded); 121 | ipcRenderer.removeListener( 122 | 'PLAYER_CONTROLS_TOGGLE_PLAY', 123 | this.handlePlayPause 124 | ); 125 | ipcRenderer.removeListener( 126 | 'PLAYER_CONTROLS_PREV_TRACK', 127 | this.handlePrevTrack 128 | ); 129 | ipcRenderer.removeListener( 130 | 'PLAYER_CONTROLS_NEXT_TRACK', 131 | this.handleNextTrack 132 | ); 133 | } 134 | 135 | static getDerivedStateFromProps( 136 | nextProps: ControlsProps 137 | ): Partial { 138 | if (nextProps.store.state.player.queue.length === 0) { 139 | return { isQueueEmpty: true }; 140 | } 141 | 142 | return { isQueueEmpty: false }; 143 | } 144 | 145 | render() { 146 | return ( 147 | <> 148 |
149 | 150 | 158 | 173 | 181 | 182 |
183 | {parseToMinutes(this.state.duration)} 184 | 190 | 191 | {parseToMinutes(Math.floor(Player.getDuration()))} 192 | 193 |
194 | 195 | 215 | 231 | 232 | 233 |
234 | 249 | 255 |
256 |
257 | 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/renderer/components/player/Details.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import * as defaultAlbumArt from '../../../../static/vectors/defaultAlbumArt.svg'; 4 | import AppStore from '../../stores/AppStore'; 5 | import { parseFile } from 'music-metadata/lib'; 6 | import { IAudioMetadata, IPicture } from 'music-metadata/lib/type'; 7 | 8 | export interface DetailsProps { 9 | store: AppStore; 10 | } 11 | 12 | interface DetailsState { 13 | albumart: string; 14 | } 15 | 16 | export default class Details extends React.Component< 17 | DetailsProps, 18 | DetailsState 19 | > { 20 | constructor(props: DetailsProps) { 21 | super(props); 22 | 23 | this.state = { 24 | albumart: defaultAlbumArt, 25 | }; 26 | } 27 | 28 | fetchAlbumArt = async (path: string) => { 29 | let model: IAudioMetadata = await parseFile(path); 30 | let img: IPicture[] | undefined = model.common.picture; 31 | if (img !== undefined) { 32 | let imgURL = window.URL.createObjectURL( 33 | new Blob([img[0].data], { type: `image/${img[0].format}` }) 34 | ); 35 | this.setState({ albumart: imgURL }); 36 | } else { 37 | this.setState({ albumart: defaultAlbumArt }); 38 | } 39 | }; 40 | 41 | componentDidUpdate(prevProps: DetailsProps, prevState: DetailsState) { 42 | const { cursor, queue } = this.props.store.state.player; 43 | if (cursor !== prevProps.store.state.player.cursor) { 44 | if (cursor === -2) { 45 | this.setState({ albumart: defaultAlbumArt }); 46 | return; 47 | } 48 | this.fetchAlbumArt(queue[cursor].source); 49 | } 50 | } 51 | 52 | render() { 53 | const { cursor, queue } = this.props.store.state.player; 54 | const track = queue[cursor]; 55 | return ( 56 | <> 57 | 58 |
59 |

{track ? track.common.title : 'Untitled'}

60 |
on
61 |

{track ? track.common.album : 'Untitled'}

62 |
by
63 |

{track ? track.common.artist : 'Untitled'}

64 |
65 | 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/components/player/Queue.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | // tslint:disable-next-line:import-name 3 | import Popover from 'react-tiny-popover'; 4 | import AppStore from '../../stores/AppStore'; 5 | import { ButtonGroup } from '../elements/ButtonGroup'; 6 | import { Button } from '../elements/Button'; 7 | import { Icon } from '../elements/Icon'; 8 | import { TrackModel } from '../../database/TracksDatabase'; 9 | import { AutoSizer, List, ListRowProps } from 'react-virtualized'; 10 | 11 | export interface QueueProps { 12 | store: AppStore; 13 | } 14 | 15 | interface QueueState { 16 | isPopoverOpen: boolean; 17 | } 18 | 19 | export default class Queue extends React.Component { 20 | constructor(props: QueueProps) { 21 | super(props); 22 | 23 | this.state = { 24 | isPopoverOpen: false, 25 | }; 26 | } 27 | 28 | handleDoubleRowClick = ( 29 | e: React.MouseEvent, 30 | index: number 31 | ) => { 32 | this.props.store.player.seekSong(index); 33 | }; 34 | 35 | rowRenderer = ({ 36 | key, 37 | index, 38 | isScrolling, 39 | isVisible, 40 | style, 41 | }: ListRowProps) => { 42 | let track: TrackModel = this.props.store.state.player.queue[index]; 43 | let current: boolean = index === this.props.store.state.player.cursor; 44 | return ( 45 |
46 |
{ 49 | this.handleDoubleRowClick(e, index); 50 | }}> 51 |
{track.common.title}
52 |

53 | {track.common.artist} - {track.common.album} 54 |

55 |
56 | 64 |
65 | ); 66 | }; 67 | 68 | render() { 69 | return ( 70 | this.setState({ isPopoverOpen: false })} 75 | content={ 76 |
77 |
78 |
Currently Playing
79 | 87 |
88 |
89 | 90 | {({ height, width }) => ( 91 | 110 | )} 111 | 112 |
113 |
114 | }> 115 | 116 | 126 | 127 |
128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/renderer/components/playlist/AddTrack.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AppStoreConsumer } from '../../stores/AppContext'; 3 | 4 | interface AddTrackProps { 5 | trackId: number; 6 | } 7 | 8 | interface AddTrackState {} 9 | 10 | export default class AddTrack extends React.Component< 11 | AddTrackProps, 12 | AddTrackState 13 | > { 14 | render() { 15 | return ( 16 | 17 | {store => ( 18 |
19 | {store.state.playlist.playlists.map((obj, index) => { 20 | if (obj.type === 'normal') { 21 | return ( 22 |
26 | store.playlist.addTrackPlaylist( 27 | index, 28 | this.props.trackId 29 | ) 30 | }> 31 |

{obj.name}

32 |
33 | ); 34 | } 35 | })} 36 |
37 | )} 38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/components/playlist/EmptyPlaylist.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as emptyState from '../../../../static/vectors/emptyState.svg'; 3 | 4 | export default class EmptyPlaylist extends React.Component { 5 | shouldComponentUpdate() { 6 | return false; 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 |
13 | 14 |
15 |

Your Playlist is Empty.

16 |
17 | Please Goto To Your Libary, Right Click on the Track and 18 | Select 'Add to Playlist'. 19 |
20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/components/playlist/SidePanel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AppStoreConsumer } from '../../stores/AppContext'; 3 | import { ButtonGroup } from '../elements/ButtonGroup'; 4 | import { Button } from '../elements/Button'; 5 | import { Icon } from '../elements/Icon'; 6 | import { EditableTextbox } from '../elements/EditableTextbox'; 7 | import AppStore from '../../stores/AppStore'; 8 | import { MessageBox } from '../elements/MessageBox'; 9 | 10 | interface SidePanelProps { 11 | store: AppStore; 12 | } 13 | 14 | interface SidePanelState {} 15 | 16 | export default class SidePanel extends React.Component< 17 | SidePanelProps, 18 | SidePanelState 19 | > { 20 | messageBoxRef: React.RefObject; 21 | 22 | constructor(props: SidePanelProps) { 23 | super(props); 24 | 25 | this.messageBoxRef = React.createRef(); 26 | } 27 | 28 | showDeleteMessageBox = () => { 29 | if (this.props.store.state.playlist.currentPlaylist.type === 'normal') { 30 | this.messageBoxRef.current.open(); 31 | } 32 | }; 33 | 34 | confirmDelete = (action: string) => { 35 | if (action === 'yes') { 36 | this.props.store.playlist.deletePlaylist(); 37 | } 38 | }; 39 | 40 | render() { 41 | return ( 42 |
43 | 49 |
50 |
Playlists
51 | 52 | 62 | 70 | 71 |
72 |
73 | {this.props.store.state.playlist.playlists.map( 74 | (obj, index) => ( 75 |
77 | this.props.store.playlist.changeActivePlaylist( 78 | index 79 | ) 80 | } 81 | key={index} 82 | className={ 83 | index === 84 | this.props.store.state.playlist.currentIndex 85 | ? 'list-row current' 86 | : 'list-row' 87 | }> 88 | 91 | this.props.store.playlist.renamePlaylist( 92 | index, 93 | value 94 | ) 95 | } 96 | editable={obj.type === 'normal'} 97 | /> 98 |
99 | ) 100 | )} 101 |
102 |
103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/renderer/components/settings/Columns.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import AppStore from '../../stores/AppStore'; 3 | import { Checkbox } from '../elements/Checkbox'; 4 | 5 | export interface ColumnsSettingsProps { 6 | store: AppStore; 7 | } 8 | export interface ColumnsSettingsState {} 9 | 10 | export default class ColumnsSettings extends React.Component< 11 | ColumnsSettingsProps, 12 | ColumnsSettingsState 13 | > { 14 | constructor(props: ColumnsSettingsProps) { 15 | super(props); 16 | } 17 | 18 | handleColumnCheckedChange = (value: string, checked: boolean) => { 19 | let map: Map = new Map( 20 | this.props.store.state.settings.columns.columns 21 | ); 22 | 23 | map.set(value, checked); 24 | 25 | this.props.store.settings.setColumns(map); 26 | }; 27 | 28 | render() { 29 | const { store } = this.props; 30 | return ( 31 |
32 | {store.state.settings.columns.columns.map((column, index) => { 33 | return ( 34 | 37 | this.handleColumnCheckedChange( 38 | column[0], 39 | checked 40 | ) 41 | } 42 | checked={column[1]} 43 | value={column[0]} 44 | /> 45 | ); 46 | })} 47 |
48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/renderer/components/settings/Library.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Textbox } from '../elements/Textbox'; 3 | import { ButtonGroup } from '../elements/ButtonGroup'; 4 | import { Button } from '../elements/Button'; 5 | import AppStore from '../../stores/AppStore'; 6 | 7 | export interface LibrarySettingsProps { 8 | store: AppStore; 9 | } 10 | 11 | export interface LibrarySettingsState {} 12 | 13 | export default class LibrarySettings extends React.Component< 14 | LibrarySettingsProps, 15 | LibrarySettingsState 16 | > { 17 | constructor(props: LibrarySettingsProps) { 18 | super(props); 19 | } 20 | 21 | handlePathChange = () => { 22 | this.props.store.settings.setLibraryPath(); 23 | }; 24 | 25 | handleRefreshLibrary = () => { 26 | this.props.store.library 27 | .refreshLibrary() 28 | .then(() => this.props.store.playlist.createFolderPlaylists()); 29 | }; 30 | 31 | shouldComponentUpdate( 32 | nextProps: LibrarySettingsProps, 33 | nextState: LibrarySettingsState 34 | ) { 35 | return ( 36 | this.props.store.state.settings.library !== 37 | nextProps.store.state.settings.library 38 | ); 39 | } 40 | 41 | render() { 42 | const { store } = this.props; 43 | return ( 44 |
45 | 46 | 51 | 52 | 55 | 56 |
57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/components/settings/Playlist.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import AppStore from '../../stores/AppStore'; 3 | import { Checkbox } from '../elements/Checkbox'; 4 | 5 | export interface PlaylistSettingsProps { 6 | store: AppStore; 7 | } 8 | 9 | export interface PlaylistSettingsState {} 10 | 11 | export default class PlaylistSettings extends React.Component< 12 | PlaylistSettingsProps, 13 | PlaylistSettingsState 14 | > { 15 | constructor(props: PlaylistSettingsProps) { 16 | super(props); 17 | } 18 | 19 | handleFolderPlaylists = (checked: boolean) => { 20 | this.props.store.settings.setFolderPlaylistMode(checked); 21 | }; 22 | 23 | render() { 24 | const { store } = this.props; 25 | return ( 26 |
27 | 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/components/settings/System.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Textbox } from '../elements/Textbox'; 3 | import { ButtonGroup } from '../elements/ButtonGroup'; 4 | import { Button } from '../elements/Button'; 5 | import AppStore from '../../stores/AppStore'; 6 | import Notifications from '../../libraries/Notifications'; 7 | import { Checkbox } from '../elements/Checkbox'; 8 | import { Icon } from '../elements/Icon'; 9 | import { webFrame } from 'electron'; 10 | 11 | export interface SystemSettingsProps { 12 | store: AppStore; 13 | } 14 | 15 | export interface SystemSettingsState {} 16 | 17 | export default class SystemSettings extends React.Component< 18 | SystemSettingsProps, 19 | SystemSettingsState 20 | > { 21 | constructor(props: SystemSettingsProps) { 22 | super(props); 23 | } 24 | 25 | handleObtrusiveCheckbox = (checked: boolean) => { 26 | this.props.store.settings.setUnobtrusiveMode(checked); 27 | }; 28 | 29 | handleZoomPlus = () => { 30 | if (webFrame.getZoomFactor() < 4) { 31 | let value = parseFloat((webFrame.getZoomFactor() + 0.1).toFixed(1)); 32 | webFrame.setZoomFactor(value); 33 | this.props.store.settings.setZoomFactor(value); 34 | } 35 | }; 36 | 37 | handleZoomMinus = () => { 38 | if (webFrame.getZoomFactor() > 0) { 39 | let value = parseFloat((webFrame.getZoomFactor() - 0.1).toFixed(1)); 40 | webFrame.setZoomFactor(value); 41 | this.props.store.settings.setZoomFactor(value); 42 | } 43 | }; 44 | 45 | handleResetZoom = () => { 46 | webFrame.setZoomFactor(1); 47 | }; 48 | 49 | render() { 50 | const { store } = this.props; 51 | return ( 52 |
53 | 54 |
55 | 61 | 67 | 73 | 76 |
77 |
78 |
79 | These settings will work only after restarting the app 80 |
81 |
82 | 87 |
88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/renderer/containers/HeaderContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import WindowControls from '../components/header/WindowControls'; 3 | import HeaderActions from '../components/header/HeaderActions'; 4 | import Search from '../components/header/Search'; 5 | import { AppStoreConsumer } from '../stores/AppContext'; 6 | import DragRegion from '../components/header/DragRegion'; 7 | import ModeTabs from '../components/header/ModeTabs'; 8 | import { LayoutContextConsumer } from '../stores/LayoutContext'; 9 | 10 | export interface HeaderContainerProps {} 11 | 12 | export interface HeaderContainerState {} 13 | 14 | export default class HeaderContainer extends React.Component< 15 | HeaderContainerProps, 16 | HeaderContainerState 17 | > { 18 | constructor(props: HeaderContainerProps) { 19 | super(props); 20 | } 21 | 22 | componentDidMount() { 23 | // All the states that require database or IO are initialized here 24 | } 25 | 26 | render() { 27 | return ( 28 | 29 | {store => ( 30 |
31 | 32 | {store => } 33 | 34 | 37 |
38 | 39 |
40 |
41 | 42 |
43 | 48 |
49 | )} 50 |
51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/containers/LibraryContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Songs from '../components/library/Songs'; 4 | import AppStore from '../stores/AppStore'; 5 | import { AppStoreConsumer } from '../stores/AppContext'; 6 | 7 | export interface LibraryContainerProps {} 8 | 9 | export default class LibraryContainer extends React.Component< 10 | LibraryContainerProps, 11 | any 12 | > { 13 | constructor(props: LibraryContainerProps) { 14 | super(props); 15 | } 16 | 17 | render() { 18 | return ( 19 | <> 20 | 21 | {store => } 22 | 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/containers/NotificationContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { CSSTransition, TransitionGroup } from 'react-transition-group'; 4 | import Notifications from '../libraries/Notifications'; 5 | 6 | interface NotificationContainerProps {} 7 | 8 | interface NotificationContainerState { 9 | notifications: Map; 10 | } 11 | 12 | export default class NotificationContainer extends React.Component< 13 | NotificationContainerProps, 14 | NotificationContainerState 15 | > { 16 | constructor(props: any) { 17 | super(props); 18 | this.state = { 19 | notifications: new Map(), 20 | }; 21 | } 22 | 23 | addNotification = (args: { notifications: Map }) => { 24 | this.setState({ notifications: new Map(args.notifications) }); 25 | }; 26 | 27 | removeNotification = (args: { notifications: Map }) => { 28 | this.setState({ notifications: new Map(args.notifications) }); 29 | }; 30 | 31 | componentDidMount() { 32 | Notifications.addListener('ADD_NOTIF', this.addNotification); 33 | Notifications.addListener('DEL_NOTIF', this.removeNotification); 34 | } 35 | 36 | componentWillUnmount() { 37 | Notifications.removeListener('ADD_NOTIF', this.addNotification); 38 | Notifications.removeListener('DEL_NOTIF', this.removeNotification); 39 | } 40 | 41 | shouldComponentUpdate( 42 | nextProps: NotificationContainerProps, 43 | nextState: NotificationContainerState 44 | ) { 45 | if (this.state.notifications.size === nextState.notifications.size) { 46 | return false; 47 | } 48 | return true; 49 | } 50 | 51 | render() { 52 | let values: any[] = []; 53 | this.state.notifications.forEach((value: string, key: string) => 54 | values.push( 55 | 56 |
57 |
{value}
58 |
59 |
60 | ) 61 | ); 62 | return ( 63 | 64 | {values} 65 | 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/containers/PlayerContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Details from '../components/player/Details'; 3 | import Controls from '../components/player/Controls'; 4 | import AppStore from '../stores/AppStore'; 5 | import { AppStoreConsumer } from '../stores/AppContext'; 6 | 7 | export interface PlayerContainerProps {} 8 | 9 | export default class PlayerContainer extends React.Component< 10 | PlayerContainerProps, 11 | any 12 | > { 13 | constructor(props: PlayerContainerProps) { 14 | super(props); 15 | } 16 | 17 | render() { 18 | return ( 19 | 20 | {store => ( 21 |
22 |
23 | 24 |
25 | )} 26 |
27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/containers/PlaylistContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import SidePanel from '../components/playlist/SidePanel'; 3 | import Tracks from '../components/playlist/Tracks'; 4 | import { AppStoreConsumer } from '../stores/AppContext'; 5 | 6 | export interface PlaylistContainerProps {} 7 | 8 | export default class PlaylistContainer extends React.Component< 9 | PlaylistContainerProps, 10 | any 11 | > { 12 | constructor(props: PlaylistContainerProps) { 13 | super(props); 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | {store => ( 20 |
21 | 22 | 23 |
24 | )} 25 |
26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/containers/SettingsContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { TabBar } from '../components/elements/TabBar'; 3 | import Library from '../components/settings/Library'; 4 | import System from '../components/settings/System'; 5 | import AppStore from '../stores/AppStore'; 6 | import { AppStoreConsumer } from '../stores/AppContext'; 7 | import Playlist from '../components/settings/Playlist'; 8 | import Columns from '../components/settings/Columns'; 9 | 10 | enum TabItemsOrder { 11 | Library, 12 | Playlist, 13 | System, 14 | Columns, 15 | } 16 | 17 | export interface SettingsContainerProps {} 18 | 19 | interface SettingsContainerState { 20 | activeTabIndex: TabItemsOrder; 21 | } 22 | 23 | export default class SettingsContainer extends React.Component< 24 | SettingsContainerProps, 25 | SettingsContainerState 26 | > { 27 | constructor(props: SettingsContainerProps) { 28 | super(props); 29 | 30 | this.state = { 31 | activeTabIndex: TabItemsOrder.Library, 32 | }; 33 | } 34 | 35 | handleTabClick = (index: number) => { 36 | this.setState({ activeTabIndex: index }); 37 | }; 38 | 39 | renderActiveTabItem() { 40 | switch (this.state.activeTabIndex) { 41 | case TabItemsOrder.Library: { 42 | return ( 43 | 44 | {store => } 45 | 46 | ); 47 | } 48 | 49 | case TabItemsOrder.System: { 50 | return ( 51 | 52 | {store => } 53 | 54 | ); 55 | } 56 | 57 | case TabItemsOrder.Playlist: { 58 | return ( 59 | 60 | {store => } 61 | 62 | ); 63 | } 64 | 65 | case TabItemsOrder.Columns: { 66 | return ( 67 | 68 | {store => } 69 | 70 | ); 71 | } 72 | } 73 | } 74 | 75 | render() { 76 | return ( 77 | <> 78 | 79 | Library 80 | Playlists 81 | System 82 | Columns 83 | 84 | {this.renderActiveTabItem()} 85 | 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/renderer/containers/ShellContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import '../styles/app.scss'; 4 | 5 | import HeaderContainer from './HeaderContainer'; 6 | import PlayerContainer from './PlayerContainer'; 7 | import LibraryContainer from './LibraryContainer'; 8 | import { AppStoreConsumer } from '../stores/AppContext'; 9 | import PlaylistContainer from './PlaylistContainer'; 10 | 11 | // tslint:disable-next-line:import-name 12 | import ReactHintFactory from 'react-hint'; 13 | import 'react-hint/css/index.css'; 14 | import NotificationContainer from './NotificationContainer'; 15 | import { LayoutContextConsumer } from '../stores/LayoutContext'; 16 | import { ActionsModel } from '../stores/AppStore'; 17 | 18 | // tslint:disable-next-line:variable-name 19 | let ReactHint = ReactHintFactory(React); 20 | 21 | export interface ShellContainerProps { 22 | init: ActionsModel['init']; 23 | } 24 | 25 | export interface ShellContainerState {} 26 | 27 | export default class ShellContainer extends React.Component< 28 | ShellContainerProps, 29 | ShellContainerState 30 | > { 31 | constructor(props: ShellContainerProps) { 32 | super(props); 33 | } 34 | 35 | componentDidMount() { 36 | this.props.init.init(); 37 | } 38 | 39 | render() { 40 | return ( 41 | <> 42 | 43 | 44 | 45 | 46 | 47 | {store => 48 | store.activeMainTab === 0 ? ( 49 | 50 | ) : ( 51 | 52 | ) 53 | } 54 | 55 | 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/database/PlaylistsDatabase.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import Dexie from 'dexie'; 3 | 4 | export interface PlaylistModel { 5 | id?: number; 6 | name: string; 7 | type: 'folder' | 'normal'; 8 | tracks: number[]; 9 | } 10 | 11 | export class PlaylistsDatabase extends Dexie { 12 | folders: Dexie.Table; 13 | playlists: Dexie.Table; 14 | 15 | constructor(dbname: string) { 16 | super(dbname); 17 | 18 | this.version(1).stores({ 19 | folders: '++id, name', 20 | playlists: '++id, name', 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/database/StateDatabase.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import Dexie from 'dexie'; 3 | 4 | interface QueueModel { 5 | id: number; 6 | queue: number[]; 7 | originalQueue: number[]; 8 | cursor: number; 9 | } 10 | 11 | export class StateDatabase extends Dexie { 12 | queue: Dexie.Table; 13 | 14 | constructor(dbname: string) { 15 | super(dbname); 16 | 17 | /* 18 | Version 1 of the database. Adds player queue persistance. 19 | */ 20 | this.version(1).stores({ 21 | queue: 'id, cursor', 22 | }); 23 | 24 | /* 25 | Populates the database with default values 26 | */ 27 | this.on('populate', () => { 28 | this.queue.add({ id: 1, queue: [], originalQueue: [], cursor: -2 }); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/database/TracksDatabase.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:import-name 2 | import Dexie from 'dexie'; 3 | import { IFormat, ICommonTagsResult } from 'music-metadata/lib/type'; 4 | import { Stats } from 'fs'; 5 | 6 | // Specifically created so that typescript behaves sorting; 7 | interface ExtendedCommonTagsResult extends ICommonTagsResult { 8 | [key: string]: any; 9 | } 10 | 11 | export interface TrackModel { 12 | id: number; 13 | source: string; 14 | common: ExtendedCommonTagsResult; 15 | format: IFormat; 16 | stats: Stats; 17 | } 18 | 19 | export class TracksDatabase extends Dexie { 20 | library: Dexie.Table; 21 | 22 | constructor(dbname: string) { 23 | super(dbname); 24 | 25 | this.version(1).stores({ 26 | tracks: 'id, source, common, format', 27 | }); 28 | 29 | /* 30 | Version 2 of the database. Renames the table to delete the old version of 31 | the data due to changes in music-metadata data structures. 32 | */ 33 | this.version(2).stores({ 34 | tracks: null, 35 | library: 'id, source, common, format', 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Nighthawk 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/renderer/libraries/Notifications.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import delay from 'lodash/delay'; 3 | 4 | class Notifications extends EventEmitter { 5 | private notifications: Map; 6 | 7 | constructor() { 8 | super(); 9 | this.notifications = new Map(); 10 | } 11 | 12 | addNotification = (id: string, message: string, timed: boolean = false) => { 13 | this.notifications.set(id, message); 14 | this.emit('ADD_NOTIF', notifications); 15 | if (timed) { 16 | delay(this.removeNotification, 6000, id); 17 | } 18 | }; 19 | 20 | removeNotification = (id: string) => { 21 | this.notifications.delete(id); 22 | this.emit('DEL_NOTIF', notifications); 23 | }; 24 | } 25 | 26 | const notifications = new Notifications(); 27 | export default notifications; 28 | -------------------------------------------------------------------------------- /src/renderer/libraries/Player.ts: -------------------------------------------------------------------------------- 1 | class Player { 2 | player: HTMLAudioElement; 3 | constructor() { 4 | this.player = new Audio(); 5 | } 6 | 7 | getInstance() { 8 | return this.player; 9 | } 10 | 11 | getDuration() { 12 | return this.player.duration; 13 | } 14 | 15 | play() { 16 | this.player.play(); 17 | } 18 | 19 | pause() { 20 | this.player.pause(); 21 | } 22 | 23 | replay() { 24 | this.player.load(); 25 | this.play(); 26 | } 27 | 28 | reset() { 29 | this.setAudioSrc(''); 30 | } 31 | 32 | isMuted() { 33 | return this.player.muted; 34 | } 35 | 36 | isPaused() { 37 | return this.player.paused; 38 | } 39 | 40 | mute() { 41 | this.player.muted = true; 42 | } 43 | 44 | unmute() { 45 | this.player.muted = false; 46 | } 47 | 48 | getCurrentTime(): number { 49 | return this.player.currentTime; 50 | } 51 | 52 | setCurrentTime(duration: number) { 53 | this.player.currentTime = duration; 54 | } 55 | 56 | getVolume(): number { 57 | return this.player.volume; 58 | } 59 | 60 | setVolume(volume: number) { 61 | this.player.volume = volume; 62 | } 63 | 64 | getAudioSrc(): string { 65 | return this.player.currentSrc; 66 | } 67 | 68 | setAudioSrc(src: string) { 69 | this.player.src = src; 70 | } 71 | } 72 | 73 | export default new Player(); 74 | -------------------------------------------------------------------------------- /src/renderer/renderer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { AppStoreProvider, AppStoreConsumer } from './stores/AppContext'; 4 | 5 | import ShellContainer from './containers/ShellContainer'; 6 | import { LayoutContext } from './stores/LayoutContext'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | {store => } 13 | 14 | 15 | , 16 | document.getElementById('app') 17 | ); 18 | -------------------------------------------------------------------------------- /src/renderer/stores/AppContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { AppStoreModel } from './AppStoreModel'; 3 | 4 | import * as InitActions from '../actions/InitActions'; 5 | import * as SettingsActions from '../actions/SettingsActions'; 6 | import * as LibraryActions from '../actions/LibraryActions'; 7 | import * as PlayerActions from '../actions/PlayerActions'; 8 | import * as SearchActions from '../actions/SearchActions'; 9 | import * as PlaylistActions from '../actions/PlaylistActions'; 10 | import AppStore, { ActionsModel } from './AppStore'; 11 | import Player from '../libraries/Player'; 12 | import { webFrame } from 'electron'; 13 | 14 | const defaultValue: AppStore = { 15 | state: { 16 | settings: SettingsActions.getSettings(), 17 | originalLibrary: [], 18 | library: [], 19 | player: { 20 | cursor: -2, 21 | queue: [], 22 | originalQueue: [], 23 | playing: false, 24 | }, 25 | playlist: { 26 | currentPlaylist: { name: 'none', tracks: [], type: 'folder' }, 27 | currentIndex: 0, 28 | currentTracks: [], 29 | playlists: [], 30 | }, 31 | }, 32 | init: null, 33 | settings: null, 34 | library: null, 35 | player: null, 36 | search: null, 37 | playlist: null, 38 | }; 39 | 40 | const storeContext = React.createContext(defaultValue); 41 | // tslint:disable-next-line:variable-name 42 | export const AppStoreConsumer = storeContext.Consumer; 43 | 44 | export class AppStoreProvider extends React.Component { 45 | actions: ActionsModel; 46 | 47 | constructor(props: any) { 48 | super(props); 49 | 50 | this.state = defaultValue.state; 51 | Player.player.muted = defaultValue.state.settings.player.mute; 52 | Player.setVolume(defaultValue.state.settings.player.volume); 53 | webFrame.setZoomFactor(defaultValue.state.settings.system.zoomFactor); 54 | 55 | this.actions = { 56 | init: null, 57 | settings: null, 58 | library: null, 59 | player: null, 60 | search: null, 61 | playlist: null, 62 | }; 63 | this.wrapActions(); 64 | } 65 | 66 | wrapActions = () => { 67 | // Wrap init actions 68 | const initActions: any = { ...InitActions }; 69 | for (const key in initActions) { 70 | if (initActions.hasOwnProperty(key)) { 71 | initActions[key] = this.wrap(initActions[key]); 72 | } 73 | } 74 | this.actions.init = initActions; 75 | 76 | // Wrap Settings Actions 77 | const settingsActions: any = { ...SettingsActions }; 78 | for (const key in settingsActions) { 79 | if (settingsActions.hasOwnProperty(key)) { 80 | settingsActions[key] = this.wrap(settingsActions[key]); 81 | } 82 | } 83 | 84 | this.actions.settings = settingsActions; 85 | 86 | // Wrap Library Actions 87 | const libraryActions: any = { ...LibraryActions }; 88 | for (const key in libraryActions) { 89 | if (libraryActions.hasOwnProperty(key)) { 90 | libraryActions[key] = this.wrap(libraryActions[key]); 91 | } 92 | } 93 | 94 | this.actions.library = libraryActions; 95 | 96 | // Wrap Player Actions 97 | const playerActions: any = { ...PlayerActions }; 98 | for (const key in playerActions) { 99 | if (playerActions.hasOwnProperty(key)) { 100 | playerActions[key] = this.wrap(playerActions[key]); 101 | } 102 | } 103 | 104 | this.actions.player = playerActions; 105 | 106 | const searchActions: any = { ...SearchActions }; 107 | for (const key in searchActions) { 108 | if (searchActions.hasOwnProperty(key)) { 109 | searchActions[key] = this.wrap(searchActions[key]); 110 | } 111 | } 112 | 113 | this.actions.search = searchActions; 114 | 115 | const playlistActions: any = { ...PlaylistActions }; 116 | for (const key in playlistActions) { 117 | if (playlistActions.hasOwnProperty(key)) { 118 | playlistActions[key] = this.wrap(playlistActions[key]); 119 | } 120 | } 121 | 122 | this.actions.playlist = playlistActions; 123 | }; 124 | 125 | wrap = any>(fn: T): T => { 126 | return (async (...args: any[]) => { 127 | this.setState(await fn(...args, this.state)); 128 | }) as T; 129 | }; 130 | 131 | render() { 132 | return ( 133 | 143 | {this.props.children} 144 | 145 | ); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/renderer/stores/AppStore.ts: -------------------------------------------------------------------------------- 1 | import { AppStoreModel } from './AppStoreModel'; 2 | 3 | import * as SettingsActions from '../actions/SettingsActions'; 4 | import * as LibraryActions from '../actions/LibraryActions'; 5 | import * as PlayerActions from '../actions/PlayerActions'; 6 | import * as SearchActions from '../actions/SearchActions'; 7 | import * as InitActions from '../actions/InitActions'; 8 | import * as PlaylistActions from '../actions/PlaylistActions'; 9 | 10 | export interface ActionsModel { 11 | init: typeof InitActions; 12 | settings: typeof SettingsActions; 13 | library: typeof LibraryActions; 14 | player: typeof PlayerActions; 15 | search: typeof SearchActions; 16 | playlist: typeof PlaylistActions; 17 | } 18 | 19 | export default interface AppStore extends ActionsModel { 20 | state: AppStoreModel; 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/stores/AppStoreModel.ts: -------------------------------------------------------------------------------- 1 | import { TrackModel } from '../database/TracksDatabase'; 2 | import { PlaylistModel } from '../database/PlaylistsDatabase'; 3 | 4 | export interface AppStoreModel { 5 | settings: SettingsStoreModel; 6 | originalLibrary: TrackModel[]; 7 | library: TrackModel[]; 8 | player: PlayerStoreModel; 9 | playlist: PlaylistStoreModel; 10 | } 11 | 12 | export interface PlayerStoreModel { 13 | queue: TrackModel[]; 14 | originalQueue: TrackModel[]; 15 | cursor: number; 16 | playing: boolean; 17 | } 18 | 19 | export interface PlaylistStoreModel { 20 | currentPlaylist: PlaylistModel; 21 | currentIndex: number; 22 | currentTracks: TrackModel[]; 23 | playlists: PlaylistModel[]; 24 | } 25 | 26 | export interface SettingsStoreModel { 27 | library: { 28 | path: string; 29 | sortBy: string; 30 | sortDirection: 'ASC' | 'DESC'; 31 | }; 32 | columns: { 33 | columns: [string, boolean][]; 34 | }; 35 | player: { 36 | shuffle: boolean; 37 | repeat: boolean; 38 | volume: number; 39 | mute: boolean; 40 | }; 41 | system: { 42 | unobtrusive: boolean; 43 | zoomFactor: number; 44 | }; 45 | playlist: { 46 | folder: boolean; 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/stores/LayoutContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const context = React.createContext({ 4 | activeMainTab: 0, 5 | changeMainTab: undefined, 6 | }); 7 | 8 | // tslint:disable-next-line:variable-name 9 | export const LayoutContextConsumer = context.Consumer; 10 | 11 | interface LayoutContextState { 12 | activeMainTab: number; 13 | } 14 | 15 | export class LayoutContext extends React.Component { 16 | constructor(props: any) { 17 | super(props); 18 | 19 | this.state = { 20 | activeMainTab: 0, 21 | }; 22 | } 23 | 24 | changeMainTab = (activeMainTab: number) => { 25 | this.setState({ activeMainTab }); 26 | }; 27 | 28 | render() { 29 | return ( 30 | 35 | {this.props.children} 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import 'base/index'; 2 | @import 'elements/index'; 3 | @import 'containers/index'; 4 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_animate.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | /*! 4 | * animate.css -http://daneden.me/animate 5 | * Version - 3.6.1 6 | * Licensed under the MIT license - http://opensource.org/licenses/MIT 7 | * 8 | * Copyright (c) 2018 Daniel Eden 9 | */ 10 | 11 | .animated { 12 | -webkit-animation-duration: 1s; 13 | animation-duration: 1s; 14 | -webkit-animation-fill-mode: both; 15 | animation-fill-mode: both; 16 | } 17 | 18 | .animated.infinite { 19 | -webkit-animation-iteration-count: infinite; 20 | animation-iteration-count: infinite; 21 | } 22 | 23 | @-webkit-keyframes zoomOut { 24 | from { 25 | opacity: 1; 26 | } 27 | 28 | 50% { 29 | opacity: 0; 30 | -webkit-transform: scale3d(0.3, 0.3, 0.3); 31 | transform: scale3d(0.3, 0.3, 0.3); 32 | } 33 | 34 | to { 35 | opacity: 0; 36 | } 37 | } 38 | 39 | @keyframes zoomOut { 40 | from { 41 | opacity: 1; 42 | } 43 | 44 | 50% { 45 | opacity: 0; 46 | -webkit-transform: scale3d(0.3, 0.3, 0.3); 47 | transform: scale3d(0.3, 0.3, 0.3); 48 | } 49 | 50 | to { 51 | opacity: 0; 52 | } 53 | } 54 | 55 | .zoomOut { 56 | -webkit-animation-name: zoomOut; 57 | animation-name: zoomOut; 58 | } 59 | 60 | @-webkit-keyframes slideInUp { 61 | from { 62 | -webkit-transform: translate3d(0, 100%, 0); 63 | transform: translate3d(0, 100%, 0); 64 | visibility: visible; 65 | } 66 | 67 | to { 68 | -webkit-transform: translate3d(0, 0, 0); 69 | transform: translate3d(0, 0, 0); 70 | } 71 | } 72 | 73 | @keyframes slideInUp { 74 | from { 75 | -webkit-transform: translate3d(0, 100%, 0); 76 | transform: translate3d(0, 100%, 0); 77 | visibility: visible; 78 | } 79 | 80 | to { 81 | -webkit-transform: translate3d(0, 0, 0); 82 | transform: translate3d(0, 0, 0); 83 | } 84 | } 85 | 86 | .slideInUp { 87 | -webkit-animation-name: slideInUp; 88 | animation-name: slideInUp; 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_base.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: var(--font-family); 4 | font-size: var(--font-size); 5 | line-height: var(--line-height); 6 | background: var(--background-color); 7 | 8 | overflow: hidden; 9 | } 10 | 11 | .shell { 12 | height: calc(100vh); 13 | max-height: calc(100vh); 14 | display: flex; 15 | flex-direction: column; 16 | } 17 | 18 | a { 19 | color: var(--accent-base-color); 20 | outline: none; 21 | text-decoration: none; 22 | 23 | &:active { 24 | color: var(--paccent-dark-color); 25 | text-decoration: underline; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'normalize'; 2 | @import 'variables'; 3 | @import 'base'; 4 | @import 'typography'; 5 | @import 'animate'; 6 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Correct the font size and margin on `h1` elements within `section` and 29 | * `article` contexts in Chrome, Firefox, and Safari. 30 | */ 31 | 32 | h1 { 33 | font-size: 2em; 34 | margin: 0.67em 0; 35 | } 36 | 37 | /* Grouping content 38 | ========================================================================== */ 39 | 40 | /** 41 | * 1. Add the correct box sizing in Firefox. 42 | * 2. Show the overflow in Edge and IE. 43 | */ 44 | 45 | hr { 46 | box-sizing: content-box; /* 1 */ 47 | height: 0; /* 1 */ 48 | overflow: visible; /* 2 */ 49 | } 50 | 51 | /** 52 | * 1. Correct the inheritance and scaling of font size in all browsers. 53 | * 2. Correct the odd `em` font sizing in all browsers. 54 | */ 55 | 56 | pre { 57 | font-family: monospace, monospace; /* 1 */ 58 | font-size: 1em; /* 2 */ 59 | } 60 | 61 | /* Text-level semantics 62 | ========================================================================== */ 63 | 64 | /** 65 | * Remove the gray background on active links in IE 10. 66 | */ 67 | 68 | a { 69 | background-color: transparent; 70 | } 71 | 72 | /** 73 | * 1. Remove the bottom border in Chrome 57- 74 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 75 | */ 76 | 77 | abbr[title] { 78 | border-bottom: none; /* 1 */ 79 | text-decoration: underline; /* 2 */ 80 | text-decoration: underline dotted; /* 2 */ 81 | } 82 | 83 | /** 84 | * Add the correct font weight in Chrome, Edge, and Safari. 85 | */ 86 | 87 | b, 88 | strong { 89 | font-weight: bolder; 90 | } 91 | 92 | /** 93 | * 1. Correct the inheritance and scaling of font size in all browsers. 94 | * 2. Correct the odd `em` font sizing in all browsers. 95 | */ 96 | 97 | code, 98 | kbd, 99 | samp { 100 | font-family: monospace, monospace; /* 1 */ 101 | font-size: 1em; /* 2 */ 102 | } 103 | 104 | /** 105 | * Add the correct font size in all browsers. 106 | */ 107 | 108 | small { 109 | font-size: 80%; 110 | } 111 | 112 | /** 113 | * Prevent `sub` and `sup` elements from affecting the line height in 114 | * all browsers. 115 | */ 116 | 117 | sub, 118 | sup { 119 | font-size: 75%; 120 | line-height: 0; 121 | position: relative; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -0.25em; 127 | } 128 | 129 | sup { 130 | top: -0.5em; 131 | } 132 | 133 | /* Embedded content 134 | ========================================================================== */ 135 | 136 | /** 137 | * Remove the border on images inside links in IE 10. 138 | */ 139 | 140 | img { 141 | border-style: none; 142 | } 143 | 144 | /* Forms 145 | ========================================================================== */ 146 | 147 | /** 148 | * 1. Change the font styles in all browsers. 149 | * 2. Remove the margin in Firefox and Safari. 150 | */ 151 | 152 | button, 153 | input, 154 | optgroup, 155 | select, 156 | textarea { 157 | font-family: inherit; /* 1 */ 158 | font-size: 100%; /* 1 */ 159 | line-height: 1.15; /* 1 */ 160 | margin: 0; /* 2 */ 161 | } 162 | 163 | /** 164 | * Show the overflow in IE. 165 | * 1. Show the overflow in Edge. 166 | */ 167 | 168 | button, 169 | input { 170 | /* 1 */ 171 | overflow: visible; 172 | } 173 | 174 | /** 175 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 176 | * 1. Remove the inheritance of text transform in Firefox. 177 | */ 178 | 179 | button, 180 | select { 181 | /* 1 */ 182 | text-transform: none; 183 | } 184 | 185 | /** 186 | * Correct the inability to style clickable types in iOS and Safari. 187 | */ 188 | 189 | button, 190 | [type='button'], 191 | [type='reset'], 192 | [type='submit'] { 193 | -webkit-appearance: button; 194 | } 195 | 196 | /** 197 | * Remove the inner border and padding in Firefox. 198 | */ 199 | 200 | button::-moz-focus-inner, 201 | [type='button']::-moz-focus-inner, 202 | [type='reset']::-moz-focus-inner, 203 | [type='submit']::-moz-focus-inner { 204 | border-style: none; 205 | padding: 0; 206 | } 207 | 208 | /** 209 | * Restore the focus styles unset by the previous rule. 210 | */ 211 | 212 | button:-moz-focusring, 213 | [type='button']:-moz-focusring, 214 | [type='reset']:-moz-focusring, 215 | [type='submit']:-moz-focusring { 216 | outline: 1px dotted ButtonText; 217 | } 218 | 219 | /** 220 | * Correct the padding in Firefox. 221 | */ 222 | 223 | fieldset { 224 | padding: 0.35em 0.75em 0.625em; 225 | } 226 | 227 | /** 228 | * 1. Correct the text wrapping in Edge and IE. 229 | * 2. Correct the color inheritance from `fieldset` elements in IE. 230 | * 3. Remove the padding so developers are not caught out when they zero out 231 | * `fieldset` elements in all browsers. 232 | */ 233 | 234 | legend { 235 | box-sizing: border-box; /* 1 */ 236 | color: inherit; /* 2 */ 237 | display: table; /* 1 */ 238 | max-width: 100%; /* 1 */ 239 | padding: 0; /* 3 */ 240 | white-space: normal; /* 1 */ 241 | } 242 | 243 | /** 244 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 245 | */ 246 | 247 | progress { 248 | vertical-align: baseline; 249 | } 250 | 251 | /** 252 | * Remove the default vertical scrollbar in IE 10+. 253 | */ 254 | 255 | textarea { 256 | overflow: auto; 257 | } 258 | 259 | /** 260 | * 1. Add the correct box sizing in IE 10. 261 | * 2. Remove the padding in IE 10. 262 | */ 263 | 264 | [type='checkbox'], 265 | [type='radio'] { 266 | box-sizing: border-box; /* 1 */ 267 | padding: 0; /* 2 */ 268 | } 269 | 270 | /** 271 | * Correct the cursor style of increment and decrement buttons in Chrome. 272 | */ 273 | 274 | [type='number']::-webkit-inner-spin-button, 275 | [type='number']::-webkit-outer-spin-button { 276 | height: auto; 277 | } 278 | 279 | /** 280 | * 1. Correct the odd appearance in Chrome and Safari. 281 | * 2. Correct the outline style in Safari. 282 | */ 283 | 284 | [type='search'] { 285 | -webkit-appearance: textfield; /* 1 */ 286 | outline-offset: -2px; /* 2 */ 287 | } 288 | 289 | /** 290 | * Remove the inner padding in Chrome and Safari on macOS. 291 | */ 292 | 293 | [type='search']::-webkit-search-decoration { 294 | -webkit-appearance: none; 295 | } 296 | 297 | /** 298 | * 1. Correct the inability to style clickable types in iOS and Safari. 299 | * 2. Change font properties to `inherit` in Safari. 300 | */ 301 | 302 | ::-webkit-file-upload-button { 303 | -webkit-appearance: button; /* 1 */ 304 | font: inherit; /* 2 */ 305 | } 306 | 307 | /* Interactive 308 | ========================================================================== */ 309 | 310 | /* 311 | * Add the correct display in Edge, IE 10+, and Firefox. 312 | */ 313 | 314 | details { 315 | display: block; 316 | } 317 | 318 | /* 319 | * Add the correct display in all browsers. 320 | */ 321 | 322 | summary { 323 | display: list-item; 324 | } 325 | 326 | /* Misc 327 | ========================================================================== */ 328 | 329 | /** 330 | * Add the correct display in IE 10+. 331 | */ 332 | 333 | template { 334 | display: none; 335 | } 336 | 337 | /** 338 | * Add the correct display in IE 10. 339 | */ 340 | 341 | [hidden] { 342 | display: none; 343 | } 344 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_typography.scss: -------------------------------------------------------------------------------- 1 | h1, 2 | h2, 3 | h3, 4 | h4, 5 | h5, 6 | h6 { 7 | color: inherit; 8 | font-weight: 600; 9 | margin: var(--spacing-vertical) var(--spacing-horizontal); 10 | } 11 | 12 | h1 { 13 | font-size: 2.5rem; 14 | } 15 | 16 | h2 { 17 | font-size: 2rem; 18 | } 19 | 20 | h3 { 21 | font-size: 1.75rem; 22 | } 23 | 24 | h4 { 25 | font-size: 1.5rem; 26 | } 27 | 28 | h5 { 29 | font-size: 1.25rem; 30 | } 31 | 32 | h6 { 33 | font-size: 1.1rem; 34 | } 35 | 36 | p { 37 | font-size: var(--font-size-base); 38 | margin: 0 var(--spacing-horizontal); 39 | } 40 | 41 | // Semantic text elements 42 | a, 43 | ins, 44 | u { 45 | text-decoration-skip: ink edges; 46 | } 47 | 48 | abbr[title] { 49 | border-bottom: var(--border-base-width) dotted; 50 | cursor: help; 51 | text-decoration: none; 52 | } 53 | 54 | blockquote { 55 | border-left: calc(var(--border-base-width) * 3) solid 56 | var(--border-base-color); 57 | padding: 8px 16px; 58 | cite { 59 | //TODO: Set this color 60 | color: #e9e9e9; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/renderer/styles/base/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | /* 3 | Color Variables 4 | */ 5 | --background-color: #fafafa; 6 | --text-body-color: #222222; 7 | --text-heading-color: #111111; 8 | --text-secondary-color: #444444; 9 | 10 | --accent-base-color: #1890ff; 11 | --accent-light-color: #40a9ff; 12 | --accent-dark-color: #096dd9; 13 | --text-accent-color: #ffffff; 14 | 15 | /* 16 | Font variables 17 | */ 18 | --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 19 | 'Noto Sans', Ubuntu, Cantarell, 'Helvetica Neue', Oxygen-Sans, 20 | sans-serif; 21 | --font-size: 12px; 22 | --line-height: 1.5; 23 | 24 | /* 25 | Border Variables 26 | */ 27 | --border-color: #e2e2e2; 28 | --border-width: 1px; 29 | --border-style: solid; 30 | --border-radius: 0px; 31 | 32 | /* 33 | Spacing and Size Variables 34 | */ 35 | --element-height: 32px; 36 | --spacing-vertical: 8px; 37 | --spacing-horizontal: 12px; 38 | 39 | /* 40 | Misc variables 41 | */ 42 | --element-disabled-opacity: 0.6; 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | background: var(--accent-base-color); 4 | color: var(--text-accent-color); 5 | 6 | .mode-tabs { 7 | height: auto; 8 | font-size: 1.1em; 9 | font-weight: 500; 10 | margin-left: var(--spacing-horizontal); 11 | 12 | .tab-bar-item { 13 | flex: 1 1 auto; 14 | margin-left: var(--spacing-horizontal); 15 | 16 | display: inline-flex; 17 | align-items: center; 18 | justify-content: center; 19 | 20 | color: var(--text-accent-color); 21 | border: none; 22 | opacity: 0.6; 23 | 24 | &.selected { 25 | opacity: 1; 26 | } 27 | } 28 | } 29 | 30 | .content { 31 | display: flex; 32 | padding: 0px var(--spacing-horizontal); 33 | } 34 | 35 | .search { 36 | display: flex; 37 | padding: 0px var(--spacing-horizontal); 38 | align-items: center; 39 | 40 | .textbox { 41 | height: calc(var(--element-height) - 4px); 42 | margin: 0px; 43 | background: #ffffff; 44 | border-color: var(--accent-dark-color); 45 | color: var(--text-body-color); 46 | } 47 | 48 | .textbox-addon { 49 | height: calc(var(--element-height) - 4px); 50 | border-color: var(--accent-base-color); 51 | background: var(--accent-base-color); 52 | color: var(--text-accent-color); 53 | } 54 | } 55 | 56 | .drag { 57 | flex: 1; 58 | 59 | &.window { 60 | -webkit-app-region: drag; 61 | } 62 | } 63 | 64 | .window-controls { 65 | display: inline-flex; 66 | min-width: 100px; 67 | justify-content: flex-end; 68 | 69 | &.macos { 70 | order: -1; 71 | } 72 | 73 | .close { 74 | border-radius: 0px; 75 | -webkit-app-region: no-drag; 76 | &:focus, 77 | &:hover { 78 | border-color: #e81123; 79 | background: #e81123; 80 | & svg > polygon { 81 | fill: #ffffff; 82 | } 83 | } 84 | &:active { 85 | border-color: #bf0f1d; 86 | background: #bf0f1d; 87 | & svg > polygon { 88 | fill: #ffffff; 89 | } 90 | } 91 | } 92 | 93 | .minimize, 94 | .resize { 95 | border-radius: 0px; 96 | -webkit-app-region: no-drag; 97 | 98 | &:focus, 99 | &:hover { 100 | & svg > rect, 101 | & svg > path, 102 | & svg > polygon { 103 | fill: var(--text-accent-color); 104 | } 105 | } 106 | 107 | &:active { 108 | & svg > rect, 109 | & svg > path, 110 | & svg > polygon { 111 | fill: var(--text-accent-color); 112 | } 113 | } 114 | } 115 | 116 | .minimize svg, 117 | .resize svg, 118 | .close svg { 119 | width: 11px; 120 | height: 11px; 121 | shape-rendering: crispEdges; 122 | } 123 | 124 | .minimize svg > rect, 125 | .resize svg > path, 126 | .close svg > polygon { 127 | fill: var(--text-accent-color); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'header'; 2 | @import 'player'; 3 | @import 'library'; 4 | @import 'settings'; 5 | @import 'panel'; 6 | @import 'playlist'; 7 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_library.scss: -------------------------------------------------------------------------------- 1 | .songs { 2 | flex: 1; 3 | 4 | margin-right: 3px; 5 | margin-bottom: 2px; 6 | } 7 | 8 | .empty-state { 9 | margin: var(--spacing-vertical) var(--spacing-horizontal); 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | 15 | height: 100%; 16 | 17 | .empty-state-icon { 18 | margin-bottom: var(--spacing-vertical); 19 | 20 | min-width: 242px; 21 | min-height: 192px; 22 | } 23 | 24 | .empty-state-title { 25 | margin: var(--spacing-vertical) auto; 26 | } 27 | 28 | .empty-state-subtitle { 29 | margin: var(--spacing-vertical) auto calc(var(--spacing-horizontal) * 4); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_panel.scss: -------------------------------------------------------------------------------- 1 | .panel { 2 | background: var(--background-color); 3 | border: var(--border-width) var(--border-style) var(--border-color); 4 | height: var(--element-height); 5 | display: flex; 6 | 7 | .details-left { 8 | flex: 1; 9 | padding: var(--spacing-vertical) 0px; 10 | } 11 | 12 | .details-right { 13 | flex: 1; 14 | padding: var(--spacing-vertical) var(--spacing-horizontal); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_player.scss: -------------------------------------------------------------------------------- 1 | .player { 2 | border-bottom: var(--border-width) var(--border-style) var(--border-color); 3 | padding: 4px; 4 | padding-right: calc(var(--spacing-horizontal) * 2); 5 | background: var(--accent-base-color); 6 | color: var(--text-accent-color); 7 | 8 | display: grid; 9 | grid-template-rows: 42px 32px; 10 | grid-template-columns: 75px auto; 11 | grid-template-areas: 'image details' 'image controls'; 12 | 13 | .image { 14 | grid-area: image; 15 | justify-self: center; 16 | align-self: center; 17 | 18 | height: 70px; 19 | width: 70px; 20 | max-width: 70px; 21 | max-height: 70px; 22 | } 23 | 24 | .details { 25 | grid-area: details; 26 | display: flex; 27 | align-items: baseline; 28 | margin-right: calc(var(--spacing-horizontal) * 2); 29 | min-width: 0; 30 | 31 | h2, 32 | h4, 33 | h5 { 34 | font-weight: 400; 35 | margin-top: 0; 36 | margin-bottom: 0; 37 | } 38 | 39 | h2, 40 | h4 { 41 | overflow: hidden; 42 | white-space: nowrap; 43 | text-overflow: ellipsis; 44 | } 45 | } 46 | 47 | .controls { 48 | grid-area: controls; 49 | display: flex; 50 | justify-content: space-between; 51 | 52 | .seekbar { 53 | display: flex; 54 | flex: 1; 55 | align-items: center; 56 | margin: 0 calc(var(--spacing-horizontal) * 2); 57 | 58 | span { 59 | margin: 0 var(--spacing-horizontal); 60 | } 61 | } 62 | 63 | .volume { 64 | margin: 0 calc(var(--spacing-horizontal) * 2); 65 | display: flex; 66 | align-items: center; 67 | min-width: 125px; 68 | } 69 | } 70 | } 71 | 72 | .popover-queue { 73 | width: 350px; 74 | height: 60vh; 75 | 76 | display: flex; 77 | flex-direction: column; 78 | border: var(--border-width) var(--border-style) var(--border-color); 79 | 80 | .queue-header { 81 | height: calc(var(--element-height) + 4px); 82 | background: var(--background-color); 83 | border-bottom: var(--border-width) var(--border-style) 84 | var(--border-color); 85 | 86 | padding: 0px var(--element-padding); 87 | 88 | overflow: hidden; 89 | 90 | display: flex; 91 | justify-content: space-between; 92 | align-items: center; 93 | } 94 | 95 | .queue-body { 96 | flex: 1 1 auto; 97 | display: flex; 98 | align-items: stretch; 99 | background: #ffffff; 100 | 101 | .queue-row { 102 | display: flex; 103 | 104 | justify-content: center; 105 | outline: none; 106 | 107 | .btn { 108 | border-radius: 0px; 109 | width: (--element-height); 110 | } 111 | 112 | .details { 113 | flex: 1 1 auto; 114 | display: flex; 115 | justify-content: center; 116 | flex-direction: column; 117 | transition: background 0.2s; 118 | 119 | &.current { 120 | background: var(--accent-base-color); 121 | color: var(--text-accent-color); 122 | 123 | &:hover { 124 | background: var(--accent-light-color); 125 | color: var(--text-accent-color); 126 | } 127 | } 128 | 129 | h6 { 130 | margin-top: 0; 131 | margin-bottom: 0; 132 | } 133 | 134 | &:hover { 135 | border-top-width: var(--border-width); 136 | background-color: var(--background-color); 137 | } 138 | 139 | &:active { 140 | background-color: var(--accent-base-color); 141 | color: var(--text-accent-color); 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | .ReactVirtualized__List { 149 | outline: none; 150 | } 151 | 152 | .ReactVirtualized__Grid__innerScrollContainer { 153 | outline: none; 154 | } 155 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_playlist.scss: -------------------------------------------------------------------------------- 1 | .playlist { 2 | flex: 1; 3 | 4 | display: flex; 5 | 6 | .details { 7 | background: #ffffff; 8 | width: 200px; 9 | height: 100%; 10 | border-right: var(--border-width) var(--border-style) 11 | var(--border-color); 12 | 13 | display: flex; 14 | flex-direction: column; 15 | 16 | .list { 17 | flex: 1; 18 | overflow: auto; 19 | display: flex; 20 | flex-direction: column; 21 | 22 | .list-row { 23 | display: flex; 24 | justify-content: center; 25 | flex-direction: column; 26 | transition: background 0.2s; 27 | 28 | min-height: 28px; 29 | 30 | &.current { 31 | background: var(--accent-base-color); 32 | color: var(--text-accent-color); 33 | 34 | &:hover { 35 | background: var(--accent-light-color); 36 | color: var(--text-accent-color); 37 | } 38 | } 39 | 40 | h6 { 41 | margin-top: 0; 42 | margin-bottom: 0; 43 | } 44 | 45 | &:hover { 46 | border-top-width: var(--border-width); 47 | background-color: var(--background-color); 48 | } 49 | 50 | &:active { 51 | background-color: var(--accent-base-color); 52 | color: var(--text-accent-color); 53 | } 54 | } 55 | } 56 | 57 | .panel { 58 | border-top: none; 59 | border-left: none; 60 | border-right: none; 61 | height: 26px; 62 | 63 | display: flex; 64 | align-items: center; 65 | 66 | h6 { 67 | margin-top: 0; 68 | margin-bottom: 0; 69 | 70 | flex: 1; 71 | } 72 | } 73 | } 74 | 75 | .table { 76 | background: #ffffff; 77 | flex: 1; 78 | border-right: var(--border-width) var(--border-style) 79 | var(--border-color); 80 | } 81 | } 82 | 83 | .add-list { 84 | flex: 1; 85 | 86 | display: flex; 87 | flex-direction: column; 88 | 89 | .list-row { 90 | display: flex; 91 | justify-content: center; 92 | flex-direction: column; 93 | transition: background 0.2s; 94 | 95 | border-top: var(--border-width) var(--border-style) var(--border-color); 96 | 97 | min-height: 40px; 98 | 99 | &:hover { 100 | color: var(--text-accent-color); 101 | background-color: var(--accent-dark-color); 102 | } 103 | 104 | &:active { 105 | background-color: var(--accent-light-color); 106 | color: var(--text-accent-color); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/renderer/styles/containers/_settings.scss: -------------------------------------------------------------------------------- 1 | .library-settings { 2 | padding: var(--spacing-vertical) var(--spacing-horizontal); 3 | } 4 | 5 | .system-settings { 6 | padding: var(--spacing-vertical) var(--spacing-horizontal); 7 | 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .system-settings { 13 | padding: var(--spacing-vertical) var(--spacing-horizontal); 14 | 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | .settings-banner { 20 | display: flex; 21 | justify-content: center; 22 | 23 | margin: var(--spacing-vertical) var(--spacing-horizontal); 24 | background: var(--accent-base-color); 25 | color: var(--text-accent-color); 26 | } 27 | 28 | .settings-zoom { 29 | display: flex; 30 | align-items: center; 31 | justify-content: space-between; 32 | 33 | .textbox { 34 | flex: 1; 35 | } 36 | } 37 | 38 | .columns-settings { 39 | padding: var(--spacing-vertical) var(--spacing-horizontal); 40 | 41 | display: grid; 42 | grid-auto-flow: row; 43 | 44 | grid-template-columns: 50% 50%; 45 | 46 | justify-items: stretch; 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_buttons.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Base Button Style. This is the default button style. All other buttons extend this base style. 3 | */ 4 | .btn { 5 | //Font Family not inherited from parents. Hack. 6 | font-family: inherit; 7 | line-height: inherit; 8 | border: var(--border-width) var(--border-style) transparent; 9 | outline: none; 10 | display: inline-flex; 11 | align-items: center; 12 | justify-content: space-around; 13 | padding: var(--spacing-vertical) var(--spacing-horizontal); 14 | margin: 0px; 15 | cursor: pointer; 16 | min-height: var(--element-height); 17 | min-width: 50px; 18 | border-radius: var(--border-radius); 19 | transition: background 0.2s; 20 | 21 | &[disabled], 22 | &:disabled { 23 | cursor: default; 24 | opacity: var(--element-disabled-opacity); 25 | pointer-events: none; 26 | } 27 | 28 | /* 29 | The default colored button style. 30 | */ 31 | &.btn-default { 32 | background: transparent; 33 | color: var(--text-color); 34 | 35 | &:focus, 36 | &:hover { 37 | border-color: var(--accent-light-color); 38 | color: var(--accent-light-color); 39 | } 40 | 41 | &:active { 42 | border-color: var(--accent-dark-color); 43 | color: var(--accent-dark-color); 44 | } 45 | } 46 | 47 | /* 48 | The primary/solid button style. 49 | */ 50 | &.btn-primary { 51 | border-color: var(--accent-base-color); 52 | background: var(--accent-base-color); 53 | color: var(--text-accent-color); 54 | 55 | &:focus, 56 | &:hover { 57 | border-color: var(--accent-light-color); 58 | background: var(--accent-light-color); 59 | color: var(--text-accent-color); 60 | } 61 | 62 | &:active { 63 | border-color: var(--accent-dark-color); 64 | background: var(--accent-dark-color); 65 | color: var(--text-accent-color); 66 | } 67 | } 68 | 69 | /* 70 | The transparent/link button style 71 | */ 72 | &.btn-link { 73 | background: transparent; 74 | border-color: transparent; 75 | transition: color 0.2s; 76 | cursor: pointer; 77 | 78 | &:focus, 79 | &:hover { 80 | color: var(--accent-base-color); 81 | } 82 | 83 | &:active { 84 | color: var(--accent-dark-color); 85 | } 86 | } 87 | 88 | /* 89 | The style for the icon button. 90 | */ 91 | &.btn-icon { 92 | min-width: var(--element-height); 93 | padding: 0px; 94 | } 95 | 96 | /* 97 | The style for the block/full-width button 98 | */ 99 | &.btn-block { 100 | display: flex; 101 | } 102 | } 103 | 104 | /* 105 | Button Groups. Uses Flexbox 106 | */ 107 | .btn-group { 108 | display: inline-flex; 109 | flex-wrap: nowrap; 110 | 111 | .btn { 112 | flex: 1 0 auto; 113 | margin: 0px; 114 | } 115 | 116 | &.btn-group-block { 117 | display: flex; 118 | 119 | .btn { 120 | flex: 1 0 0; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_checkbox.scss: -------------------------------------------------------------------------------- 1 | .checkbox-container { 2 | display: flex; 3 | align-items: center; 4 | margin: var(--spacing-vertical) var(--spacing-horizontal); 5 | } 6 | 7 | .checkbox { 8 | position: absolute; // take it out of document flow 9 | opacity: 0; // hide it 10 | 11 | & + label { 12 | position: relative; 13 | cursor: pointer; 14 | padding: 0; 15 | display: inline-flex; 16 | align-items: center; 17 | } 18 | 19 | // Box. 20 | & + label:before { 21 | content: ''; 22 | margin-right: 10px; 23 | display: inline-block; 24 | vertical-align: text-top; 25 | width: 20px; 26 | height: 20px; 27 | background: white; 28 | border: var(--border-width) var(--border-style) var(--border-color); 29 | transition: background 0.2s; 30 | } 31 | 32 | // Box hover 33 | & + label:hover:before { 34 | background: var(--accent-light-color); 35 | } 36 | 37 | // Box focus 38 | & + label:focus:before { 39 | box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12); 40 | } 41 | 42 | // Box checked 43 | &:checked + label:before { 44 | background: var(--accent-base-color); 45 | color: var(--text-accent-color); 46 | content: '\f12c'; 47 | font-family: 'Material Design Icons'; 48 | font-size: calc(var(--font-size) + 4px); 49 | -webkit-font-smoothing: antialiased; 50 | -moz-osx-font-smoothing: grayscale; 51 | 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | } 56 | 57 | // Disabled state label. 58 | &:disabled + label { 59 | color: #b8b8b8; 60 | cursor: auto; 61 | } 62 | 63 | // Disabled box. 64 | &:disabled + label:before { 65 | box-shadow: none; 66 | background: #ddd; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_context.scss: -------------------------------------------------------------------------------- 1 | .context-menu { 2 | display: none; 3 | position: fixed; 4 | flex-direction: column; 5 | 6 | background-color: var(--background-color); 7 | border: var(--border-width) var(--border-style) var(--grey-dark-color); 8 | color: var(--text-body-color); 9 | border-radius: var(--border-radius); 10 | 11 | min-width: 200px; 12 | max-width: 500px; 13 | 14 | max-height: 100vh; 15 | 16 | box-shadow: 0 2px 7px rgba(0, 0, 0, 0.25); 17 | 18 | z-index: 899; 19 | 20 | &.active { 21 | display: flex; 22 | } 23 | &:focus { 24 | outline: transparent auto 0 !important; 25 | } 26 | } 27 | 28 | .context-menu-item { 29 | display: flex; 30 | height: calc(var(--element-height) - 12px); 31 | padding: var(--spacing-horizontal); 32 | align-items: center; 33 | 34 | &:focus, 35 | &:hover { 36 | background: var(--accent-base-color); 37 | color: var(--text-accent-color); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_icons.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Import the sass files from mdi package 3 | */ 4 | @import '~@mdi/font/css/materialdesignicons.css'; 5 | 6 | .mdi-14px.mdi-set, 7 | .mdi-14px.mdi:before { 8 | font-size: 14px; 9 | } 10 | 11 | .mdi-16px.mdi-set, 12 | .mdi-16px.mdi:before { 13 | font-size: 16px; 14 | } 15 | 16 | .mdi-21px.mdi-set, 17 | .mdi-21px.mdi:before { 18 | font-size: 21px; 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'buttons'; 2 | @import 'icons'; 3 | @import 'slider'; 4 | @import 'table'; 5 | @import 'textbox'; 6 | @import 'modal'; 7 | @import 'tabs'; 8 | @import 'scrollbars'; 9 | @import 'popover'; 10 | @import 'context'; 11 | @import 'notifications'; 12 | @import 'checkbox'; 13 | @import 'messagebox'; 14 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_messagebox.scss: -------------------------------------------------------------------------------- 1 | // message-boxs 2 | .message-box-overlay { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | background-color: rgba(0, 0, 0, 0.1); 12 | 13 | z-index: 999; 14 | } 15 | 16 | .message-box-container { 17 | background-color: var(--background-color); 18 | color: var(--text-color); 19 | border-radius: var(--border-radius); 20 | border: var(--border-width) var(--border-style) var(--accent-base-color); 21 | display: flex; 22 | flex-direction: column; 23 | outline: none; 24 | 25 | width: 50vw; 26 | height: auto; 27 | 28 | max-width: 50vw; 29 | max-height: 75vh; 30 | 31 | box-shadow: 0 0px 20px rgba(0, 0, 0, 0.5); 32 | 33 | .message-box-header { 34 | display: flex; 35 | justify-content: space-between; 36 | align-items: baseline; 37 | padding: var(--spacing-vertical) var(--spacing-horizontal); 38 | border-bottom: var(--border-width) var(--border-style) 39 | var(--border-color); 40 | 41 | h5 { 42 | font-weight: 600; 43 | } 44 | } 45 | 46 | .message-box-body { 47 | min-height: 10vh; 48 | max-height: 50vh; 49 | padding: var(--spacing-vertical) var(--spacing-horizontal); 50 | 51 | display: flex; 52 | align-items: center; 53 | } 54 | 55 | .message-box-footer { 56 | display: flex; 57 | justify-content: space-between; 58 | align-items: baseline; 59 | padding: 0px var(--spacing-horizontal) var(--spacing-vertical); 60 | border-top: var(--border-width) var(--border-style) var(--border-color); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_modal.scss: -------------------------------------------------------------------------------- 1 | // Modals 2 | .modal-overlay { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | background-color: rgba(0, 0, 0, 0.1); 12 | 13 | z-index: 999; 14 | } 15 | 16 | .modal-container { 17 | background-color: var(--background-color); 18 | color: var(--text-color); 19 | border-radius: var(--border-radius); 20 | border: var(--border-width) var(--border-style) var(--accent-base-color); 21 | display: flex; 22 | flex-direction: column; 23 | outline: none; 24 | 25 | width: 50vw; 26 | height: auto; 27 | 28 | max-width: 50vw; 29 | max-height: 75vh; 30 | 31 | box-shadow: 0 0px 20px rgba(0, 0, 0, 0.5); 32 | 33 | .modal-header { 34 | display: flex; 35 | justify-content: space-between; 36 | align-items: baseline; 37 | padding: var(--spacing-vertical) var(--spacing-horizontal); 38 | 39 | h5 { 40 | font-weight: 600; 41 | } 42 | } 43 | 44 | .modal-body { 45 | min-height: 15vh; 46 | max-height: 50vh; 47 | overflow-y: auto; 48 | padding: 0px; 49 | } 50 | 51 | .modal-footer { 52 | display: flex; 53 | justify-content: flex-end; 54 | padding: var(--spacing-vertical) var(--spacing-horizontal); 55 | } 56 | 57 | .modal-footer { 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: baseline; 61 | padding: var(--spacing-vertical) var(--spacing-horizontal); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_notifications.scss: -------------------------------------------------------------------------------- 1 | .notifications { 2 | z-index: 899; 3 | 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | right: 0; 8 | bottom: 0; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: flex-end; 14 | 15 | pointer-events: none; 16 | padding: var(--spacing-vertical) 0px; 17 | } 18 | 19 | .notifications-box { 20 | z-index: 899; 21 | box-shadow: 0 0px 20px rgba(0, 0, 0, 0.5); 22 | display: flex; 23 | min-width: 90vw; 24 | max-width: 90vw; 25 | 26 | background-color: var(--accent-base-color); 27 | color: var(--text-accent-color); 28 | 29 | margin: var(--spacing-vertical) 0px; 30 | padding: var(--spacing-vertical) var(--spacing-horizontal); 31 | } 32 | 33 | .animate-enter { 34 | @extend .animated; 35 | @extend .slideInUp; 36 | animation-duration: 300ms; 37 | } 38 | .animate-exit { 39 | @extend .animated; 40 | @extend .zoomOut; 41 | animation-duration: 500ms; 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_popover.scss: -------------------------------------------------------------------------------- 1 | .popover { 2 | background: transparent; 3 | color: var(--text-body-color); 4 | 5 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.24); 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_scrollbars.scss: -------------------------------------------------------------------------------- 1 | /* Turn on custom 8px wide scrollbar */ 2 | ::-webkit-scrollbar { 3 | width: 10px; 4 | background-color: rgba(0, 0, 0, 0); 5 | } 6 | 7 | ::-webkit-scrollbar:hover { 8 | background-color: rgba(0, 0, 0, 0.09); 9 | } 10 | 11 | ::-webkit-scrollbar-thumb:vertical { 12 | background: rgba(0, 0, 0, 0.5); 13 | background-clip: padding-box; 14 | border: 2px solid rgba(0, 0, 0, 0); 15 | min-height: var(--element-height); /*Prevent it from getting too small */ 16 | } 17 | ::-webkit-scrollbar-thumb:vertical:active { 18 | background: rgba(0, 0, 0, 0.61); /* Some darker color when you click it */ 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_slider.scss: -------------------------------------------------------------------------------- 1 | .slider { 2 | background: var(--accent-dark-color); 3 | display: flex; 4 | flex-wrap: nowrap; 5 | height: 4px; 6 | cursor: pointer; 7 | border-radius: var(--border-radius); 8 | width: 100%; 9 | margin: var(--sapcing-vertical) var(--spacing-horizontal); 10 | 11 | .slider-fill { 12 | background: var(--text-accent-color); 13 | color: var(--accent-dark-color); 14 | height: 100%; 15 | width: 0; 16 | display: flex; 17 | justify-content: flex-end; 18 | align-items: center; 19 | transition: width 0.5s; 20 | 21 | .slider-thumb { 22 | background: var(--accent-base-color); 23 | border: calc(var(--border-width) * 4) var(--border-style) 24 | var(--text-accent-color); //pointer-events: none; 25 | /* 26 | Min and max must be same for width and height respectively. 27 | Otherwise size issues occur if slider value is less than half of width 28 | */ 29 | min-height: 16px; 30 | min-width: 16px; 31 | max-height: 16px; 32 | max-width: 16px; 33 | margin-right: -8px; //Must always be half of width 34 | border-radius: 50%; 35 | padding: 0; 36 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 37 | 0 1px 2px rgba(0, 0, 0, 0.24); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_table.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Table default theme. 3 | Specifies the theme for the React-Virtualized Table. 4 | Forked From React-Virtualized Sources. 5 | */ 6 | 7 | .ReactVirtualized__Table { 8 | background-color: #ffffff; 9 | 10 | &:focus { 11 | outline: 0; 12 | outline-color: transparent; 13 | outline-style: none; 14 | } 15 | } 16 | 17 | .ReactVirtualized__Table__Grid { 18 | background-color: #ffffff; 19 | 20 | &:focus { 21 | outline: 0; 22 | outline-color: transparent; 23 | outline-style: none; 24 | } 25 | } 26 | 27 | .ReactVirtualized__Table__headerRow { 28 | font-weight: 700; 29 | text-transform: uppercase; 30 | display: flex; 31 | flex-direction: row; 32 | align-items: center; 33 | 34 | background-color: var(--background-color); 35 | color: var(--text-body-color); 36 | 37 | border-bottom: var(--border-width) var(--border-style) var(--border-color); 38 | } 39 | 40 | .ReactVirtualized__Table__row { 41 | display: flex; 42 | flex-direction: row; 43 | align-items: stretch; 44 | 45 | background: #ffffff; 46 | color: var(--text-body-color); 47 | transition: background 0.2s; 48 | 49 | &:hover { 50 | background-color: var(--border-color); 51 | } 52 | 53 | &:active, 54 | &:focus { 55 | background-color: var(--accent-base-color); 56 | color: var(--text-accent-color); 57 | outline: none; 58 | } 59 | } 60 | 61 | .odd-row { 62 | background: var(--background-color); 63 | } 64 | 65 | .even-row { 66 | background: #ffffff; 67 | } 68 | 69 | .ReactVirtualized__Table__headerTruncatedText { 70 | display: inline-block; 71 | max-width: 100%; 72 | white-space: nowrap; 73 | text-overflow: ellipsis; 74 | overflow: hidden; 75 | } 76 | 77 | .ReactVirtualized__Table__headerColumn { 78 | border-right: var(--border-width) var(--border-style) var(--border-color); 79 | } 80 | 81 | .ReactVirtualized__Table__headerColumn, 82 | .ReactVirtualized__Table__rowColumn { 83 | margin-right: 10px; 84 | min-width: 0px; 85 | 86 | display: inline-flex; 87 | align-items: center; 88 | } 89 | 90 | .ReactVirtualized__Table__rowColumn { 91 | text-overflow: ellipsis; 92 | white-space: nowrap; 93 | } 94 | 95 | // For Proper Scrollbar positining. 96 | .ReactVirtualized__Table__headerColumn:last-of-type, 97 | .ReactVirtualized__Table__rowColumn:last-of-type { 98 | margin-right: 16px; 99 | } 100 | 101 | .ReactVirtualized__Table__headerColumn:first-of-type, 102 | .ReactVirtualized__Table__rowColumn:first-of-type { 103 | margin-left: 10px; 104 | } 105 | 106 | .ReactVirtualized__Table__sortableHeaderColumn { 107 | cursor: pointer; 108 | } 109 | 110 | .ReactVirtualized__Table__sortableHeaderIconContainer { 111 | display: flex; 112 | align-items: center; 113 | } 114 | 115 | .ReactVirtualized__Table__sortableHeaderIcon { 116 | flex: 0 0 24px; 117 | height: 1em; 118 | width: 1em; 119 | fill: currentColor; 120 | } 121 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_tabs.scss: -------------------------------------------------------------------------------- 1 | .tab-bar { 2 | display: flex; 3 | justify-content: space-around; 4 | align-items: stretch; 5 | 6 | height: var(--element-height); 7 | } 8 | 9 | .tab-bar-item { 10 | flex: 1 1 auto; 11 | 12 | display: inline-flex; 13 | align-items: center; 14 | justify-content: center; 15 | 16 | border: var(--border-width) var(--border-style) var(--border-color); 17 | 18 | &.selected { 19 | color: var(--accent-base-color); 20 | border-bottom: calc(var(--border-width) * 2) var(--border-style) 21 | var(--accent-base-color); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/styles/elements/_textbox.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Base Textbox Class. Every Other input derives from it. 3 | */ 4 | 5 | .textbox { 6 | //Font Family not inherited from parents. Hack. 7 | font-family: inherit; 8 | line-height: inherit; 9 | background: var(--background-color); 10 | background-image: none; 11 | border: var(--border-width) var(--border-style) var(--border-color); 12 | border-radius: var(--border-radius); 13 | color: var(--body-text-color); 14 | height: var(--element-height); 15 | display: flex; 16 | 17 | margin: var(--spacing-vertical) 0px; 18 | transition: border-color 0.2s; 19 | 20 | width: 100%; 21 | box-sizing: border-box; 22 | outline: none; 23 | padding: 0px var(--spacing-horizontal); 24 | 25 | &:focus { 26 | border-color: var(--accent-base-color); 27 | //@include control-shadow(var(--primary-light-color)); 28 | } 29 | 30 | &::placeholder { 31 | color: var(--border-color); 32 | } // Textarea 33 | &.textarea { 34 | height: auto; 35 | } // File Input 36 | &[type='file'] { 37 | height: auto; 38 | } 39 | } 40 | 41 | .textbox-addon { 42 | background: var(--background-color); 43 | border: var(--border-width) var(--border-style) var(--border-color); 44 | border-radius: var(--border-radius); 45 | color: var(--body-text-color); 46 | height: var(--element-height); 47 | width: var(--element-height); 48 | min-width: var(--element-height); 49 | 50 | display: flex; 51 | align-items: center; 52 | justify-content: space-around; 53 | 54 | box-sizing: border-box; 55 | outline: none; 56 | padding: 0px var(--spacing-horizontal); 57 | 58 | &.left { 59 | border-right: none; 60 | } 61 | 62 | &.right { 63 | border-left: none; 64 | } 65 | } 66 | 67 | .editable-textbox { 68 | //Font Family not inherited from parents. Hack. 69 | font-family: inherit; 70 | line-height: inherit; 71 | background: var(--text-accent-color); 72 | background-image: none; 73 | border: var(--border-width) var(--border-style) var(--border-color); 74 | border-radius: var(--border-radius); 75 | color: var(--text-body-color); 76 | display: flex; 77 | 78 | margin: calc(var(--spacing-vertical) - 6px) var(--spacing-horizontal); 79 | transition: border-color 0.2s; 80 | 81 | width: 175px; 82 | box-sizing: border-box; 83 | -webkit-box-sizing: border-box; 84 | outline: none; 85 | padding: calc(var(--spacing-vertical) - 6px) var(--spacing-horizontal); 86 | 87 | &:focus { 88 | border-color: var(--accent-base-color); 89 | //@include control-shadow(var(--primary-light-color)); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/renderer/utilities/LibraryUtils.ts: -------------------------------------------------------------------------------- 1 | import { TrackModel } from '../database/TracksDatabase'; 2 | import orderBy from 'lodash/orderBy'; 3 | import { DraftArray } from 'immer'; 4 | 5 | /** 6 | * Sorting Function for a List of Tracks 7 | * Sorts based on any valid parameter 8 | * @param {TrackModel[]} List 9 | * @returns {TrackModel[]} Sorted List 10 | */ 11 | export function sortTracks( 12 | sortBy: string, 13 | sortDirection: 'ASC' | 'DESC', 14 | library: DraftArray 15 | ): DraftArray { 16 | if (!sortBy.localeCompare('title')) { 17 | let tracks = library.sort((a, b) => 18 | a.common.title.localeCompare(b.common.title) 19 | ); 20 | 21 | // Reverse the list 22 | if (sortDirection === 'DESC') tracks.reverse(); 23 | return tracks; 24 | // tslint:disable-next-line:no-else-after-return 25 | } else if ( 26 | !sortBy.localeCompare('Added At') || 27 | !sortBy.localeCompare('Modified At') 28 | ) { 29 | let tracks = (orderBy( 30 | library, 31 | [ 32 | (item: TrackModel) => 33 | sortBy.localeCompare('Added At') 34 | ? item.stats.ctimeMs 35 | : item.stats.mtimeMs, 36 | (item: TrackModel) => item.common.title, 37 | ], 38 | [sortDirection, 'ASC'] 39 | ) as any) as TrackModel[]; 40 | // This type assertion is a horribly wierd hack. 41 | // Black Magic copied from some stackoverflow answer. 42 | // I do not know how it works. It just works. 43 | 44 | // Reverse the list 45 | if (sortDirection === 'DESC') tracks.reverse(); 46 | return tracks; 47 | } else { 48 | let tracks = (orderBy( 49 | library, 50 | [ 51 | (item: TrackModel) => item.common[sortBy], 52 | (item: TrackModel) => item.common.title, 53 | ], 54 | [sortDirection, 'ASC'] 55 | ) as any) as TrackModel[]; 56 | // This type assertion is a horribly wierd hack. 57 | // Black Magic copied from some stackoverflow answer. 58 | // I do not know how it works. It just works. 59 | 60 | // Reverse the list 61 | if (sortDirection === 'DESC') tracks.reverse(); 62 | return tracks; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/renderer/utilities/QueueUtils.ts: -------------------------------------------------------------------------------- 1 | import { TrackModel } from '../database/TracksDatabase'; 2 | import { StateDatabase } from '../database/StateDatabase'; 3 | import { DraftArray, DraftObject } from 'immer'; 4 | 5 | /** 6 | * Fisher-Yates-shuffle for Arrays 7 | * @param {DraftArray} List 8 | * @returns {DraftArray} Shuffled List 9 | */ 10 | export function shuffleList( 11 | list: DraftArray 12 | ): DraftArray { 13 | let current: number = list.length; 14 | let temp: DraftObject; 15 | let swap: number; 16 | 17 | while (current) { 18 | swap = Math.floor(Math.random() * current--); 19 | 20 | // Swap with random element 21 | temp = list[current]; 22 | list[current] = list[swap]; 23 | list[swap] = temp; 24 | } 25 | 26 | return list; 27 | } 28 | 29 | export function updatePlayerState( 30 | queue: number[], 31 | originalQueue: number[], 32 | cursor: number 33 | ) { 34 | let db: StateDatabase = new StateDatabase('state'); 35 | db.queue 36 | .put({ queue, originalQueue, cursor, id: 1 }) 37 | .then(() => db.close()); 38 | } 39 | 40 | export function updatePlayerStateCursor(cursor: number) { 41 | let db: StateDatabase = new StateDatabase('state'); 42 | db.queue.update(1, { cursor }).then(() => db.close()); 43 | } 44 | 45 | export function updatePlayerStateQueue( 46 | queue: number[], 47 | originalQueue: number[] 48 | ) { 49 | let db: StateDatabase = new StateDatabase('state'); 50 | db.queue.update(1, { queue, originalQueue }); 51 | db.close(); 52 | } 53 | -------------------------------------------------------------------------------- /src/renderer/utilities/TimeUtils.ts: -------------------------------------------------------------------------------- 1 | export const parseToMinutes = (seconds: number): string => { 2 | let secondsFloor = Math.floor(seconds); 3 | const min = Math.floor(secondsFloor / 60); 4 | const sec = secondsFloor % 60; 5 | 6 | return String.prototype.concat( 7 | (min < 10 ? '0' : '') + min, 8 | ':', 9 | (sec < 10 ? '0' : '') + sec 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /static/readme/win-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/static/readme/win-image.jpg -------------------------------------------------------------------------------- /static/tray/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/static/tray/mac.png -------------------------------------------------------------------------------- /static/tray/mac@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/static/tray/mac@2x.png -------------------------------------------------------------------------------- /static/tray/windows.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octavezero/nighthawk/9ce69fa23cdccc5ab2f019c0964bb0a7924a41c0/static/tray/windows.ico -------------------------------------------------------------------------------- /static/vectors/defaultAlbumArt.svg: -------------------------------------------------------------------------------- 1 | artArtboard 1 -------------------------------------------------------------------------------- /static/vectors/emptyState.svg: -------------------------------------------------------------------------------- 1 | emptyArtboard 1 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "sourceMap": true, 10 | "target": "es2017" 11 | }, 12 | "formatCodeOptions": { 13 | "indentSize": 4, 14 | "tabSize": 4 15 | }, 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint-config-airbnb", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "indent": [true, "spaces", 4], 7 | "no-increment-decrement": [0], 8 | "prefer-const": false 9 | }, 10 | "rulesDirectory": [] 11 | } 12 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const path = require('path'); 4 | 5 | let mainConfig = { 6 | mode: 'development', 7 | entry: './src/main/main.ts', 8 | target: 'electron-main', 9 | output: { 10 | filename: 'main.bundle.js', 11 | path: __dirname + '/dist', 12 | }, 13 | node: { 14 | __dirname: false, 15 | __filename: false, 16 | }, 17 | resolve: { 18 | extensions: ['.js', '.json', '.ts'], 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 24 | test: /\.(ts)$/, 25 | exclude: /node_modules/, 26 | use: { 27 | loader: 'ts-loader', 28 | }, 29 | }, 30 | { 31 | test: /\.(jpg|png|svg|ico|icns)$/, 32 | loader: 'file-loader', 33 | options: { 34 | name: '[path][name].[ext]', 35 | }, 36 | }, 37 | { 38 | test: /\.(eot|ttf|woff|woff2)$/, 39 | loader: 'file-loader', 40 | options: { 41 | name: '[path][name].[ext]', 42 | }, 43 | }, 44 | ], 45 | }, 46 | }; 47 | 48 | let rendererConfig = { 49 | mode: 'development', 50 | entry: './src/renderer/renderer.tsx', 51 | target: 'electron-renderer', 52 | output: { 53 | filename: 'renderer.bundle.js', 54 | path: __dirname + '/dist', 55 | }, 56 | node: { 57 | __dirname: false, 58 | __filename: false, 59 | }, 60 | resolve: { 61 | extensions: ['.js', '.json', '.ts', '.tsx'], 62 | }, 63 | module: { 64 | rules: [ 65 | { 66 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 67 | test: /\.(ts|tsx)$/, 68 | exclude: /node_modules/, 69 | use: { 70 | loader: 'ts-loader', 71 | }, 72 | }, 73 | { 74 | test: /\.(scss|css)$/, 75 | use: [ 76 | MiniCssExtractPlugin.loader, 77 | 'css-loader?sourceMap', 78 | 'sass-loader?sourceMap', 79 | ], 80 | }, 81 | { 82 | test: /\.(jpg|png|svg|ico|icns)$/, 83 | loader: 'file-loader', 84 | options: { 85 | name: '[path][name].[ext]', 86 | }, 87 | }, 88 | { 89 | test: /\.(eot|ttf|woff|woff2)$/, 90 | loader: 'file-loader', 91 | options: { 92 | name: '[name].[ext]', 93 | }, 94 | }, 95 | ], 96 | }, 97 | plugins: [ 98 | new MiniCssExtractPlugin({ 99 | filename: 'style.css', 100 | }), 101 | new HtmlWebpackPlugin({ 102 | template: path.resolve(__dirname, './src/renderer/index.html'), 103 | }), 104 | ], 105 | }; 106 | 107 | module.exports = [mainConfig, rendererConfig]; 108 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const path = require('path'); 4 | 5 | let mainConfig = { 6 | mode: 'production', 7 | entry: './src/main/main.ts', 8 | target: 'electron-main', 9 | output: { 10 | filename: 'main.bundle.js', 11 | path: __dirname + '/dist', 12 | }, 13 | node: { 14 | __dirname: false, 15 | __filename: false, 16 | }, 17 | resolve: { 18 | extensions: ['.js', '.json', '.ts'], 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 24 | test: /\.(ts)$/, 25 | exclude: /node_modules/, 26 | use: { 27 | loader: 'ts-loader', 28 | }, 29 | }, 30 | { 31 | test: /\.(jpg|png|svg|ico|icns)$/, 32 | loader: 'file-loader', 33 | options: { 34 | name: '[path][name].[ext]', 35 | }, 36 | }, 37 | { 38 | test: /\.(eot|ttf|woff|woff2)$/, 39 | loader: 'file-loader', 40 | options: { 41 | name: '[path][name].[ext]', 42 | }, 43 | }, 44 | ], 45 | }, 46 | }; 47 | 48 | let rendererConfig = { 49 | mode: 'production', 50 | entry: './src/renderer/renderer.tsx', 51 | target: 'electron-renderer', 52 | output: { 53 | filename: 'renderer.bundle.js', 54 | path: __dirname + '/dist', 55 | }, 56 | node: { 57 | __dirname: false, 58 | __filename: false, 59 | }, 60 | resolve: { 61 | extensions: ['.js', '.json', '.ts', '.tsx'], 62 | }, 63 | module: { 64 | rules: [ 65 | { 66 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader'. 67 | test: /\.(ts|tsx)$/, 68 | exclude: /node_modules/, 69 | use: { 70 | loader: 'ts-loader', 71 | }, 72 | }, 73 | { 74 | test: /\.(scss|css)$/, 75 | use: [ 76 | MiniCssExtractPlugin.loader, 77 | 'css-loader?sourceMap', 78 | 'sass-loader?sourceMap', 79 | ], 80 | }, 81 | { 82 | test: /\.(jpg|png|svg|ico|icns)$/, 83 | loader: 'file-loader', 84 | options: { 85 | name: '[path][name].[ext]', 86 | }, 87 | }, 88 | { 89 | test: /\.(eot|ttf|woff|woff2)$/, 90 | loader: 'file-loader', 91 | options: { 92 | name: '[name].[ext]', 93 | }, 94 | }, 95 | ], 96 | }, 97 | plugins: [ 98 | new MiniCssExtractPlugin({ 99 | filename: 'style.css', 100 | }), 101 | new HtmlWebpackPlugin({ 102 | template: path.resolve(__dirname, './src/renderer/index.html'), 103 | }), 104 | ], 105 | }; 106 | 107 | module.exports = [mainConfig, rendererConfig]; 108 | --------------------------------------------------------------------------------