├── .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 | [](https://github.com/quantumkv/nighthawk)
8 | [](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 | 
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 |
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 |
--------------------------------------------------------------------------------
/static/vectors/emptyState.svg:
--------------------------------------------------------------------------------
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 |
--------------------------------------------------------------------------------