From 756d0b6d7f2855d67d24bf06d9e5268f3ae6adf4 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 11:49:01 +0200 Subject: [PATCH 01/18] initial commit --- .prettierrc | 26 +- .stylelintrc | 26 +- src/main/modules/config.ts | 206 ++-- src/renderer/App.tsx | 166 +-- src/renderer/Root.tsx | 72 +- .../components/Header/Header.module.css | 73 +- .../components/PlaylistsNav/PlaylistsNav.tsx | 306 ++--- .../components/TrackRow/TrackRow.module.css | 90 +- src/renderer/components/TrackRow/TrackRow.tsx | 285 ++--- .../TracksList/TracksList.module.css | 21 +- .../components/TracksList/TracksList.tsx | 1071 +++++++++-------- .../TracksListHeader.module.css | 27 +- .../TracksListHeader/TracksListHeader.tsx | 156 +-- .../TracksListHeaderCell.tsx | 101 +- src/renderer/constants/sort-orders.ts | 55 +- src/renderer/lib/utils.ts | 309 ++--- src/renderer/main.tsx | 50 +- src/renderer/store/actions/LibraryActions.ts | 593 ++++----- src/renderer/views/Library/Library.tsx | 148 +-- src/shared/types/museeks.ts | 187 +-- 20 files changed, 2007 insertions(+), 1961 deletions(-) diff --git a/.prettierrc b/.prettierrc index 9a773cdf8..dd118dd5c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,14 +1,16 @@ { - "printWidth": 120, - "singleQuote": true, - "jsxSingleQuote": true, - "arrowParens": "always", - "overrides": [ - { - "files": ["**/*.css"], - "options": { - "printWidth": 1000 - } - } - ] + "printWidth": 120, + "singleQuote": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "tabWidth": 4, + "useTabs": true, + "overrides": [ + { + "files": ["**/*.css"], + "options": { + "printWidth": 1000 + } + } + ] } diff --git a/.stylelintrc b/.stylelintrc index 6fd7cfd3f..365ea20e2 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,17 +1,13 @@ { - "extends": [ - "stylelint-config-standard", - "stylelint-config-css-modules" - ], - "ignoreFiles": [ - "./src/**/*.tsx", - "./src/**/*.ts" - ], - "rules": { - "no-descending-specificity": null, - "string-quotes": "single", - "max-line-length": 500, - "selector-class-pattern": null, - "alpha-value-notation": "number" - } + "extends": ["stylelint-config-standard", "stylelint-config-css-modules"], + "ignoreFiles": ["./src/**/*.tsx", "./src/**/*.ts"], + "rules": { + "no-descending-specificity": null, + "string-quotes": "double", + "max-line-length": 500, + "selector-class-pattern": null, + "tabWidth": 4, + "useTabs": true, + "alpha-value-notation": "number" + } } diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index b26ee03d7..e8e0760ec 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -2,114 +2,114 @@ * Essential module for creating/loading the app config */ -import path from 'path'; -import electron from 'electron'; -import teeny from 'teeny-conf'; +import path from "path"; +import electron from "electron"; +import teeny from "teeny-conf"; -import { Config, Repeat, SortBy, SortOrder } from '../../shared/types/museeks'; -import Module from './module'; +import { Config, Repeat, SortBy, SortOrder } from "../../shared/types/museeks"; +import Module from "./module"; const { app } = electron; class ConfigModule extends Module { - protected workArea: Electron.Rectangle; - protected conf: teeny | undefined; - - constructor() { - super(); - - this.workArea = electron.screen.getPrimaryDisplay().workArea; - } - - async load(): Promise { - const defaultConfig: Partial = this.getDefaultConfig(); - const pathUserData = app.getPath('userData'); - - this.conf = new teeny(path.join(pathUserData, 'config.json'), defaultConfig); - - // Check if config update - let configChanged = false; - - (Object.keys(defaultConfig) as (keyof Config)[]).forEach((key) => { - if (this.conf && this.conf.get(key) === undefined) { - this.conf.set(key, defaultConfig[key]); - configChanged = true; - } - }); - - // save config if changed - if (configChanged) this.conf.save(); - } - - getDefaultConfig(): Config { - const config: Config = { - theme: '__system', - audioVolume: 1, - audioPlaybackRate: 1, - audioOutputDevice: 'default', - audioMuted: false, - audioShuffle: false, - audioRepeat: Repeat.NONE, - defaultView: 'library', - librarySort: { - by: SortBy.ARTIST, - order: SortOrder.ASC, - }, - // musicFolders: [], - sleepBlocker: false, - autoUpdateChecker: true, - minimizeToTray: false, - displayNotifications: true, - bounds: { - width: 1000, - height: 600, - x: Math.round(this.workArea.width / 2), - y: Math.round(this.workArea.height / 2), - }, - }; - - return config; - } - - getConfig(): Config { - if (!this.conf) { - throw new Error('Config not loaded'); - } - - return this.conf.get() as Config; // Maybe possible to type TeenyConf with Generics? - } - - get(key: T): Config[T] { - if (!this.conf) { - throw new Error('Config not loaded'); - } - - return this.conf.get(key); - } - - set(key: T, value: Config[T]): void { - if (!this.conf) { - throw new Error('Config not loaded'); - } - - return this.conf.set(key, value); - } - - save(): void { - if (!this.conf) { - throw new Error('Config not loaded'); - } - - return this.conf.save(); - } - - reload(): void { - if (!this.conf) { - throw new Error('Config not loaded'); - } - - this.conf.reload(); - } + protected workArea: Electron.Rectangle; + protected conf: teeny | undefined; + + constructor() { + super(); + + this.workArea = electron.screen.getPrimaryDisplay().workArea; + } + + async load(): Promise { + const defaultConfig: Partial = this.getDefaultConfig(); + const pathUserData = app.getPath("userData"); + + this.conf = new teeny(path.join(pathUserData, "config.json"), defaultConfig); + + // Check if config update + let configChanged = false; + + (Object.keys(defaultConfig) as (keyof Config)[]).forEach((key) => { + if (this.conf && this.conf.get(key) === undefined) { + this.conf.set(key, defaultConfig[key]); + configChanged = true; + } + }); + + // save config if changed + if (configChanged) this.conf.save(); + } + + getDefaultConfig(): Config { + const config: Config = { + theme: "__system", + audioVolume: 1, + audioPlaybackRate: 1, + audioOutputDevice: "default", + audioMuted: false, + audioShuffle: false, + audioRepeat: Repeat.NONE, + defaultView: "library", + librarySort: { + by: SortBy.TITLE, + order: SortOrder.ASC, + }, + // musicFolders: [], + sleepBlocker: false, + autoUpdateChecker: true, + minimizeToTray: false, + displayNotifications: true, + bounds: { + width: 1000, + height: 600, + x: Math.round(this.workArea.width / 2), + y: Math.round(this.workArea.height / 2), + }, + }; + + return config; + } + + getConfig(): Config { + if (!this.conf) { + throw new Error("Config not loaded"); + } + + return this.conf.get() as Config; // Maybe possible to type TeenyConf with Generics? + } + + get(key: T): Config[T] { + if (!this.conf) { + throw new Error("Config not loaded"); + } + + return this.conf.get(key); + } + + set(key: T, value: Config[T]): void { + if (!this.conf) { + throw new Error("Config not loaded"); + } + + return this.conf.set(key, value); + } + + save(): void { + if (!this.conf) { + throw new Error("Config not loaded"); + } + + return this.conf.save(); + } + + reload(): void { + if (!this.conf) { + throw new Error("Config not loaded"); + } + + this.conf.reload(); + } } export default ConfigModule; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 49d23cd37..bcf4043cb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,22 +1,22 @@ -import os from 'os'; -import React, { useCallback, useEffect } from 'react'; -import KeyBinding from 'react-keybinding-component'; -import { useNavigate } from 'react-router'; -import { useDrop } from 'react-dnd'; -import { NativeTypes } from 'react-dnd-html5-backend'; +import os from "os"; +import React, { useCallback, useEffect } from "react"; +import KeyBinding from "react-keybinding-component"; +import { useNavigate } from "react-router"; +import { useDrop } from "react-dnd"; +import { NativeTypes } from "react-dnd-html5-backend"; -import Header from './components/Header/Header'; -import Footer from './components/Footer/Footer'; -import Toasts from './components/Toasts/Toasts'; +import Header from "./components/Header/Header"; +import Footer from "./components/Footer/Footer"; +import Toasts from "./components/Toasts/Toasts"; -import AppActions from './store/actions/AppActions'; -import * as LibraryActions from './store/actions/LibraryActions'; -import * as PlayerActions from './store/actions/PlayerActions'; +import AppActions from "./store/actions/AppActions"; +import * as LibraryActions from "./store/actions/LibraryActions"; +import * as PlayerActions from "./store/actions/PlayerActions"; -import styles from './App.module.css'; -import { isCtrlKey } from './lib/utils-platform'; -import Player from './lib/player'; -import DropzoneImport from './components/DropzoneImport/DropzoneImport'; +import styles from "./App.module.css"; +import { isCtrlKey } from "./lib/utils-platform"; +import Player from "./lib/player"; +import DropzoneImport from "./components/DropzoneImport/DropzoneImport"; /* |-------------------------------------------------------------------------- @@ -25,81 +25,81 @@ import DropzoneImport from './components/DropzoneImport/DropzoneImport'; */ type Props = { - children: React.ReactNode; + children: React.ReactNode; }; const Museeks: React.FC = (props) => { - const navigate = useNavigate(); + const navigate = useNavigate(); - // App shortcuts (not using Electron's global shortcuts API to avoid conflicts - // with other applications) - const onKey = useCallback( - async (e: KeyboardEvent) => { - switch (e.key) { - case ' ': - e.preventDefault(); - e.stopPropagation(); - PlayerActions.playPause(); - break; - case ',': - if (isCtrlKey(e)) { - e.preventDefault(); - e.stopPropagation(); - navigate('/settings'); - } - break; - case 'ArrowLeft': - e.preventDefault(); - e.stopPropagation(); - PlayerActions.jumpTo(Player.getCurrentTime() - 10); - break; - case 'ArrowRight': - e.preventDefault(); - e.stopPropagation(); - PlayerActions.jumpTo(Player.getCurrentTime() + 10); - break; - default: - break; - } - }, - [navigate] - ); + // App shortcuts (not using Electron's global shortcuts API to avoid conflicts + // with other applications) + const onKey = useCallback( + async (e: KeyboardEvent) => { + switch (e.key) { + case " ": + e.preventDefault(); + e.stopPropagation(); + PlayerActions.playPause(); + break; + case ",": + if (isCtrlKey(e)) { + e.preventDefault(); + e.stopPropagation(); + navigate("/settings"); + } + break; + case "ArrowLeft": + e.preventDefault(); + e.stopPropagation(); + PlayerActions.jumpTo(Player.getCurrentTime() - 10); + break; + case "ArrowRight": + e.preventDefault(); + e.stopPropagation(); + PlayerActions.jumpTo(Player.getCurrentTime() + 10); + break; + default: + break; + } + }, + [navigate] + ); - useEffect(() => { - AppActions.init(); - }, [navigate]); + useEffect(() => { + AppActions.init(); + }, [navigate]); - // Drop behavior to add tracks to the library from any string - const [{ isOver }, drop] = useDrop(() => { - return { - accept: [NativeTypes.FILE], - drop(item: { files: Array }) { - const files = item.files.map((file) => file.path); + // Drop behavior to add tracks to the library from any string + const [{ isOver }, drop] = useDrop(() => { + return { + accept: [NativeTypes.FILE], + drop(item: { files: Array }) { + const files = item.files.map((file) => file.path); - LibraryActions.add(files) - .then((_importedTracks) => { - // TODO: Import to playlist here - }) - .catch((err) => { - console.warn(err); - }); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }; - }); + LibraryActions.add(files) + .then((_importedTracks) => { + // TODO: Import to playlist here + }) + .catch((err) => { + console.warn(err); + }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }; + }); - return ( -
- -
-
{props.children}
-
- - -
- ); + return ( +
+ +
+
{props.children}
+
+ + +
+ ); }; export default Museeks; diff --git a/src/renderer/Root.tsx b/src/renderer/Root.tsx index bc1c5743c..8b330cabb 100644 --- a/src/renderer/Root.tsx +++ b/src/renderer/Root.tsx @@ -1,48 +1,48 @@ -import React from 'react'; +import React from "react"; -import * as ViewMessage from './elements/ViewMessage/ViewMessage'; -import ExternalLink from './elements/ExternalLink/ExternalLink'; +import * as ViewMessage from "./elements/ViewMessage/ViewMessage"; +import ExternalLink from "./elements/ExternalLink/ExternalLink"; type Props = { - children: React.ReactNode; + children: React.ReactNode; }; type State = { - hasError: boolean; + hasError: boolean; }; class Root extends React.Component { - constructor(props: Props) { - super(props); - this.state = { hasError: false }; - } - - componentDidCatch(err: Error) { - // RIP - console.error(`Museeks crashed: ${err}`); - this.setState({ hasError: true }); - } - - render() { - if (this.state.hasError) { - return ( - -

- - 💥 - {' '} - Something wrong happened -

- - If it happens again, please{' '} - report an issue - -
- ); - } - - return this.props.children; - } + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(err: Error) { + // RIP + console.error(`Museeks crashed: ${err}`); + this.setState({ hasError: true }); + } + + render() { + if (this.state.hasError) { + return ( + +

+ + 💥 + {" "} + Something wrong happened +

+ + If it happens again, please{" "} + report an issue + +
+ ); + } + + return this.props.children; + } } export default Root; diff --git a/src/renderer/components/Header/Header.module.css b/src/renderer/components/Header/Header.module.css index 3e9073b36..b6b8a6ce7 100644 --- a/src/renderer/components/Header/Header.module.css +++ b/src/renderer/components/Header/Header.module.css @@ -1,60 +1,61 @@ :global(.os__darwin) .header__mainControls { - padding-left: 65px; /* let some space for titleBarStyle */ + padding-left: 65px; /* let some space for titleBarStyle */ } /* The native frame may be light, so we need to increase the contrast between the frame and the header */ :global(.os__win32), :global(.os__linux) { - .header { - border-top: 1px solid var(--border-color); - } + .header { + border-top: 1px solid var(--border-color); + } } .header { - border-bottom: 1px solid var(--border-color); - background-color: var(--header-bg); - color: var(--header-color); - padding: 0 10px; - display: flex; - align-items: center; - justify-content: space-between; - height: 50px; - flex: 0 0 auto; + border-bottom: 1px solid var(--border-color); + background-color: var(--header-bg); + color: var(--header-color); + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + height: 50px; + padding: 5px; + flex: 0 0 auto; - /* Draggable region (zone able to move the window) */ - -webkit-app-region: drag; + /* Draggable region (zone able to move the window) */ + -webkit-app-region: drag; } .header__mainControls { - width: 220px; - flex: 0 0 auto; - display: flex; - align-items: center; - padding-right: 10px; + width: 220px; + flex: 0 0 auto; + display: flex; + align-items: center; + padding-right: 10px; } .header__search { - -webkit-app-region: no-drag; - width: 220px; - flex: 0 0 auto; - display: flex; - justify-content: flex-end; + -webkit-app-region: no-drag; + width: 220px; + flex: 0 0 auto; + display: flex; + justify-content: flex-end; } .header__search__input { - display: block; - font-size: inherit; - width: 100%; - padding: 6px 12px; - background-color: var(--search-bg); - border: 1px solid var(--border-color); - color: var(--text); - border-radius: var(--border-radius); + display: block; + font-size: inherit; + width: 100%; + padding: 6px 12px; + background-color: var(--search-bg); + border: 1px solid var(--border-color); + color: var(--text); + border-radius: var(--border-radius); } .header__playingBar { - flex: 1 1 auto; - min-width: 0; - max-width: 600px; + flex: 1 1 auto; + min-width: 0; + max-width: 600px; } diff --git a/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx b/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx index 9050723de..acc143174 100644 --- a/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx +++ b/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx @@ -1,166 +1,170 @@ /* eslint-disable jsx-a11y/no-autofocus */ -import electron from 'electron'; -import { Menu } from '@electron/remote'; -import React from 'react'; -import Icon from 'react-fontawesome'; +import electron from "electron"; +import { Menu } from "@electron/remote"; +import React from "react"; +import Icon from "react-fontawesome"; -import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; -import PlaylistsNavLink from '../PlaylistsNavLink/PlaylistsNavLink'; -import { PlaylistModel } from '../../../shared/types/museeks'; +import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; +import PlaylistsNavLink from "../PlaylistsNavLink/PlaylistsNavLink"; +import { PlaylistModel } from "../../../shared/types/museeks"; -import styles from './PlaylistsNav.module.css'; +import styles from "./PlaylistsNav.module.css"; interface Props { - playlists: PlaylistModel[]; + playlists: PlaylistModel[]; } interface State { - renamed: string | null; + renamed: string | null; } class PlaylistsNav extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - renamed: null, // the playlist being renamed if there's one - }; - - this.blur = this.blur.bind(this); - this.focus = this.focus.bind(this); - this.keyDown = this.keyDown.bind(this); - this.showContextMenu = this.showContextMenu.bind(this); - this.createPlaylist = this.createPlaylist.bind(this); - } - - showContextMenu(playlistId: string) { - const template: electron.MenuItemConstructorOptions[] = [ - { - label: 'Rename', - click: () => { - this.setState({ renamed: playlistId }); - }, - }, - { - label: 'Delete', - click: async () => { - await PlaylistsActions.remove(playlistId); - }, - }, - { - type: 'separator', - }, - { - label: 'Duplicate', - click: async () => { - await PlaylistsActions.duplicate(playlistId); - }, - }, - { - type: 'separator', - }, - { - label: 'Export', - click: async () => { - await PlaylistsActions.exportToM3u(playlistId); - }, - }, - ]; - - const context = Menu.buildFromTemplate(template); - - context.popup({}); // Let it appear - } - - async createPlaylist() { - // Todo 'new playlist 1', 'new playlist 2' ... - await PlaylistsActions.create('New playlist', [], false, true); - } - - async rename(_id: string, name: string) { - await PlaylistsActions.rename(_id, name); - } - - async keyDown(e: React.KeyboardEvent) { - e.persist(); - - switch (e.nativeEvent.code) { - case 'Enter': { - // Enter - if (this.state.renamed && e.currentTarget) { - await this.rename(this.state.renamed, e.currentTarget.value); - this.setState({ renamed: null }); - } - break; - } - case 'Escape': { - // Escape - this.setState({ renamed: null }); - break; - } - default: { - break; - } - } - } - - async blur(e: React.FocusEvent) { - if (this.state.renamed) { - await this.rename(this.state.renamed, e.currentTarget.value); - } - - this.setState({ renamed: null }); - } - - focus(e: React.FocusEvent) { - e.currentTarget.select(); - } - - render() { - const { playlists } = this.props; - - // TODO (y.solovyov): extract into separate method that returns items - const nav = playlists.map((elem) => { - let navItemContent; - - if (elem._id === this.state.renamed) { - navItemContent = ( - - ); - } else { - navItemContent = ( - - {elem.name} - - ); - } - - return
{navItemContent}
; - }); - - return ( -
-
-

Playlists

-
- -
-
-
{nav}
-
- ); - } + constructor(props: Props) { + super(props); + + this.state = { + renamed: null, // the playlist being renamed if there's one + }; + + this.blur = this.blur.bind(this); + this.focus = this.focus.bind(this); + this.keyDown = this.keyDown.bind(this); + this.showContextMenu = this.showContextMenu.bind(this); + this.createPlaylist = this.createPlaylist.bind(this); + } + + showContextMenu(playlistId: string) { + const template: electron.MenuItemConstructorOptions[] = [ + { + label: "Rename", + click: () => { + this.setState({ renamed: playlistId }); + }, + }, + { + label: "Delete", + click: async () => { + await PlaylistsActions.remove(playlistId); + }, + }, + { + type: "separator", + }, + { + label: "Duplicate", + click: async () => { + await PlaylistsActions.duplicate(playlistId); + }, + }, + { + type: "separator", + }, + { + label: "Export", + click: async () => { + await PlaylistsActions.exportToM3u(playlistId); + }, + }, + ]; + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + } + + async createPlaylist() { + // Todo 'new playlist 1', 'new playlist 2' ... + await PlaylistsActions.create("New playlist", [], false, true); + } + + async rename(_id: string, name: string) { + await PlaylistsActions.rename(_id, name); + } + + async keyDown(e: React.KeyboardEvent) { + e.persist(); + + switch (e.nativeEvent.code) { + case "Enter": { + // Enter + if (this.state.renamed && e.currentTarget) { + await this.rename(this.state.renamed, e.currentTarget.value); + this.setState({ renamed: null }); + } + break; + } + case "Escape": { + // Escape + this.setState({ renamed: null }); + break; + } + default: { + break; + } + } + } + + async blur(e: React.FocusEvent) { + if (this.state.renamed) { + await this.rename(this.state.renamed, e.currentTarget.value); + } + + this.setState({ renamed: null }); + } + + focus(e: React.FocusEvent) { + e.currentTarget.select(); + } + + render() { + const { playlists } = this.props; + + // TODO (y.solovyov): extract into separate method that returns items + const nav = playlists.map((elem) => { + let navItemContent; + + if (elem._id === this.state.renamed) { + navItemContent = ( + + ); + } else { + navItemContent = ( + + {elem.name} + + ); + } + + return
{navItemContent}
; + }); + + return ( +
+
+

Playlists

+
+ +
+
+
{nav}
+
+ ); + } } export default PlaylistsNav; diff --git a/src/renderer/components/TrackRow/TrackRow.module.css b/src/renderer/components/TrackRow/TrackRow.module.css index e27fee010..170849040 100644 --- a/src/renderer/components/TrackRow/TrackRow.module.css +++ b/src/renderer/components/TrackRow/TrackRow.module.css @@ -1,30 +1,32 @@ .cell { - padding: 3px 4px; - cursor: default; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 24px; + padding: 3px 4px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 15px; } .track { - position: relative; - display: flex; - outline: none; + position: relative; + display: flex; + outline: none; + height: 50px; + padding: 5px; - &:nth-child(odd) { - background-color: var(--tracks-bg-odd); - } + &:nth-child(odd) { + background-color: var(--tracks-bg-odd); + } - &:nth-child(even) { - background-color: var(--tracks-bg-even); - } + &:nth-child(even) { + background-color: var(--tracks-bg-even); + } - &.selected { - background-color: var(--main-color); - color: white; + &.selected { + background-color: var(--main-color); + color: white; - /* // put that elsewhere someday + /* // put that elsewhere someday .playingIndicator { .animation { @@ -34,29 +36,29 @@ } } } */ - } - - &.reordered { - opacity: 0.5; - } - - &.isReorderedOver { - &::after { - pointer-events: none; - position: absolute; - z-index: 1; - display: block; - width: 100%; - content: ''; - border-bottom: solid 2px var(--main-color); - } - - &.isAbove::after { - top: -1px; - } - - &.isBelow::after { - bottom: -1px; - } - } + } + + &.reordered { + opacity: 0.5; + } + + &.isReorderedOver { + &::after { + pointer-events: none; + position: absolute; + z-index: 1; + display: block; + width: 100%; + content: ""; + border-bottom: solid 2px var(--main-color); + } + + &.isAbove::after { + top: -1px; + } + + &.isBelow::after { + bottom: -1px; + } + } } diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index e4ac56b25..4175f0d75 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -1,151 +1,156 @@ -import React from 'react'; -import cx from 'classnames'; +import React from "react"; +import cx from "classnames"; -import PlayingIndicator from '../PlayingIndicator/PlayingIndicator'; -import { parseDuration } from '../../lib/utils'; -import { TrackModel } from '../../../shared/types/museeks'; +import PlayingIndicator from "../PlayingIndicator/PlayingIndicator"; +import { parseDuration } from "../../lib/utils"; +import { TrackModel } from "../../../shared/types/museeks"; -import cellStyles from '../TracksListHeader/TracksListHeader.module.css'; -import styles from './TrackRow.module.css'; +import cellStyles from "../TracksListHeader/TracksListHeader.module.css"; +import styles from "./TrackRow.module.css"; interface Props { - selected: boolean; - track: TrackModel; - index: number; - isPlaying: boolean; - onDoubleClick: (trackId: string) => void; - onMouseDown: (event: React.MouseEvent, trackId: string, index: number) => void; - onContextMenu: (event: React.MouseEvent, index: number) => void; - onClick: (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => void; - - draggable?: boolean; - reordered?: boolean; - onDragStart?: () => void; - onDragOver?: (trackId: string, position: 'above' | 'below') => void; - onDragEnd?: () => void; - onDrop?: (targetTrackId: string, position: 'above' | 'below') => void; + selected: boolean; + track: TrackModel; + index: number; + isPlaying: boolean; + onDoubleClick: (trackId: string) => void; + onMouseDown: (event: React.MouseEvent, trackId: string, index: number) => void; + onContextMenu: (event: React.MouseEvent, index: number) => void; + onClick: (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => void; + + draggable?: boolean; + reordered?: boolean; + onDragStart?: () => void; + onDragOver?: (trackId: string, position: "above" | "below") => void; + onDragEnd?: () => void; + onDrop?: (targetTrackId: string, position: "above" | "below") => void; } interface State { - reorderOver: boolean; - reorderPosition: 'above' | 'below' | null; + reorderOver: boolean; + reorderPosition: "above" | "below" | null; } export default class TrackRow extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - reorderOver: false, - reorderPosition: null, - }; - } - - onMouseDown = (e: React.MouseEvent) => { - this.props.onMouseDown(e, this.props.track._id, this.props.index); - }; - - onClick = (e: React.MouseEvent | React.KeyboardEvent) => { - this.props.onClick(e, this.props.track._id); - }; - - onContextMenu = (e: React.MouseEvent) => { - this.props.onContextMenu(e, this.props.index); - }; - - onDoubleClick = () => { - this.props.onDoubleClick(this.props.track._id); - }; - - onDragStart = (event: React.DragEvent) => { - const { onDragStart } = this.props; - - if (onDragStart) { - event.dataTransfer.setData('text/plain', this.props.track._id); - event.dataTransfer.dropEffect = 'move'; - event.dataTransfer.effectAllowed = 'move'; - - onDragStart(); - } - }; - - onDragOver = (event: React.DragEvent) => { - event.preventDefault(); - - const relativePosition = event.nativeEvent.offsetY / event.currentTarget.offsetHeight; - const dragPosition = relativePosition < 0.5 ? 'above' : 'below'; - - this.setState({ - reorderOver: true, - reorderPosition: dragPosition, - }); - }; - - onDragLeave = (_event: React.DragEvent) => { - this.setState({ - reorderOver: false, - reorderPosition: null, - }); - }; - - onDrop = (_event: React.DragEvent) => { - const { reorderPosition } = this.state; - const { onDrop } = this.props; - - if (reorderPosition && onDrop) { - onDrop(this.props.track._id, reorderPosition); - } - - this.setState({ - reorderOver: false, - reorderPosition: null, - }); - }; - - render() { - const { track, selected, reordered, draggable } = this.props; - const { reorderOver, reorderPosition } = this.state; - - const trackClasses = cx(styles.track, { - [styles.selected]: selected, - [styles.reordered]: reordered, - [styles.isReorderedOver]: reorderOver, - [styles.isAbove]: reorderPosition === 'above', - [styles.isBelow]: reorderPosition === 'below', - }); - - return ( -
{ - if (e.key === 'Enter') { - this.onClick(e); - } - }} - onContextMenu={this.onContextMenu} - role='option' - aria-selected={selected} - tabIndex={-1} // we do not want trackrows to be focusable by the keyboard - draggable={draggable} - onDragStart={(draggable && this.onDragStart) || undefined} - onDragOver={(draggable && this.onDragOver) || undefined} - onDragLeave={(draggable && this.onDragLeave) || undefined} - onDrop={(draggable && this.onDrop) || undefined} - onDragEnd={(draggable && this.props.onDragEnd) || undefined} - {...(this.props.isPlaying ? { 'data-is-playing': true } : {})} - > -
- {this.props.isPlaying ? : null} -
-
{track.title}
-
{parseDuration(track.duration)}
-
{track.artist.sort().join(', ')}
-
{track.album}
-
{track.genre.join(', ')}
-
- ); - } + constructor(props: Props) { + super(props); + + this.state = { + reorderOver: false, + reorderPosition: null, + }; + } + + onMouseDown = (e: React.MouseEvent) => { + this.props.onMouseDown(e, this.props.track._id, this.props.index); + }; + + onClick = (e: React.MouseEvent | React.KeyboardEvent) => { + this.props.onClick(e, this.props.track._id); + }; + + onContextMenu = (e: React.MouseEvent) => { + this.props.onContextMenu(e, this.props.index); + }; + + onDoubleClick = () => { + this.props.onDoubleClick(this.props.track._id); + }; + + onDragStart = (event: React.DragEvent) => { + const { onDragStart } = this.props; + + if (onDragStart) { + event.dataTransfer.setData("text/plain", this.props.track._id); + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.effectAllowed = "move"; + + onDragStart(); + } + }; + + onDragOver = (event: React.DragEvent) => { + event.preventDefault(); + + const relativePosition = event.nativeEvent.offsetY / event.currentTarget.offsetHeight; + const dragPosition = relativePosition < 0.5 ? "above" : "below"; + + this.setState({ + reorderOver: true, + reorderPosition: dragPosition, + }); + }; + + onDragLeave = (_event: React.DragEvent) => { + this.setState({ + reorderOver: false, + reorderPosition: null, + }); + }; + + onDrop = (_event: React.DragEvent) => { + const { reorderPosition } = this.state; + const { onDrop } = this.props; + + if (reorderPosition && onDrop) { + onDrop(this.props.track._id, reorderPosition); + } + + this.setState({ + reorderOver: false, + reorderPosition: null, + }); + }; + + render() { + const { track, selected, reordered, draggable } = this.props; + const { reorderOver, reorderPosition } = this.state; + + const trackClasses = cx(styles.track, { + [styles.selected]: selected, + [styles.reordered]: reordered, + [styles.isReorderedOver]: reorderOver, + [styles.isAbove]: reorderPosition === "above", + [styles.isBelow]: reorderPosition === "below", + }); + + return ( +
{ + if (e.key === "Enter") { + this.onClick(e); + } + }} + onContextMenu={this.onContextMenu} + role="option" + aria-selected={selected} + tabIndex={-1} // we do not want trackrows to be focusable by the keyboard + draggable={draggable} + onDragStart={(draggable && this.onDragStart) || undefined} + onDragOver={(draggable && this.onDragOver) || undefined} + onDragLeave={(draggable && this.onDragLeave) || undefined} + onDrop={(draggable && this.onDrop) || undefined} + onDragEnd={(draggable && this.props.onDragEnd) || undefined} + {...(this.props.isPlaying ? { "data-is-playing": true } : {})} + > +
+ {this.props.isPlaying ? : null} +
+
+
{track.title}
+
{track.artist.sort().join(", ")}
+
+
{parseDuration(track.duration)}
+
{track.album}
+
+ {new Date(track.added || 0).toDateString()} +
+
{track.genre.join(", ")}
+
+ ); + } } diff --git a/src/renderer/components/TracksList/TracksList.module.css b/src/renderer/components/TracksList/TracksList.module.css index 5afd11b09..cefb949a4 100644 --- a/src/renderer/components/TracksList/TracksList.module.css +++ b/src/renderer/components/TracksList/TracksList.module.css @@ -1,21 +1,22 @@ .tracksList { - outline: none; - display: flex; - flex-direction: column; - flex: 1 1 auto; + outline: none; + display: flex; + flex-direction: column; + flex: 1 1 auto; } .tracksListBody { - overflow: auto; - flex: 1 1 auto; + overflow: auto; + flex: 1 1 auto; } .tiles { - position: relative; + position: relative; } .tile { - position: absolute; - width: 100%; - z-index: 10; + position: absolute; + width: 100%; + height: 60px; + z-index: 10; } diff --git a/src/renderer/components/TracksList/TracksList.tsx b/src/renderer/components/TracksList/TracksList.tsx index d1e71a69c..70f9c047c 100644 --- a/src/renderer/components/TracksList/TracksList.tsx +++ b/src/renderer/components/TracksList/TracksList.tsx @@ -1,33 +1,33 @@ -import electron from 'electron'; -import { Menu } from '@electron/remote'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import KeyBinding from 'react-keybinding-component'; -import chunk from 'lodash-es/chunk'; -import { useSelector } from 'react-redux'; - -import { useNavigate } from 'react-router'; -import TrackRow from '../TrackRow/TrackRow'; -import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; -import TracksListHeader from '../TracksListHeader/TracksListHeader'; - -import * as LibraryActions from '../../store/actions/LibraryActions'; -import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; -import * as PlayerActions from '../../store/actions/PlayerActions'; -import * as QueueActions from '../../store/actions/QueueActions'; - -import { isLeftClick, isRightClick } from '../../lib/utils-events'; -import { isCtrlKey, isAltKey } from '../../lib/utils-platform'; -import { PlaylistModel, TrackModel, PlayerStatus } from '../../../shared/types/museeks'; -import { RootState } from '../../store/reducers'; - -import scrollbarStyles from '../CustomScrollbar/CustomScrollbar.module.css'; -import headerStyles from '../Header/Header.module.css'; -import styles from './TracksList.module.css'; +import electron from "electron"; +import { Menu } from "@electron/remote"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import KeyBinding from "react-keybinding-component"; +import chunk from "lodash-es/chunk"; +import { useSelector } from "react-redux"; + +import { useNavigate } from "react-router"; +import TrackRow from "../TrackRow/TrackRow"; +import CustomScrollbar from "../CustomScrollbar/CustomScrollbar"; +import TracksListHeader from "../TracksListHeader/TracksListHeader"; + +import * as LibraryActions from "../../store/actions/LibraryActions"; +import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; +import * as PlayerActions from "../../store/actions/PlayerActions"; +import * as QueueActions from "../../store/actions/QueueActions"; + +import { isLeftClick, isRightClick } from "../../lib/utils-events"; +import { isCtrlKey, isAltKey } from "../../lib/utils-platform"; +import { PlaylistModel, TrackModel, PlayerStatus } from "../../../shared/types/museeks"; +import { RootState } from "../../store/reducers"; + +import scrollbarStyles from "../CustomScrollbar/CustomScrollbar.module.css"; +import headerStyles from "../Header/Header.module.css"; +import styles from "./TracksList.module.css"; const { shell } = electron; const CHUNK_LENGTH = 20; -const ROW_HEIGHT = 30; // FIXME +const ROW_HEIGHT = 50; // FIXME const TILES_TO_DISPLAY = 5; const TILE_HEIGHT = ROW_HEIGHT * CHUNK_LENGTH; @@ -36,518 +36,519 @@ const TILE_HEIGHT = ROW_HEIGHT * CHUNK_LENGTH; // -------------------------------------------------------------------------- interface Props { - type: string; - playerStatus: string; - tracks: TrackModel[]; - trackPlayingId: string | null; - playlists: PlaylistModel[]; - currentPlaylist?: string; - reorderable?: boolean; - onReorder?: (playlistId: string, tracksIds: string[], targetTrackId: string, position: 'above' | 'below') => void; + type: string; + playerStatus: string; + tracks: TrackModel[]; + trackPlayingId: string | null; + playlists: PlaylistModel[]; + currentPlaylist?: string; + reorderable?: boolean; + onReorder?: (playlistId: string, tracksIds: string[], targetTrackId: string, position: "above" | "below") => void; } const TracksList: React.FC = (props) => { - const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists } = props; - - const [tilesScrolled, setTilesScrolled] = useState(0); - const [selected, setSelected] = useState([]); - const [reordered, setReordered] = useState([]); - const [renderView, setRenderView] = useState(null); - const navigate = useNavigate(); - - const highlight = useSelector((state) => state.library.highlightPlayingTrack); - - // Highlight playing track and scroll to it - useEffect(() => { - if (highlight === true && trackPlayingId && renderView) { - setSelected([trackPlayingId]); - - const playingTrackIndex = tracks.findIndex((track) => track._id === trackPlayingId); - - if (playingTrackIndex >= 0) { - const nodeOffsetTop = playingTrackIndex * ROW_HEIGHT; - - renderView.scrollTop = nodeOffsetTop; - } - - LibraryActions.highlightPlayingTrack(false); - } - }, [highlight, trackPlayingId, renderView, tracks]); - - // FIXME: find a way to use a real ref for the render view - useEffect(() => { - const element = document.querySelector(`.${scrollbarStyles.renderView}`); - - if (element instanceof HTMLElement) setRenderView(element); - }, []); - - /** - * Helpers - */ - - const startPlayback = useCallback( - async (_id: string) => { - PlayerActions.start(tracks, _id); - }, - [tracks] - ); - - /** - * Keyboard navigations events/helpers - */ - const onEnter = useCallback(async (i: number, tracks: TrackModel[]) => { - if (i !== -1) PlayerActions.start(tracks, tracks[i]._id); - }, []); - - const onControlAll = useCallback( - (i: number, tracks: TrackModel[]) => { - setSelected(tracks.map((track) => track._id)); - const nodeOffsetTop = (i - 1) * ROW_HEIGHT; - - if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; - }, - [renderView] - ); - - const onUp = useCallback( - (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { - if (i - 1 >= 0) { - // Issue #489, shift key modifier - let newSelected = selected; - - if (shiftKeyPressed) newSelected = [tracks[i - 1]._id, ...selected]; - else newSelected = [tracks[i - 1]._id]; - - setSelected(newSelected); - const nodeOffsetTop = (i - 1) * ROW_HEIGHT; - if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; - } - }, - [renderView, selected] - ); - - const onDown = useCallback( - (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { - if (i + 1 < tracks.length) { - // Issue #489, shift key modifier - let newSelected = selected; - if (shiftKeyPressed) newSelected.push(tracks[i + 1]._id); - else newSelected = [tracks[i + 1]._id]; - - setSelected(newSelected); - const nodeOffsetTop = (i + 1) * ROW_HEIGHT; - - if (renderView && renderView.scrollTop + renderView.offsetHeight <= nodeOffsetTop + ROW_HEIGHT) { - renderView.scrollTop = nodeOffsetTop - renderView.offsetHeight + ROW_HEIGHT; - } - } - }, - [renderView, selected] - ); - - const onKey = useCallback( - async (e: KeyboardEvent) => { - let firstSelectedTrackId = tracks.findIndex((track) => selected.includes(track._id)); - - switch (e.code) { - case 'KeyA': - if (isCtrlKey(e)) { - onControlAll(firstSelectedTrackId, tracks); - e.preventDefault(); - } - break; - - case 'ArrowUp': - e.preventDefault(); - onUp(firstSelectedTrackId, tracks, e.shiftKey); - break; - - case 'ArrowDown': - // This effectively becomes lastSelectedTrackID - firstSelectedTrackId = tracks.findIndex((track) => selected[selected.length - 1] === track._id); - e.preventDefault(); - onDown(firstSelectedTrackId, tracks, e.shiftKey); - break; - - case 'Enter': - e.preventDefault(); - await onEnter(firstSelectedTrackId, tracks); - break; - - default: - break; - } - }, - [onControlAll, onDown, onUp, onEnter, selected, tracks] - ); - - /** - * Playlists re-order events handlers - */ - const onReorderStart = useCallback(() => setReordered(selected), [selected]); - const onReorderEnd = useCallback(() => setReordered(null), []); - - const onDrop = useCallback( - async (targetTrackId: string, position: 'above' | 'below') => { - if (onReorder && currentPlaylist && reordered) { - onReorder(currentPlaylist, reordered, targetTrackId, position); - } - }, - [currentPlaylist, onReorder, reordered] - ); - - /** - * Tracks selection - */ - const isSelectableTrack = useCallback((id: string) => !selected.includes(id), [selected]); - - const sortSelected = useCallback( - (a: string, b: string): number => { - const allTracksIds = tracks.map((track) => track._id); - - return allTracksIds.indexOf(a) - allTracksIds.indexOf(b); - }, - [tracks] - ); - - const toggleSelectionById = useCallback( - (id: string) => { - let newSelected = [...selected]; - - if (newSelected.includes(id)) { - // remove track - newSelected.splice(newSelected.indexOf(id), 1); - } else { - // add track - newSelected.push(id); - } - - newSelected = newSelected.sort(sortSelected); - setSelected(newSelected); - }, - [selected, sortSelected] - ); - - const multiSelect = useCallback( - (index: number) => { - const selectedInt = []; - - // Prefer destructuring - for (let i = 0; i < tracks.length; i++) { - if (selected.includes(tracks[i]._id)) { - selectedInt.push(i); - } - } - - let base; - const min = Math.min(...selectedInt); - const max = Math.max(...selectedInt); - - if (index < min) { - base = max; - } else { - base = min; - } - - const newSelected = []; - - if (index < min) { - for (let i = 0; i <= Math.abs(index - base); i++) { - newSelected.push(tracks[base - i]._id); - } - } else if (index > max) { - for (let i = 0; i <= Math.abs(index - base); i++) { - newSelected.push(tracks[base + i]._id); - } - } - - setSelected(newSelected.sort(sortSelected)); - }, - [selected, sortSelected, tracks] - ); - - const selectTrack = useCallback( - (event: React.MouseEvent, trackId: string, index: number) => { - // To allow selection drag-and-drop, we need to prevent track selection - // when selection a track that is already selected - if (selected.includes(trackId) && !event.metaKey && !event.ctrlKey && !event.shiftKey) { - return; - } - - if (isLeftClick(event) || (isRightClick(event) && isSelectableTrack(trackId))) { - if (isCtrlKey(event)) { - toggleSelectionById(trackId); - } else if (event.shiftKey) { - if (selected.length === 0) { - const newSelected = [trackId]; - setSelected(newSelected); - } else { - multiSelect(index); - } - } else { - if (!isAltKey(event)) { - const newSelected = [trackId]; - setSelected(newSelected); - } - } - } - }, - [selected, multiSelect, toggleSelectionById, isSelectableTrack] - ); - - const selectTrackClick = useCallback( - (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => { - if (!event.metaKey && !event.ctrlKey && !event.shiftKey && selected.includes(trackId)) { - setSelected([trackId]); - } - }, - [selected] - ); - - /** - * Context menus - */ - const showContextMenu = useCallback( - (_e: React.MouseEvent, index: number) => { - const selectedCount = selected.length; - const track = tracks[index]; - let shownPlaylists = playlists; - - // Hide current playlist if needed - if (type === 'playlist') { - shownPlaylists = playlists.filter((elem) => elem._id !== currentPlaylist); - } - - const playlistTemplate: electron.MenuItemConstructorOptions[] = []; - let addToQueueTemplate: electron.MenuItemConstructorOptions[] = []; - - if (shownPlaylists) { - playlistTemplate.push( - { - label: 'Create new playlist...', - click: async () => { - await PlaylistsActions.create('New playlist', selected); - }, - }, - { - type: 'separator', - } - ); - - if (shownPlaylists.length === 0) { - playlistTemplate.push({ - label: 'No playlists', - enabled: false, - }); - } else { - shownPlaylists.forEach((playlist) => { - playlistTemplate.push({ - label: playlist.name, - click: async () => { - await PlaylistsActions.addTracks(playlist._id, selected); - }, - }); - }); - } - } - - if (playerStatus !== PlayerStatus.STOP) { - addToQueueTemplate = [ - { - label: 'Add to queue', - click: async () => { - await QueueActions.addAfter(selected); - }, - }, - { - label: 'Play next', - click: async () => { - await QueueActions.addNext(selected); - }, - }, - { - type: 'separator', - }, - ]; - } - - const template: electron.MenuItemConstructorOptions[] = [ - { - label: selectedCount > 1 ? `${selectedCount} tracks selected` : `${selectedCount} track selected`, - enabled: false, - }, - { - type: 'separator', - }, - ...addToQueueTemplate, - { - label: 'Add to playlist', - submenu: playlistTemplate, - }, - { - type: 'separator', - }, - ]; - - for (const artist of track.artist) { - template.push({ - label: `Search for "${artist}" `, - click: () => { - // HACK - const searchInput: HTMLInputElement | null = document.querySelector( - `input[type="text"].${headerStyles.header__search__input}` - ); - - if (searchInput) { - searchInput.value = track.artist[0]; - searchInput.dispatchEvent(new Event('input', { bubbles: true })); - } - }, - }); - } - - template.push({ - label: `Search for "${track.album}"`, - click: () => { - // HACK - const searchInput: HTMLInputElement | null = document.querySelector( - `input[type="text"].${headerStyles.header__search__input}` - ); - - if (searchInput) { - searchInput.value = track.album; - searchInput.dispatchEvent(new Event('input', { bubbles: true })); - } - }, - }); - - if (type === 'playlist' && currentPlaylist) { - template.push( - { - type: 'separator', - }, - { - label: 'Remove from playlist', - click: async () => { - await PlaylistsActions.removeTracks(currentPlaylist, selected); - }, - } - ); - } - - template.push( - { - type: 'separator', - }, - { - label: 'Edit track', - click: () => { - navigate(`/details/${track._id}`); - }, - }, - { - type: 'separator', - }, - { - label: 'Show in file manager', - click: () => { - shell.showItemInFolder(track.path); - }, - }, - { - label: 'Remove from library', - click: () => { - LibraryActions.remove(selected); - }, - } - ); - - const context = Menu.buildFromTemplate(template); - - context.popup({}); // Let it appear - }, - [currentPlaylist, playerStatus, playlists, selected, tracks, type, navigate] - ); - - /** - * Tracks list virtualization + rendering - */ - - const onScroll = useCallback(() => { - if (renderView) { - const nextTilesScrolled = Math.floor(renderView.scrollTop / TILE_HEIGHT); - - if (tilesScrolled !== nextTilesScrolled) { - setTilesScrolled(nextTilesScrolled); - } - } - }, [tilesScrolled, renderView]); - - const trackTiles = useMemo(() => { - const tracksChunked = chunk(tracks, CHUNK_LENGTH); - - return tracksChunked.splice(tilesScrolled, TILES_TO_DISPLAY).map((tracksChunk, indexChunk) => { - const list = tracksChunk.map((track, index) => { - const trackRowIndex = (tilesScrolled + indexChunk) * CHUNK_LENGTH + index; - - return ( - - ); - }); - - const translationDistance = tilesScrolled * ROW_HEIGHT * CHUNK_LENGTH + indexChunk * ROW_HEIGHT * CHUNK_LENGTH; - const tracksListTileStyles = { - transform: `translate3d(0, ${translationDistance}px, 0)`, - }; - - return ( -
- {list} -
- ); - }); - }, [ - reordered, - selected, - tilesScrolled, - reorderable, - trackPlayingId, - tracks, - onDrop, - onReorderStart, - onReorderEnd, - selectTrack, - selectTrackClick, - showContextMenu, - startPlayback, - ]); - - return ( -
- - - -
- {trackTiles} -
-
-
- ); + const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists } = props; + + const [tilesScrolled, setTilesScrolled] = useState(0); + const [selected, setSelected] = useState([]); + const [reordered, setReordered] = useState([]); + const [renderView, setRenderView] = useState(null); + const navigate = useNavigate(); + + const highlight = useSelector((state) => state.library.highlightPlayingTrack); + + // Highlight playing track and scroll to it + useEffect(() => { + if (highlight === true && trackPlayingId && renderView) { + setSelected([trackPlayingId]); + + const playingTrackIndex = tracks.findIndex((track) => track._id === trackPlayingId); + + if (playingTrackIndex >= 0) { + const nodeOffsetTop = playingTrackIndex * ROW_HEIGHT; + + renderView.scrollTop = nodeOffsetTop; + } + + LibraryActions.highlightPlayingTrack(false); + } + }, [highlight, trackPlayingId, renderView, tracks]); + + // FIXME: find a way to use a real ref for the render view + useEffect(() => { + const element = document.querySelector(`.${scrollbarStyles.renderView}`); + + if (element instanceof HTMLElement) setRenderView(element); + }, []); + + /** + * Helpers + */ + + const startPlayback = useCallback( + async (_id: string) => { + PlayerActions.start(tracks, _id); + }, + [tracks] + ); + + /** + * Keyboard navigations events/helpers + */ + const onEnter = useCallback(async (i: number, tracks: TrackModel[]) => { + if (i !== -1) PlayerActions.start(tracks, tracks[i]._id); + }, []); + + const onControlAll = useCallback( + (i: number, tracks: TrackModel[]) => { + setSelected(tracks.map((track) => track._id)); + const nodeOffsetTop = (i - 1) * ROW_HEIGHT; + + if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; + }, + [renderView] + ); + + const onUp = useCallback( + (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { + if (i - 1 >= 0) { + // Issue #489, shift key modifier + let newSelected = selected; + + if (shiftKeyPressed) newSelected = [tracks[i - 1]._id, ...selected]; + else newSelected = [tracks[i - 1]._id]; + + setSelected(newSelected); + const nodeOffsetTop = (i - 1) * ROW_HEIGHT; + if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; + } + }, + [renderView, selected] + ); + + const onDown = useCallback( + (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { + if (i + 1 < tracks.length) { + // Issue #489, shift key modifier + let newSelected = selected; + if (shiftKeyPressed) newSelected.push(tracks[i + 1]._id); + else newSelected = [tracks[i + 1]._id]; + + setSelected(newSelected); + const nodeOffsetTop = (i + 1) * ROW_HEIGHT; + + if (renderView && renderView.scrollTop + renderView.offsetHeight <= nodeOffsetTop + ROW_HEIGHT) { + renderView.scrollTop = nodeOffsetTop - renderView.offsetHeight + ROW_HEIGHT; + } + } + }, + [renderView, selected] + ); + + const onKey = useCallback( + async (e: KeyboardEvent) => { + let firstSelectedTrackId = tracks.findIndex((track) => selected.includes(track._id)); + + switch (e.code) { + case "KeyA": + if (isCtrlKey(e)) { + onControlAll(firstSelectedTrackId, tracks); + e.preventDefault(); + } + break; + + case "ArrowUp": + e.preventDefault(); + onUp(firstSelectedTrackId, tracks, e.shiftKey); + break; + + case "ArrowDown": + // This effectively becomes lastSelectedTrackID + firstSelectedTrackId = tracks.findIndex((track) => selected[selected.length - 1] === track._id); + e.preventDefault(); + onDown(firstSelectedTrackId, tracks, e.shiftKey); + break; + + case "Enter": + e.preventDefault(); + await onEnter(firstSelectedTrackId, tracks); + break; + + default: + break; + } + }, + [onControlAll, onDown, onUp, onEnter, selected, tracks] + ); + + /** + * Playlists re-order events handlers + */ + const onReorderStart = useCallback(() => setReordered(selected), [selected]); + const onReorderEnd = useCallback(() => setReordered(null), []); + + const onDrop = useCallback( + async (targetTrackId: string, position: "above" | "below") => { + if (onReorder && currentPlaylist && reordered) { + onReorder(currentPlaylist, reordered, targetTrackId, position); + } + }, + [currentPlaylist, onReorder, reordered] + ); + + /** + * Tracks selection + */ + const isSelectableTrack = useCallback((id: string) => !selected.includes(id), [selected]); + + const sortSelected = useCallback( + (a: string, b: string): number => { + const allTracksIds = tracks.map((track) => track._id); + + return allTracksIds.indexOf(a) - allTracksIds.indexOf(b); + }, + [tracks] + ); + + const toggleSelectionById = useCallback( + (id: string) => { + let newSelected = [...selected]; + + if (newSelected.includes(id)) { + // remove track + newSelected.splice(newSelected.indexOf(id), 1); + } else { + // add track + newSelected.push(id); + } + + newSelected = newSelected.sort(sortSelected); + setSelected(newSelected); + }, + [selected, sortSelected] + ); + + const multiSelect = useCallback( + (index: number) => { + const selectedInt = []; + + // Prefer destructuring + for (let i = 0; i < tracks.length; i++) { + if (selected.includes(tracks[i]._id)) { + selectedInt.push(i); + } + } + + let base; + const min = Math.min(...selectedInt); + const max = Math.max(...selectedInt); + + if (index < min) { + base = max; + } else { + base = min; + } + + const newSelected = []; + + if (index < min) { + for (let i = 0; i <= Math.abs(index - base); i++) { + newSelected.push(tracks[base - i]._id); + } + } else if (index > max) { + for (let i = 0; i <= Math.abs(index - base); i++) { + newSelected.push(tracks[base + i]._id); + } + } + + setSelected(newSelected.sort(sortSelected)); + }, + [selected, sortSelected, tracks] + ); + + const selectTrack = useCallback( + (event: React.MouseEvent, trackId: string, index: number) => { + // To allow selection drag-and-drop, we need to prevent track selection + // when selection a track that is already selected + if (selected.includes(trackId) && !event.metaKey && !event.ctrlKey && !event.shiftKey) { + return; + } + + if (isLeftClick(event) || (isRightClick(event) && isSelectableTrack(trackId))) { + if (isCtrlKey(event)) { + toggleSelectionById(trackId); + } else if (event.shiftKey) { + if (selected.length === 0) { + const newSelected = [trackId]; + setSelected(newSelected); + } else { + multiSelect(index); + } + } else { + if (!isAltKey(event)) { + const newSelected = [trackId]; + setSelected(newSelected); + } + } + } + }, + [selected, multiSelect, toggleSelectionById, isSelectableTrack] + ); + + const selectTrackClick = useCallback( + (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => { + if (!event.metaKey && !event.ctrlKey && !event.shiftKey && selected.includes(trackId)) { + setSelected([trackId]); + } + }, + [selected] + ); + + /** + * Context menus + */ + const showContextMenu = useCallback( + (_e: React.MouseEvent, index: number) => { + const selectedCount = selected.length; + const track = tracks[index]; + let shownPlaylists = playlists; + + // Hide current playlist if needed + if (type === "playlist") { + shownPlaylists = playlists.filter((elem) => elem._id !== currentPlaylist); + } + + const playlistTemplate: electron.MenuItemConstructorOptions[] = []; + let addToQueueTemplate: electron.MenuItemConstructorOptions[] = []; + + if (shownPlaylists) { + playlistTemplate.push( + { + label: "Create new playlist...", + click: async () => { + await PlaylistsActions.create("New playlist", selected); + }, + }, + { + type: "separator", + } + ); + + if (shownPlaylists.length === 0) { + playlistTemplate.push({ + label: "No playlists", + enabled: false, + }); + } else { + shownPlaylists.forEach((playlist) => { + playlistTemplate.push({ + label: playlist.name, + click: async () => { + await PlaylistsActions.addTracks(playlist._id, selected); + }, + }); + }); + } + } + + if (playerStatus !== PlayerStatus.STOP) { + addToQueueTemplate = [ + { + label: "Add to queue", + click: async () => { + await QueueActions.addAfter(selected); + }, + }, + { + label: "Play next", + click: async () => { + await QueueActions.addNext(selected); + }, + }, + { + type: "separator", + }, + ]; + } + + const template: electron.MenuItemConstructorOptions[] = [ + { + label: selectedCount > 1 ? `${selectedCount} tracks selected` : `${selectedCount} track selected`, + enabled: false, + }, + { + type: "separator", + }, + ...addToQueueTemplate, + { + label: "Add to playlist", + submenu: playlistTemplate, + }, + { + type: "separator", + }, + ]; + + for (const artist of track.artist) { + template.push({ + label: `Search for "${artist}" `, + click: () => { + // HACK + const searchInput: HTMLInputElement | null = document.querySelector( + `input[type="text"].${headerStyles.header__search__input}` + ); + + if (searchInput) { + searchInput.value = track.artist[0]; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + }, + }); + } + + template.push({ + label: `Search for "${track.album}"`, + click: () => { + // HACK + const searchInput: HTMLInputElement | null = document.querySelector( + `input[type="text"].${headerStyles.header__search__input}` + ); + + if (searchInput) { + searchInput.value = track.album; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + } + }, + }); + + if (type === "playlist" && currentPlaylist) { + template.push( + { + type: "separator", + }, + { + label: "Remove from playlist", + click: async () => { + await PlaylistsActions.removeTracks(currentPlaylist, selected); + }, + } + ); + } + + template.push( + { + type: "separator", + }, + { + label: "Edit track", + click: () => { + navigate(`/details/${track._id}`); + }, + }, + { + type: "separator", + }, + { + label: "Show in file manager", + click: () => { + shell.showItemInFolder(track.path); + }, + }, + { + label: "Remove from library", + click: () => { + LibraryActions.remove(selected); + }, + } + ); + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + }, + [currentPlaylist, playerStatus, playlists, selected, tracks, type, navigate] + ); + + /** + * Tracks list virtualization + rendering + */ + + const onScroll = useCallback(() => { + if (renderView) { + const nextTilesScrolled = Math.floor(renderView.scrollTop / TILE_HEIGHT); + + if (tilesScrolled !== nextTilesScrolled) { + setTilesScrolled(nextTilesScrolled); + } + } + }, [tilesScrolled, renderView]); + + const trackTiles = useMemo(() => { + const tracksChunked = chunk(tracks, CHUNK_LENGTH); + + return tracksChunked.splice(tilesScrolled, TILES_TO_DISPLAY).map((tracksChunk, indexChunk) => { + const list = tracksChunk.map((track, index) => { + const trackRowIndex = (tilesScrolled + indexChunk) * CHUNK_LENGTH + index; + + return ( + + ); + }); + + const translationDistance = + tilesScrolled * ROW_HEIGHT * CHUNK_LENGTH + indexChunk * ROW_HEIGHT * CHUNK_LENGTH; + const tracksListTileStyles = { + transform: `translate3d(0, ${translationDistance}px, 0)`, + }; + + return ( +
+ {list} +
+ ); + }); + }, [ + reordered, + selected, + tilesScrolled, + reorderable, + trackPlayingId, + tracks, + onDrop, + onReorderStart, + onReorderEnd, + selectTrack, + selectTrackClick, + showContextMenu, + startPlayback, + ]); + + return ( +
+ + + +
+ {trackTiles} +
+
+
+ ); }; export default TracksList; diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.module.css b/src/renderer/components/TracksListHeader/TracksListHeader.module.css index 89d439683..4f01177e8 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.module.css +++ b/src/renderer/components/TracksListHeader/TracksListHeader.module.css @@ -1,26 +1,31 @@ .tracksListHeader { - border-bottom: 1px solid var(--border-color); - color: var(--tracks-header-color); - background-color: var(--tracks-header-bg); - display: flex; - width: 100%; + border-bottom: 1px solid var(--border-color); + color: var(--tracks-header-color); + background-color: var(--tracks-header-bg); + display: flex; + width: 100%; } .cellTrackPlaying { - width: 30px; - text-align: center; + width: 20px; + text-align: center; } .cellTrack { - flex: 1; + flex: 1; } .cellDuration { - width: 7%; + width: 7%; } -.cellArtist, .cellAlbum, +.cellAdded, +.cellSection, .cellGenre { - width: 20%; + width: 20%; +} + +.cellSection { + height: 100%; } diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index e47812fe9..31879655a 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -1,87 +1,109 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback } from "react"; +import { connect } from "react-redux"; -import TracksListHeaderCell from '../TracksListHeaderCell/TracksListHeaderCell'; +import TracksListHeaderCell from "../TracksListHeaderCell/TracksListHeaderCell"; -import { SortBy, SortOrder } from '../../../shared/types/museeks'; -import { RootState } from '../../store/reducers'; -import { LibrarySort } from '../../store/reducers/library'; +import { PlayerStatus, SortBy, SortOrder } from "../../../shared/types/museeks"; +import { RootState } from "../../store/reducers"; +import { LibrarySort } from "../../store/reducers/library"; -import styles from './TracksListHeader.module.css'; +import { Menu } from "@electron/remote"; +import styles from "./TracksListHeader.module.css"; +import electron from "electron"; +import { type } from "os"; +import playlists from "src/renderer/store/reducers/playlists"; interface OwnProps { - enableSort: boolean; + enableSort: boolean; } interface InjectedProps { - sort?: LibrarySort; + sort?: LibrarySort; } type Props = OwnProps & InjectedProps; class TracksListHeader extends React.Component { - static getIcon = (sort: LibrarySort | undefined, sortType: SortBy) => { - if (sort && sort.by === sortType) { - if (sort.order === SortOrder.ASC) { - return 'angle-up'; - } - - // Must be DSC then - return 'angle-down'; - } - - return null; - }; - - render() { - const { enableSort, sort } = this.props; - - return ( -
- - - - - - -
- ); - } + static getIcon = (sort: LibrarySort | undefined, sortType: SortBy) => { + if (sort && sort.by === sortType) { + if (sort.order === SortOrder.ASC) { + return "angle-up"; + } + + // Must be DSC then + return "angle-down"; + } + + return null; + }; + + showContextMenu(_e: React.MouseEvent, selected: string) { + const template: electron.MenuItemConstructorOptions[] = [ + { + label: selected, + }, + ]; + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + } + + render() { + const { enableSort, sort } = this.props; + + return ( +
+ {" "} + + this.showContextMenu(e, "title")} + className={styles.cellSection} + title="Title" + sortBy={enableSort ? SortBy.TITLE : null} + icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} + /> + this.showContextMenu(e, "duration")} + className={styles.cellDuration} + title="Duration" + sortBy={enableSort ? SortBy.DURATION : null} + icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} + /> + this.showContextMenu(e, "album")} + className={styles.cellAlbum} + title="Album" + sortBy={enableSort ? SortBy.ALBUM : null} + icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} + /> + this.showContextMenu(e, "added")} + className={styles.cellAdded} + title="Date Added" + sortBy={enableSort ? SortBy.ADDED : null} + icon={TracksListHeader.getIcon(sort, SortBy.ADDED)} + /> + this.showContextMenu(e, "genre")} + className={styles.cellGenre} + title="Genre" + sortBy={enableSort ? SortBy.GENRE : null} + icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} + /> +
+ ); + } } const mapStateToProps = (state: RootState, ownProps: OwnProps): InjectedProps => { - if (ownProps.enableSort) { - return { - sort: state.library.sort, - }; - } + if (ownProps.enableSort) { + return { + sort: state.library.sort, + }; + } - return {}; + return {}; }; export default connect(mapStateToProps)(TracksListHeader); diff --git a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx index e418232a2..c601dc795 100644 --- a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx +++ b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx @@ -1,65 +1,70 @@ -import React from 'react'; -import cx from 'classnames'; -import Icon from 'react-fontawesome'; +import React from "react"; +import cx from "classnames"; +import Icon from "react-fontawesome"; -import * as LibraryActions from '../../store/actions/LibraryActions'; -import { SortBy } from '../../../shared/types/museeks'; +import * as LibraryActions from "../../store/actions/LibraryActions"; +import { SortBy } from "../../../shared/types/museeks"; -import styles from './TracksListHeaderCell.module.css'; +import styles from "./TracksListHeaderCell.module.css"; interface Props { - title: string; - className?: string; - sortBy?: SortBy | null; - icon?: string | null; + title: string; + className?: string; + sortBy?: SortBy | null; + icon?: string | null; + onContextMenu?: (event: React.MouseEvent) => void; } class TracksListHeaderCell extends React.Component { - static defaultProps = { - className: '', - sortBy: null, - icon: null, - }; + static defaultProps = { + className: "", + sortBy: null, + icon: null, + }; - constructor(props: Props) { - super(props); - this.sort = this.sort.bind(this); - } + constructor(props: Props) { + super(props); + this.sort = this.sort.bind(this); + } - sort() { - if (this.props.sortBy) { - LibraryActions.sort(this.props.sortBy); - } - } + sort() { + if (this.props.sortBy) { + LibraryActions.sort(this.props.sortBy); + } + } - render() { - const { sortBy, className, title, icon } = this.props; + render() { + const { sortBy, className, title, icon } = this.props; - const classes = cx(styles.trackCellHeader, className, { - [styles.sort]: sortBy, - }); + const classes = cx(styles.trackCellHeader, className, { + [styles.sort]: sortBy, + }); - const content = ( - -
{title}
- {icon && ( -
- -
- )} -
- ); + const content = ( + +
{title}
+ {icon && ( +
+ +
+ )} +
+ ); - if (sortBy) { - return ( - - ); - } + if (sortBy) { + return ( + + ); + } - return
{content}
; - } + return ( +
+ {content} +
+ ); + } } export default TracksListHeaderCell; diff --git a/src/renderer/constants/sort-orders.ts b/src/renderer/constants/sort-orders.ts index a16c0c72e..8e2eb4d2a 100644 --- a/src/renderer/constants/sort-orders.ts +++ b/src/renderer/constants/sort-orders.ts @@ -1,4 +1,4 @@ -import { Track, SortOrder, SortBy } from '../../shared/types/museeks'; +import { Track, SortOrder, SortBy } from "../../shared/types/museeks"; // For perforances reasons, otherwise _.orderBy will perform weird check // the is far more resource/time impactful @@ -7,33 +7,32 @@ const parseGenre = (t: Track): string => t.loweredMetas.genre.toString(); // Declarations const sortOrders = { - [SortBy.ARTIST]: { - [SortOrder.ASC]: [ - // Default - [parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], - null, - ], - [SortOrder.DSC]: [[parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], - }, - [SortBy.TITLE]: { - [SortOrder.ASC]: [['loweredMetas.title', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], - [SortOrder.DSC]: [ - ['loweredMetas.title', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], - ['desc'], - ], - }, - [SortBy.DURATION]: { - [SortOrder.ASC]: [['duration', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], - [SortOrder.DSC]: [['duration', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], - }, - [SortBy.ALBUM]: { - [SortOrder.ASC]: [['loweredMetas.album', parseArtist, 'year', 'disk.no', 'track.no'], null], - [SortOrder.DSC]: [['loweredMetas.album', parseArtist, 'year', 'disk.no', 'track.no'], ['desc']], - }, - [SortBy.GENRE]: { - [SortOrder.ASC]: [[parseGenre, parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], - [SortOrder.DSC]: [[parseGenre, parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], - }, + [SortBy.ADDED]: { + [SortOrder.ASC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], + [SortOrder.DSC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], + }, + [SortBy.TITLE]: { + [SortOrder.ASC]: [ + ["loweredMetas.title", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], + null, + ], + [SortOrder.DSC]: [ + ["loweredMetas.title", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], + ["desc"], + ], + }, + [SortBy.DURATION]: { + [SortOrder.ASC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], + [SortOrder.DSC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], + }, + [SortBy.ALBUM]: { + [SortOrder.ASC]: [["loweredMetas.album", parseArtist, "year", "disk.no", "track.no"], null], + [SortOrder.DSC]: [["loweredMetas.album", parseArtist, "year", "disk.no", "track.no"], ["desc"]], + }, + [SortBy.GENRE]: { + [SortOrder.ASC]: [[parseGenre, parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], + [SortOrder.DSC]: [[parseGenre, parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], + }, }; export default sortOrders; diff --git a/src/renderer/lib/utils.ts b/src/renderer/lib/utils.ts index 67364ea59..515be4a82 100644 --- a/src/renderer/lib/utils.ts +++ b/src/renderer/lib/utils.ts @@ -1,214 +1,215 @@ -import path from 'path'; -import * as mmd from 'music-metadata'; -import pickBy from 'lodash-es/pickBy'; +import path from "path"; +import * as mmd from "music-metadata"; +import pickBy from "lodash-es/pickBy"; -import { Track, TrackEditableFields } from '../../shared/types/museeks'; +import { Track, TrackEditableFields } from "../../shared/types/museeks"; /** * Parse an int to a more readable string */ export const parseDuration = (duration: number | null): string => { - if (duration !== null) { - const hours = Math.trunc(duration / 3600); - const minutes = Math.trunc(duration / 60) % 60; - const seconds = Math.trunc(duration) % 60; + if (duration !== null) { + const hours = Math.trunc(duration / 3600); + const minutes = Math.trunc(duration / 60) % 60; + const seconds = Math.trunc(duration) % 60; - const hoursStringified = hours < 10 ? `0${hours}` : hours; - const minutesStringified = minutes < 10 ? `0${minutes}` : minutes; - const secondsStringified = seconds < 10 ? `0${seconds}` : seconds; + const hoursStringified = hours < 10 ? `0${hours}` : hours; + const minutesStringified = minutes < 10 ? `0${minutes}` : minutes; + const secondsStringified = seconds < 10 ? `0${seconds}` : seconds; - let result = hoursStringified > 0 ? `${hoursStringified}:` : ''; - result += `${minutesStringified}:${secondsStringified}`; + let result = hoursStringified > 0 ? `${hoursStringified}:` : ""; + result += `${minutesStringified}:${secondsStringified}`; - return result; - } + return result; + } - return '00:00'; + return "00:00"; }; /** * Parse an URI, encoding some characters */ export const parseUri = (uri: string): string => { - const root = process.platform === 'win32' ? '' : path.parse(uri).root; + const root = process.platform === "win32" ? "" : path.parse(uri).root; - const location = path - .resolve(uri) - .split(path.sep) - .map((d, i) => (i === 0 ? d : encodeURIComponent(d))) - .reduce((a, b) => path.join(a, b)); + const location = path + .resolve(uri) + .split(path.sep) + .map((d, i) => (i === 0 ? d : encodeURIComponent(d))) + .reduce((a, b) => path.join(a, b)); - return `file://${root}${location}`; + return `file://${root}${location}`; }; /** * Sort an array of string by ASC or DESC, then remove all duplicates */ -export const simpleSort = (array: string[], sorting: 'asc' | 'desc'): string[] => { - if (sorting === 'asc') { - array.sort((a, b) => (a > b ? 1 : -1)); - } else if (sorting === 'desc') { - array.sort((a, b) => (b > a ? -1 : 1)); - } - - const result: string[] = []; - array.forEach((item) => { - if (!result.includes(item)) result.push(item); - }); - - return result; +export const simpleSort = (array: string[], sorting: "asc" | "desc"): string[] => { + if (sorting === "asc") { + array.sort((a, b) => (a > b ? 1 : -1)); + } else if (sorting === "desc") { + array.sort((a, b) => (b > a ? -1 : 1)); + } + + const result: string[] = []; + array.forEach((item) => { + if (!result.includes(item)) result.push(item); + }); + + return result; }; /** * Strip accent from String. From https://jsperf.com/strip-accents */ export const stripAccents = (str: string): string => { - const accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž'; - const fixes = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz'; - const split = accents.split('').join('|'); - const reg = new RegExp(`(${split})`, 'g'); + const accents = "ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž"; + const fixes = "AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz"; + const split = accents.split("").join("|"); + const reg = new RegExp(`(${split})`, "g"); - function replacement(a: string) { - return fixes[accents.indexOf(a)] || ''; - } + function replacement(a: string) { + return fixes[accents.indexOf(a)] || ""; + } - return str.replace(reg, replacement).toLowerCase(); + return str.replace(reg, replacement).toLowerCase(); }; /** * Remove duplicates (realpath) and useless children folders */ export const removeUselessFolders = (folders: string[]): string[] => { - // Remove duplicates - let filteredFolders = folders.filter((elem, index) => folders.indexOf(elem) === index); + // Remove duplicates + let filteredFolders = folders.filter((elem, index) => folders.indexOf(elem) === index); - const foldersToBeRemoved: string[] = []; + const foldersToBeRemoved: string[] = []; - filteredFolders.forEach((folder, i) => { - filteredFolders.forEach((subfolder, j) => { - if (subfolder.includes(folder) && i !== j && !foldersToBeRemoved.includes(folder)) { - foldersToBeRemoved.push(subfolder); - } - }); - }); + filteredFolders.forEach((folder, i) => { + filteredFolders.forEach((subfolder, j) => { + if (subfolder.includes(folder) && i !== j && !foldersToBeRemoved.includes(folder)) { + foldersToBeRemoved.push(subfolder); + } + }); + }); - filteredFolders = filteredFolders.filter((elem) => !foldersToBeRemoved.includes(elem)); + filteredFolders = filteredFolders.filter((elem) => !foldersToBeRemoved.includes(elem)); - return filteredFolders; + return filteredFolders; }; // TODO export const getDefaultMetadata = (): Track => ({ - album: 'Unknown', - artist: ['Unknown artist'], - disk: { - no: 0, - of: 0, - }, - duration: 0, - genre: [], - loweredMetas: { - artist: ['unknown artist'], - album: 'unknown', - title: '', - genre: [], - }, - path: '', - playCount: 0, - title: '', - track: { - no: 0, - of: 0, - }, - year: null, + album: "Unknown", + artist: ["Unknown artist"], + disk: { + no: 0, + of: 0, + }, + duration: 0, + genre: [], + loweredMetas: { + artist: ["unknown artist"], + album: "unknown", + title: "", + genre: [], + }, + added: 0, + path: "", + playCount: 0, + title: "", + track: { + no: 0, + of: 0, + }, + year: null, }); export const parseMusicMetadata = (data: mmd.IAudioMetadata, trackPath: string): Partial => { - const { common, format } = data; - - const metadata = { - album: common.album, - artist: common.artists || (common.artist && [common.artist]) || (common.albumartist && [common.albumartist]), - disk: common.disk, - duration: format.duration, - genre: common.genre, - title: common.title || path.parse(trackPath).base, - track: common.track, - year: common.year, - }; - - return pickBy(metadata); + const { common, format } = data; + + const metadata = { + album: common.album, + artist: common.artists || (common.artist && [common.artist]) || (common.albumartist && [common.albumartist]), + disk: common.disk, + duration: format.duration, + genre: common.genre, + title: common.title || path.parse(trackPath).base, + track: common.track, + year: common.year, + }; + + return pickBy(metadata); }; -export const getLoweredMeta = (metadata: TrackEditableFields): Track['loweredMetas'] => ({ - artist: metadata.artist.map((meta) => stripAccents(meta.toLowerCase())), - album: stripAccents(metadata.album.toLowerCase()), - title: stripAccents(metadata.title.toLowerCase()), - genre: metadata.genre.map((meta) => stripAccents(meta.toLowerCase())), +export const getLoweredMeta = (metadata: TrackEditableFields): Track["loweredMetas"] => ({ + artist: metadata.artist.map((meta) => stripAccents(meta.toLowerCase())), + album: stripAccents(metadata.album.toLowerCase()), + title: stripAccents(metadata.title.toLowerCase()), + genre: metadata.genre.map((meta) => stripAccents(meta.toLowerCase())), }); export const getAudioDuration = (trackPath: string): Promise => { - const audio = new Audio(); - - return new Promise((resolve, reject) => { - audio.addEventListener('loadedmetadata', () => { - resolve(audio.duration); - }); - - audio.addEventListener('error', (e) => { - // eslint-disable-next-line - // @ts-ignore error event typing is wrong - const message = `Error getting audio duration: (${e.currentTarget.error.code}) ${trackPath}`; - reject(new Error(message)); - }); - - audio.preload = 'metadata'; - // HACK no idea what other caracters could fuck things up - audio.src = encodeURI(trackPath).replace('#', '%23'); - }); + const audio = new Audio(); + + return new Promise((resolve, reject) => { + audio.addEventListener("loadedmetadata", () => { + resolve(audio.duration); + }); + + audio.addEventListener("error", (e) => { + // eslint-disable-next-line + // @ts-ignore error event typing is wrong + const message = `Error getting audio duration: (${e.currentTarget.error.code}) ${trackPath}`; + reject(new Error(message)); + }); + + audio.preload = "metadata"; + // HACK no idea what other caracters could fuck things up + audio.src = encodeURI(trackPath).replace("#", "%23"); + }); }; /** * Get a file metadata */ export const getMetadata = async (trackPath: string): Promise => { - const defaultMetadata = getDefaultMetadata(); - - const basicMetadata: Track = { - ...defaultMetadata, - path: trackPath, - }; - - try { - const data = await mmd.parseFile(trackPath, { - skipCovers: true, - duration: true, - }); - - // Let's try to define something with what we got so far... - const parsedData = parseMusicMetadata(data, trackPath); - - const metadata: Track = { - ...defaultMetadata, - ...parsedData, - path: trackPath, - }; - - metadata.loweredMetas = getLoweredMeta(metadata); - - // Let's try another wat to retrieve a track duration - if (metadata.duration < 0.5) { - try { - metadata.duration = await getAudioDuration(trackPath); - } catch (err) { - console.warn(`An error occured while getting ${trackPath} duration: ${err}`); - } - } - - return metadata; - } catch (err) { - console.warn(`An error occured while reading ${trackPath} id3 tags: ${err}`); - } - - return basicMetadata; + const defaultMetadata = getDefaultMetadata(); + + const basicMetadata: Track = { + ...defaultMetadata, + path: trackPath, + }; + + try { + const data = await mmd.parseFile(trackPath, { + skipCovers: true, + duration: true, + }); + + // Let's try to define something with what we got so far... + const parsedData = parseMusicMetadata(data, trackPath); + + const metadata: Track = { + ...defaultMetadata, + ...parsedData, + path: trackPath, + }; + + metadata.loweredMetas = getLoweredMeta(metadata); + + // Let's try another wat to retrieve a track duration + if (metadata.duration < 0.5) { + try { + metadata.duration = await getAudioDuration(trackPath); + } catch (err) { + console.warn(`An error occured while getting ${trackPath} duration: ${err}`); + } + } + + return metadata; + } catch (err) { + console.warn(`An error occured while reading ${trackPath} id3 tags: ${err}`); + } + + return basicMetadata; }; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 9a057be7b..8e8d460c1 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -4,15 +4,15 @@ |-------------------------------------------------------------------------- */ -import React from 'react'; -import * as ReactDOM from 'react-dom/client'; -import { Provider } from 'react-redux'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; +import React from "react"; +import * as ReactDOM from "react-dom/client"; +import { Provider } from "react-redux"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; -import Root from './Root'; -import Router from './Router'; -import store from './store/store'; +import Root from "./Root"; +import Router from "./Router"; +import store from "./store/store"; /* |-------------------------------------------------------------------------- @@ -20,10 +20,10 @@ import store from './store/store'; |-------------------------------------------------------------------------- */ -import '../../node_modules/normalize.css/normalize.css'; -import '../../node_modules/font-awesome/css/font-awesome.css'; -import '../../node_modules/react-rangeslider/lib/index.css'; -import './styles/main.module.css'; +import "../../node_modules/normalize.css/normalize.css"; +import "../../node_modules/font-awesome/css/font-awesome.css"; +import "../../node_modules/react-rangeslider/lib/index.css"; +import "./styles/main.module.css"; /* |-------------------------------------------------------------------------- @@ -31,19 +31,19 @@ import './styles/main.module.css'; |-------------------------------------------------------------------------- */ -const wrap = document.getElementById('wrap'); +const wrap = document.getElementById("wrap"); if (wrap) { - const root = ReactDOM.createRoot(wrap); - root.render( - - - - - - - - - - ); + const root = ReactDOM.createRoot(wrap); + root.render( + + + + + + + + + + ); } diff --git a/src/renderer/store/actions/LibraryActions.ts b/src/renderer/store/actions/LibraryActions.ts index bea7f1f73..785d3d367 100644 --- a/src/renderer/store/actions/LibraryActions.ts +++ b/src/renderer/store/actions/LibraryActions.ts @@ -1,336 +1,337 @@ -import * as fs from 'fs'; -import path from 'path'; -import * as util from 'util'; -import electron, { ipcRenderer } from 'electron'; -import globby from 'globby'; -import queue from 'queue'; - -import store from '../store'; -import types from '../action-types'; - -import * as app from '../../lib/app'; -import * as utils from '../../lib/utils'; -import * as m3u from '../../lib/utils-m3u'; -import { TrackEditableFields, SortBy, TrackModel } from '../../../shared/types/museeks'; -import { SUPPORTED_PLAYLISTS_EXTENSIONS, SUPPORTED_TRACKS_EXTENSIONS } from '../../../shared/constants'; -import channels from '../../../shared/lib/ipc-channels'; - -import * as PlaylistsActions from './PlaylistsActions'; -import * as ToastsActions from './ToastsActions'; +import * as fs from "fs"; +import path from "path"; +import * as util from "util"; +import electron, { ipcRenderer } from "electron"; +import globby from "globby"; +import queue from "queue"; + +import store from "../store"; +import types from "../action-types"; + +import * as app from "../../lib/app"; +import * as utils from "../../lib/utils"; +import * as m3u from "../../lib/utils-m3u"; +import { TrackEditableFields, SortBy, TrackModel } from "../../../shared/types/museeks"; +import { SUPPORTED_PLAYLISTS_EXTENSIONS, SUPPORTED_TRACKS_EXTENSIONS } from "../../../shared/constants"; +import channels from "../../../shared/lib/ipc-channels"; + +import * as PlaylistsActions from "./PlaylistsActions"; +import * as ToastsActions from "./ToastsActions"; const stat = util.promisify(fs.stat); interface ScanFile { - path: string; - stat: fs.Stats; + path: string; + stat: fs.Stats; } /** * Load tracks from database */ export const refresh = async (): Promise => { - try { - const tracks = await app.db.Track.find().execAsync(); - - store.dispatch({ - type: types.LIBRARY_REFRESH, - payload: { - tracks, - }, - }); - } catch (err) { - console.warn(err); - } + try { + const tracks = await app.db.Track.find().execAsync(); + + store.dispatch({ + type: types.LIBRARY_REFRESH, + payload: { + tracks, + }, + }); + } catch (err) { + console.warn(err); + } }; /** * Filter tracks by search */ export const search = (value: string): void => { - store.dispatch({ - type: types.FILTER_SEARCH, - payload: { - search: value, - }, - }); + store.dispatch({ + type: types.FILTER_SEARCH, + payload: { + search: value, + }, + }); }; /** * Filter tracks by sort query */ export const sort = (sortBy: SortBy): void => { - store.dispatch({ - type: types.LIBRARY_SORT, - payload: { - sortBy, - }, - }); + store.dispatch({ + type: types.LIBRARY_SORT, + payload: { + sortBy, + }, + }); }; const scanPlaylists = async (paths: string[]) => { - return Promise.all( - paths.map(async (filePath) => { - try { - const playlistFiles = m3u.parse(filePath); - const playlistName = path.parse(filePath).name; - - const existingTracks: TrackModel[] = await app.db.Track.findAsync({ - $or: playlistFiles.map((filePath) => ({ path: filePath })), - }); - - await PlaylistsActions.create( - playlistName, - existingTracks.map((track) => track._id), - filePath - ); - } catch (err) { - console.warn(err); - } - }) - ); + return Promise.all( + paths.map(async (filePath) => { + try { + const playlistFiles = m3u.parse(filePath); + const playlistName = path.parse(filePath).name; + + const existingTracks: TrackModel[] = await app.db.Track.findAsync({ + $or: playlistFiles.map((filePath) => ({ path: filePath })), + }); + + await PlaylistsActions.create( + playlistName, + existingTracks.map((track) => track._id), + filePath + ); + } catch (err) { + console.warn(err); + } + }) + ); }; const scan = { - processed: 0, - total: 0, + processed: 0, + total: 0, }; const scanTracks = async (paths: string[]): Promise => { - return new Promise((resolve, reject) => { - if (paths.length === 0) resolve([]); - - try { - // Instantiate queue - let scannedFiles: TrackModel[] = []; - - // eslint-disable-next-line - // @ts-ignore Outdated types - // https://github.com/jessetane/queue/pull/15#issuecomment-414091539 - const scanQueue = queue(); - scanQueue.concurrency = 32; - scanQueue.autostart = true; - - scanQueue.on('end', async () => { - scan.processed = 0; - scan.total = 0; - - resolve(scannedFiles); - }); - - scanQueue.on('success', () => { - // Every 100 scans, update progress bar - if (scan.processed % 100 === 0) { - // Progress bar update - store.dispatch({ - type: types.LIBRARY_REFRESH_PROGRESS, - payload: { - processed: scan.processed, - total: scan.total, - }, - }); - - // Add tracks to the library view - const tracks = [...scannedFiles]; - scannedFiles = []; // Reset current selection - store.dispatch({ - type: types.LIBRARY_ADD_TRACKS, - payload: { - tracks, - }, - }); - } - }); - // End queue instantiation - - scan.total += paths.length; - - paths.forEach((filePath) => { - scanQueue.push(async (callback) => { - try { - // Normalize (back)slashes on Windows - filePath = path.resolve(filePath); - - // Check if there is an existing record in the DB - const existingDoc = await app.db.Track.findOneAsync({ path: filePath }); - - // If there is existing document - if (!existingDoc) { - // Get metadata - const track = await utils.getMetadata(filePath); - const insertedDoc: TrackModel = await app.db.Track.insertAsync(track); - scannedFiles.push(insertedDoc); - } - - scan.processed++; - } catch (err) { - console.warn(err); - } - - if (callback) callback(); - }); - }); - } catch (err) { - reject(err); - } - }); + return new Promise((resolve, reject) => { + if (paths.length === 0) resolve([]); + + try { + // Instantiate queue + let scannedFiles: TrackModel[] = []; + + // eslint-disable-next-line + // @ts-ignore Outdated types + // https://github.com/jessetane/queue/pull/15#issuecomment-414091539 + const scanQueue = queue(); + scanQueue.concurrency = 32; + scanQueue.autostart = true; + + scanQueue.on("end", async () => { + scan.processed = 0; + scan.total = 0; + + resolve(scannedFiles); + }); + + scanQueue.on("success", () => { + // Every 100 scans, update progress bar + if (scan.processed % 100 === 0) { + // Progress bar update + store.dispatch({ + type: types.LIBRARY_REFRESH_PROGRESS, + payload: { + processed: scan.processed, + total: scan.total, + }, + }); + + // Add tracks to the library view + const tracks = [...scannedFiles]; + scannedFiles = []; // Reset current selection + store.dispatch({ + type: types.LIBRARY_ADD_TRACKS, + payload: { + tracks, + }, + }); + } + }); + // End queue instantiation + + scan.total += paths.length; + + paths.forEach((filePath) => { + scanQueue.push(async (callback) => { + try { + // Normalize (back)slashes on Windows + filePath = path.resolve(filePath); + + // Check if there is an existing record in the DB + const existingDoc = await app.db.Track.findOneAsync({ path: filePath }); + + // If there is existing document + if (!existingDoc) { + // Get metadata + const track = await utils.getMetadata(filePath); + track.added = new Date().getTime(); + const insertedDoc: TrackModel = await app.db.Track.insertAsync(track); + scannedFiles.push(insertedDoc); + } + + scan.processed++; + } catch (err) { + console.warn(err); + } + + if (callback) callback(); + }); + }); + } catch (err) { + reject(err); + } + }); }; /** * Add tracks to Library */ export const add = async (pathsToScan: string[]): Promise => { - store.dispatch({ - type: types.LIBRARY_REFRESH_START, - }); - - try { - // 1. Get the stats for all the files/paths - const statsPromises: Promise[] = pathsToScan.map(async (folderPath) => ({ - path: folderPath, - stat: await stat(folderPath), - })); - - const paths = await Promise.all(statsPromises); - - // 2. Split directories and files - const files: string[] = []; - const folders: string[] = []; - - paths.forEach((elem) => { - if (elem.stat.isFile()) files.push(elem.path); - if (elem.stat.isDirectory() || elem.stat.isSymbolicLink()) folders.push(elem.path); - }); - - // 3. Scan all the directories with globby - const globbies = folders.map((folder) => { - // Normalize slashes and escape regex special characters - const pattern = `${folder.replace(/\\/g, '/').replace(/([$^*+?()\[\]])/g, '\\$1')}/**/*.*`; - - return globby(pattern, { followSymbolicLinks: true }); - }); - - const subDirectoriesFiles = await Promise.all(globbies); - // Scan folders and add files to library - - // 4. Merge all path arrays together and filter them with the extensions we support - const allFiles = subDirectoriesFiles.reduce((acc, array) => acc.concat(array), [] as string[]).concat(files); // Add the initial files - - const supportedTrackFiles = allFiles.filter((filePath) => { - const extension = path.extname(filePath).toLowerCase(); - return SUPPORTED_TRACKS_EXTENSIONS.includes(extension); - }); - - const supportedPlaylistsFiles = allFiles.filter((filePath) => { - const extension = path.extname(filePath).toLowerCase(); - return SUPPORTED_PLAYLISTS_EXTENSIONS.includes(extension); - }); - - if (supportedTrackFiles.length === 0 && supportedPlaylistsFiles.length === 0) { - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - - return []; - } - - // 5. Scan tracks then scan playlists - const importedTracks = await scanTracks(supportedTrackFiles); - await scanPlaylists(supportedPlaylistsFiles); - - await refresh(); - await PlaylistsActions.refresh(); - - return importedTracks; - } catch (err) { - ToastsActions.add('danger', 'An error occured when scanning the library'); - console.warn(err); - return []; - } finally { - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - } + store.dispatch({ + type: types.LIBRARY_REFRESH_START, + }); + + try { + // 1. Get the stats for all the files/paths + const statsPromises: Promise[] = pathsToScan.map(async (folderPath) => ({ + path: folderPath, + stat: await stat(folderPath), + })); + + const paths = await Promise.all(statsPromises); + + // 2. Split directories and files + const files: string[] = []; + const folders: string[] = []; + + paths.forEach((elem) => { + if (elem.stat.isFile()) files.push(elem.path); + if (elem.stat.isDirectory() || elem.stat.isSymbolicLink()) folders.push(elem.path); + }); + + // 3. Scan all the directories with globby + const globbies = folders.map((folder) => { + // Normalize slashes and escape regex special characters + const pattern = `${folder.replace(/\\/g, "/").replace(/([$^*+?()\[\]])/g, "\\$1")}/**/*.*`; + + return globby(pattern, { followSymbolicLinks: true }); + }); + + const subDirectoriesFiles = await Promise.all(globbies); + // Scan folders and add files to library + + // 4. Merge all path arrays together and filter them with the extensions we support + const allFiles = subDirectoriesFiles.reduce((acc, array) => acc.concat(array), [] as string[]).concat(files); // Add the initial files + + const supportedTrackFiles = allFiles.filter((filePath) => { + const extension = path.extname(filePath).toLowerCase(); + return SUPPORTED_TRACKS_EXTENSIONS.includes(extension); + }); + + const supportedPlaylistsFiles = allFiles.filter((filePath) => { + const extension = path.extname(filePath).toLowerCase(); + return SUPPORTED_PLAYLISTS_EXTENSIONS.includes(extension); + }); + + if (supportedTrackFiles.length === 0 && supportedPlaylistsFiles.length === 0) { + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + + return []; + } + + // 5. Scan tracks then scan playlists + const importedTracks = await scanTracks(supportedTrackFiles); + await scanPlaylists(supportedPlaylistsFiles); + + await refresh(); + await PlaylistsActions.refresh(); + + return importedTracks; + } catch (err) { + ToastsActions.add("danger", "An error occured when scanning the library"); + console.warn(err); + return []; + } finally { + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + } }; /** * remove tracks from library */ export const remove = async (tracksIds: string[]): Promise => { - // not calling await on it as it calls the synchonous message box - const options: Electron.MessageBoxOptions = { - buttons: ['Cancel', 'Remove'], - title: 'Remove tracks from library?', - message: `Are you sure you want to remove ${tracksIds.length} element(s) from your library?`, - type: 'warning', - }; - - const result: electron.MessageBoxReturnValue = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); - - if (result.response === 1) { - // button possition, here 'remove' - // Remove tracks from the Track collection - app.db.Track.removeAsync({ _id: { $in: tracksIds } }, { multi: true }); - - store.dispatch({ - type: types.LIBRARY_REMOVE_TRACKS, - payload: { - tracksIds, - }, - }); - // That would be great to remove those ids from all the playlists, but it's not easy - // and should not cause strange behaviors, all PR for that would be really appreciated - // TODO: see if it's possible to remove the Ids from the selected state of TracksList as it "could" lead to strange behaviors - } + // not calling await on it as it calls the synchonous message box + const options: Electron.MessageBoxOptions = { + buttons: ["Cancel", "Remove"], + title: "Remove tracks from library?", + message: `Are you sure you want to remove ${tracksIds.length} element(s) from your library?`, + type: "warning", + }; + + const result: electron.MessageBoxReturnValue = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); + + if (result.response === 1) { + // button possition, here 'remove' + // Remove tracks from the Track collection + app.db.Track.removeAsync({ _id: { $in: tracksIds } }, { multi: true }); + + store.dispatch({ + type: types.LIBRARY_REMOVE_TRACKS, + payload: { + tracksIds, + }, + }); + // That would be great to remove those ids from all the playlists, but it's not easy + // and should not cause strange behaviors, all PR for that would be really appreciated + // TODO: see if it's possible to remove the Ids from the selected state of TracksList as it "could" lead to strange behaviors + } }; /** * Reset the library */ export const reset = async (): Promise => { - try { - const options: Electron.MessageBoxOptions = { - buttons: ['Cancel', 'Reset'], - title: 'Reset library?', - message: 'Are you sure you want to reset your library? All your tracks and playlists will be cleared.', - type: 'warning', - }; - - const result = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); - - if (result.response === 1) { - store.dispatch({ - type: types.LIBRARY_REFRESH_START, - }); - - await app.db.Track.removeAsync({}, { multi: true }); - await app.db.Playlist.removeAsync({}, { multi: true }); - - store.dispatch({ - type: types.LIBRARY_RESET, - }); - - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - - await refresh(); - } - } catch (err) { - console.error(err); - } + try { + const options: Electron.MessageBoxOptions = { + buttons: ["Cancel", "Reset"], + title: "Reset library?", + message: "Are you sure you want to reset your library? All your tracks and playlists will be cleared.", + type: "warning", + }; + + const result = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); + + if (result.response === 1) { + store.dispatch({ + type: types.LIBRARY_REFRESH_START, + }); + + await app.db.Track.removeAsync({}, { multi: true }); + await app.db.Playlist.removeAsync({}, { multi: true }); + + store.dispatch({ + type: types.LIBRARY_RESET, + }); + + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + + await refresh(); + } + } catch (err) { + console.error(err); + } }; /** * Update the play count attribute. */ export const incrementPlayCount = async (source: string): Promise => { - const query = { src: source }; // HACK Not great, should be done with an _id - const update = { $inc: { playcount: 1 } }; - try { - await app.db.Track.updateAsync(query, update); - } catch (err) { - console.warn(err); - } + const query = { src: source }; // HACK Not great, should be done with an _id + const update = { $inc: { playcount: 1 } }; + try { + await app.db.Track.updateAsync(query, update); + } catch (err) { + console.warn(err); + } }; /** @@ -342,33 +343,33 @@ export const incrementPlayCount = async (source: string): Promise => { * @param newFields The fields to be updated and their new value */ export const updateTrackMetadata = async (trackId: string, newFields: TrackEditableFields): Promise => { - const query = { _id: trackId }; + const query = { _id: trackId }; - let track: TrackModel = await app.db.Track.findOneAsync(query); + let track: TrackModel = await app.db.Track.findOneAsync(query); - track = { - ...track, - ...newFields, - loweredMetas: utils.getLoweredMeta(newFields), - }; + track = { + ...track, + ...newFields, + loweredMetas: utils.getLoweredMeta(newFields), + }; - if (!track) { - throw new Error('No track found while trying to update track metadata'); - } + if (!track) { + throw new Error("No track found while trying to update track metadata"); + } - await app.db.Track.updateAsync(query, track); + await app.db.Track.updateAsync(query, track); - await refresh(); + await refresh(); }; /** * Set highlight trigger for a track */ export const highlightPlayingTrack = (highlight: boolean): void => { - store.dispatch({ - type: types.LIBRARY_HIGHLIGHT_PLAYING_TRACK, - payload: { - highlight, - }, - }); + store.dispatch({ + type: types.LIBRARY_HIGHLIGHT_PLAYING_TRACK, + payload: { + highlight, + }, + }); }; diff --git a/src/renderer/views/Library/Library.tsx b/src/renderer/views/Library/Library.tsx index 1b2d38756..968e7e8ce 100644 --- a/src/renderer/views/Library/Library.tsx +++ b/src/renderer/views/Library/Library.tsx @@ -1,91 +1,91 @@ -import React, { useMemo } from 'react'; -import { Link } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import React, { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { useSelector } from "react-redux"; -import * as ViewMessage from '../../elements/ViewMessage/ViewMessage'; -import TracksList from '../../components/TracksList/TracksList'; -import { filterTracks, sortTracks } from '../../lib/utils-library'; -import SORT_ORDERS from '../../constants/sort-orders'; -import { RootState } from '../../store/reducers'; +import * as ViewMessage from "../../elements/ViewMessage/ViewMessage"; +import TracksList from "../../components/TracksList/TracksList"; +import { filterTracks, sortTracks } from "../../lib/utils-library"; +import SORT_ORDERS from "../../constants/sort-orders"; +import { RootState } from "../../store/reducers"; -import appStyles from '../../App.module.css'; -import styles from './Library.module.css'; +import appStyles from "../../App.module.css"; +import styles from "./Library.module.css"; const Library: React.FC = () => { - const library = useSelector((state: RootState) => state.library); - const player = useSelector((state: RootState) => state.player); - const playlists = useSelector((state: RootState) => state.playlists.list); - const tracks = useSelector((state: RootState) => { - const { search, tracks, sort } = state.library; + const library = useSelector((state: RootState) => state.library); + const player = useSelector((state: RootState) => state.player); + const playlists = useSelector((state: RootState) => state.playlists.list); + const tracks = useSelector((state: RootState) => { + const { search, tracks, sort } = state.library; - // Filter and sort TracksList - // sorting being a costly operation, do it after filtering - const filteredTracks = sortTracks(filterTracks(tracks.library, search), SORT_ORDERS[sort.by][sort.order]); + // Filter and sort TracksList + // sorting being a costly operation, do it after filtering + const filteredTracks = sortTracks(filterTracks(tracks.library, search), SORT_ORDERS[sort.by][sort.order]); - return filteredTracks; - }); + return filteredTracks; + }); - const getLibraryComponent = useMemo(() => { - const { playerStatus } = player; + const getLibraryComponent = useMemo(() => { + const { playerStatus } = player; - const trackPlayingId = - player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null; + const trackPlayingId = + player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null; - // Loading library - if (library.loading) { - return ( - -

Loading library...

-
- ); - } + // Loading library + if (library.loading) { + return ( + +

Loading library...

+
+ ); + } - // Empty library - if (tracks.length === 0 && library.search === '') { - if (library.refreshing) { - return ( - -

Your library is being scanned =)

- hold on... -
- ); - } + // Empty library + if (tracks.length === 0 && library.search === "") { + if (library.refreshing) { + return ( + +

Your library is being scanned =)

+ hold on... +
+ ); + } - return ( - -

Too bad, there is no music in your library =(

- - you can always just drop files and folders anywhere or{' '} - - add your music here - - -
- ); - } + return ( + +

Too bad, there is no music in your library =(

+ + you can always just drop files and folders anywhere or{" "} + + add your music here + + +
+ ); + } - // Empty search - if (tracks.length === 0) { - return ( - -

Your search returned no results

-
- ); - } + // Empty search + if (tracks.length === 0) { + return ( + +

Your search returned no results

+
+ ); + } - // All good ! - return ( - - ); - }, [library, playlists, player, tracks]); + // All good ! + return ( + + ); + }, [library, playlists, player, tracks]); - return
{getLibraryComponent}
; + return
{getLibraryComponent}
; }; export default Library; diff --git a/src/shared/types/museeks.ts b/src/shared/types/museeks.ts index ecd0e1ba8..a7b6844e6 100644 --- a/src/shared/types/museeks.ts +++ b/src/shared/types/museeks.ts @@ -2,98 +2,99 @@ * Player related stuff */ export enum PlayerStatus { - PLAY = 'play', - PAUSE = 'pause', - STOP = 'stop', + PLAY = "play", + PAUSE = "pause", + STOP = "stop", } export enum Repeat { - ALL = 'all', - ONE = 'one', - NONE = 'none', + ALL = "all", + ONE = "one", + NONE = "none", } export enum SortBy { - ARTIST = 'artist', - ALBUM = 'album', - TITLE = 'title', - DURATION = 'duration', - GENRE = 'genre', + ALBUM = "album", + TITLE = "title", + DURATION = "duration", + GENRE = "genre", + ADDED = "added", } export enum SortOrder { - ASC = 'asc', - DSC = 'dsc', + ASC = "asc", + DSC = "dsc", } /** * Redux */ export interface Action { - // TODO action specific types - type: string; - payload?: any; + // TODO action specific types + type: string; + payload?: any; } /** * Untyped libs / helpers */ export type LinvoSchema = { - _id: string; - find: any; - findOne: any; - insert: any; - copy: any; // TODO better types? - remove: any; - save: any; - serialize: any; - update: any; - ensureIndex: any; - // bluebird-injected - findAsync: any; - findOneAsync: any; - insertAsync: any; - copyAsync: any; - removeAsync: any; - saveAsync: any; - serializeAsync: any; - updateAsync: any; + _id: string; + find: any; + findOne: any; + insert: any; + copy: any; // TODO better types? + remove: any; + save: any; + serialize: any; + update: any; + ensureIndex: any; + // bluebird-injected + findAsync: any; + findOneAsync: any; + insertAsync: any; + copyAsync: any; + removeAsync: any; + saveAsync: any; + serializeAsync: any; + updateAsync: any; } & { - [Property in keyof Schema]: Schema[Property]; + [Property in keyof Schema]: Schema[Property]; }; /** * App models */ export interface Track { - album: string; - artist: string[]; - disk: { - no: number; - of: number; - }; - duration: number; - genre: string[]; - loweredMetas: { - artist: string[]; - album: string; - title: string; - genre: string[]; - }; - path: string; - playCount: number; - title: string; - track: { - no: number; - of: number; - }; - year: number | null; + album: string; + artist: string[]; + disk: { + no: number; + of: number; + }; + duration: number; + genre: string[]; + added: number; + loweredMetas: { + artist: string[]; + album: string; + title: string; + genre: string[]; + }; + path: string; + playCount: number; + title: string; + track: { + no: number; + of: number; + }; + year: number | null; } export interface Playlist { - name: string; - tracks: string[]; - importPath?: string; // associated m3u file + name: string; + tracks: string[]; + importPath?: string; // associated m3u file } /** @@ -105,52 +106,52 @@ export type PlaylistModel = LinvoSchema; /** * Editable track fields (via right-click -> edit track) */ -export type TrackEditableFields = Pick; +export type TrackEditableFields = Pick; /** * Various */ export interface Toast { - _id: number; - content: string; - type: ToastType; + _id: number; + content: string; + type: ToastType; } -export type ToastType = 'success' | 'danger' | 'warning'; +export type ToastType = "success" | "danger" | "warning"; /** * Config */ export interface ConfigBounds { - width: number; - height: number; - x: number; - y: number; + width: number; + height: number; + x: number; + y: number; } // TODO: how to automate this? Maybe losen types to "string" -type ThemeIds = 'dark' | 'light' | 'dark-legacy'; +type ThemeIds = "dark" | "light" | "dark-legacy"; export interface Config { - theme: ThemeIds | '__system'; - audioVolume: number; - audioPlaybackRate: number; - audioOutputDevice: string; - audioMuted: boolean; - audioShuffle: boolean; - audioRepeat: Repeat; - defaultView: string; - librarySort: { - by: SortBy; - order: SortOrder; - }; - // musicFolders: string[], - sleepBlocker: boolean; - autoUpdateChecker: boolean; - minimizeToTray: boolean; - displayNotifications: boolean; - bounds: ConfigBounds; + theme: ThemeIds | "__system"; + audioVolume: number; + audioPlaybackRate: number; + audioOutputDevice: string; + audioMuted: boolean; + audioShuffle: boolean; + audioRepeat: Repeat; + defaultView: string; + librarySort: { + by: SortBy; + order: SortOrder; + }; + // musicFolders: string[], + sleepBlocker: boolean; + autoUpdateChecker: boolean; + minimizeToTray: boolean; + displayNotifications: boolean; + bounds: ConfigBounds; } /** @@ -158,8 +159,8 @@ export interface Config { */ export interface Theme { - _id: ThemeIds; - name: string; - themeSource: Electron.NativeTheme['themeSource']; - variables: Record; + _id: ThemeIds; + name: string; + themeSource: Electron.NativeTheme["themeSource"]; + variables: Record; } From 45624fd9ef1bcafeb65a2f69ef94aecd697f9c54 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 11:49:50 +0200 Subject: [PATCH 02/18] another initial commit --- .../components/TracksListHeader/TracksListHeader.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.module.css b/src/renderer/components/TracksListHeader/TracksListHeader.module.css index 4f01177e8..25f4457d5 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.module.css +++ b/src/renderer/components/TracksListHeader/TracksListHeader.module.css @@ -28,4 +28,5 @@ .cellSection { height: 100%; + width: 30%; } From a65622c89c9fdab734383b7d66c74630bd4bdd04 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 11:51:46 +0200 Subject: [PATCH 03/18] add readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 7926a53d9..c68b44388 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +# blackshibe/museeks + +This fork seeks to implement at least a section of issue #401 and slight interface changes. +![image](https://user-images.githubusercontent.com/57596776/174771584-8b38884e-ba57-4963-87cf-db8ede305211.png) + # Museeks ![Build Status](https://github.com/martpie/museeks/workflows/build/badge.svg) From 51a4c39031464f39721f88eb622ebeccfeebfc4b Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 12:46:32 +0200 Subject: [PATCH 04/18] add LibraryLayoutSettings --- README.md | 5 + .../components/TracksList/TracksList.tsx | 7 +- .../TracksListHeader/TracksListHeader.tsx | 39 +- .../TracksListHeaderCell.tsx | 2 +- src/renderer/store/action-types.ts | 84 ++-- src/renderer/store/actions/LibraryActions.ts | 15 + src/renderer/store/reducers/library.ts | 401 +++++++++--------- src/renderer/views/Library/Library.tsx | 1 + 8 files changed, 310 insertions(+), 244 deletions(-) diff --git a/README.md b/README.md index c68b44388..817b82286 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ This fork seeks to implement at least a section of issue #401 and slight interface changes. ![image](https://user-images.githubusercontent.com/57596776/174771584-8b38884e-ba57-4963-87cf-db8ede305211.png) +# Issues + +Everything tagged with FIXME +Casing (i'm not used to camelCase) + # Museeks ![Build Status](https://github.com/martpie/museeks/workflows/build/badge.svg) diff --git a/src/renderer/components/TracksList/TracksList.tsx b/src/renderer/components/TracksList/TracksList.tsx index 70f9c047c..45f939d2d 100644 --- a/src/renderer/components/TracksList/TracksList.tsx +++ b/src/renderer/components/TracksList/TracksList.tsx @@ -23,6 +23,7 @@ import { RootState } from "../../store/reducers"; import scrollbarStyles from "../CustomScrollbar/CustomScrollbar.module.css"; import headerStyles from "../Header/Header.module.css"; import styles from "./TracksList.module.css"; +import library from "src/renderer/store/reducers/library"; const { shell } = electron; @@ -43,11 +44,13 @@ interface Props { playlists: PlaylistModel[]; currentPlaylist?: string; reorderable?: boolean; + layout: LibraryActions.LibraryLayoutSettings; onReorder?: (playlistId: string, tracksIds: string[], targetTrackId: string, position: "above" | "below") => void; } const TracksList: React.FC = (props) => { - const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists } = props; + const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists, layout } = + props; const [tilesScrolled, setTilesScrolled] = useState(0); const [selected, setSelected] = useState([]); @@ -541,7 +544,7 @@ const TracksList: React.FC = (props) => { return (
- +
{trackTiles} diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 31879655a..64322b4aa 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -12,9 +12,13 @@ import styles from "./TracksListHeader.module.css"; import electron from "electron"; import { type } from "os"; import playlists from "src/renderer/store/reducers/playlists"; +import { LibraryLayoutSettings, set_context_state } from "src/renderer/store/actions/LibraryActions"; interface OwnProps { enableSort: boolean; + // this should be in the interface below, but it doesn't want to work there + // FIXME? + libraryLayoutSettings: LibraryLayoutSettings; } interface InjectedProps { @@ -37,10 +41,31 @@ class TracksListHeader extends React.Component { return null; }; - showContextMenu(_e: React.MouseEvent, selected: string) { + // questionable? + constructor(props: Props, state: LibraryLayoutSettings) { + super(props, state); + + console.log(props); + } + + showContextMenu(_e: React.MouseEvent, selected: string, title: string) { const template: electron.MenuItemConstructorOptions[] = [ { - label: selected, + label: `Selected: ${title}`, + enabled: false, + }, + { + type: "checkbox", + label: "Collapse Artist and Title", + checked: this.props.libraryLayoutSettings.collapse_artist, + click: () => { + // I didn't know a better way to do this + // FIXME + set_context_state({ + visibility: this.props.libraryLayoutSettings.visibility, + collapse_artist: !this.props.libraryLayoutSettings.collapse_artist, + }); + }, }, ]; @@ -57,35 +82,35 @@ class TracksListHeader extends React.Component { {" "} this.showContextMenu(e, "title")} + onContextMenu={(e) => this.showContextMenu(e, "title", "Title")} className={styles.cellSection} title="Title" sortBy={enableSort ? SortBy.TITLE : null} icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} /> this.showContextMenu(e, "duration")} + onContextMenu={(e) => this.showContextMenu(e, "duration", "Duration")} className={styles.cellDuration} title="Duration" sortBy={enableSort ? SortBy.DURATION : null} icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} /> this.showContextMenu(e, "album")} + onContextMenu={(e) => this.showContextMenu(e, "album", "Album")} className={styles.cellAlbum} title="Album" sortBy={enableSort ? SortBy.ALBUM : null} icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} /> this.showContextMenu(e, "added")} + onContextMenu={(e) => this.showContextMenu(e, "added", "Added")} className={styles.cellAdded} title="Date Added" sortBy={enableSort ? SortBy.ADDED : null} icon={TracksListHeader.getIcon(sort, SortBy.ADDED)} /> this.showContextMenu(e, "genre")} + onContextMenu={(e) => this.showContextMenu(e, "genre", "Genre")} className={styles.cellGenre} title="Genre" sortBy={enableSort ? SortBy.GENRE : null} diff --git a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx index c601dc795..9c610aecf 100644 --- a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx +++ b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx @@ -53,7 +53,7 @@ class TracksListHeaderCell extends React.Component { if (sortBy) { return ( - ); diff --git a/src/renderer/store/action-types.ts b/src/renderer/store/action-types.ts index 3c91cd06e..93bf4a5fe 100644 --- a/src/renderer/store/action-types.ts +++ b/src/renderer/store/action-types.ts @@ -1,45 +1,47 @@ enum ActionTypes { - LIBRARY_REFRESH = 'LIBRARY_REFRESH', - REFRESH_CONFIG = 'REFRESH_CONFIG', - - FILTER_SEARCH = 'FILTER_SEARCH', - - PLAYER_START = 'PLAYER_START', - PLAYER_TOGGLE = 'PLAYER_TOGGLE', - PLAYER_PLAY = 'PLAYER_PLAY', - PLAYER_PAUSE = 'PLAYER_PAUSE', - PLAYER_STOP = 'PLAYER_STOP', - PLAYER_NEXT = 'PLAYER_NEXT', - PLAYER_PREVIOUS = 'PLAYER_PREVIOUS', - PLAYER_JUMP_TO = 'PLAYER_JUMP_TO', - - PLAYER_SHUFFLE = 'PLAYER_SHUFFLE', - PLAYER_REPEAT = 'PLAYER_REPEAT', - - QUEUE_START = 'QUEUE_START', - QUEUE_CLEAR = 'QUEUE_CLEAR', - QUEUE_REMOVE = 'QUEUE_REMOVE', - QUEUE_ADD = 'QUEUE_ADD', - QUEUE_ADD_NEXT = 'QUEUE_ADD_NEXT', - QUEUE_SET_QUEUE = 'QUEUE_SET_QUEUE', - - LIBRARY_SORT = 'LIBRARY_SORT', - LIBRARY_RESET = 'LIBRARY_RESET', - LIBRARY_REFRESH_START = 'LIBRARY_REFRESH_START', - LIBRARY_REFRESH_END = 'LIBRARY_REFRESH_END', - LIBRARY_REFRESH_PROGRESS = 'LIBRARY_REFRESH_PROGRESS', - LIBRARY_ADD_TRACKS = 'LIBRARY_ADD_TRACKS', - LIBRARY_REMOVE_TRACKS = 'LIBRARY_REMOVE_TRACKS', - LIBRARY_HIGHLIGHT_PLAYING_TRACK = 'LIBRARY_HIGHLIGHT_PLAYING_TRACK', - - PLAYLISTS_REFRESH = 'PLAYLISTS_REFRESH', - PLAYLISTS_LOAD_ONE = 'PLAYLISTS_LOAD_ONE', - PLAYLIST_REORDER_TRACKS = 'PLAYLIST_REORDER_TRACKS', - - TOAST_ADD = 'TOAST_ADD', - TOAST_REMOVE = 'TOAST_REMOVE', - - NOTIFICATION_NEW = 'NOTIFICATION_NEW', + LIBRARY_REFRESH = "LIBRARY_REFRESH", + REFRESH_CONFIG = "REFRESH_CONFIG", + + FILTER_SEARCH = "FILTER_SEARCH", + + PLAYER_START = "PLAYER_START", + PLAYER_TOGGLE = "PLAYER_TOGGLE", + PLAYER_PLAY = "PLAYER_PLAY", + PLAYER_PAUSE = "PLAYER_PAUSE", + PLAYER_STOP = "PLAYER_STOP", + PLAYER_NEXT = "PLAYER_NEXT", + PLAYER_PREVIOUS = "PLAYER_PREVIOUS", + PLAYER_JUMP_TO = "PLAYER_JUMP_TO", + + PLAYER_SHUFFLE = "PLAYER_SHUFFLE", + PLAYER_REPEAT = "PLAYER_REPEAT", + + QUEUE_START = "QUEUE_START", + QUEUE_CLEAR = "QUEUE_CLEAR", + QUEUE_REMOVE = "QUEUE_REMOVE", + QUEUE_ADD = "QUEUE_ADD", + QUEUE_ADD_NEXT = "QUEUE_ADD_NEXT", + QUEUE_SET_QUEUE = "QUEUE_SET_QUEUE", + + LIBRARY_SORT = "LIBRARY_SORT", + LIBRARY_RESET = "LIBRARY_RESET", + LIBRARY_REFRESH_START = "LIBRARY_REFRESH_START", + LIBRARY_REFRESH_END = "LIBRARY_REFRESH_END", + LIBRARY_REFRESH_PROGRESS = "LIBRARY_REFRESH_PROGRESS", + LIBRARY_ADD_TRACKS = "LIBRARY_ADD_TRACKS", + LIBRARY_REMOVE_TRACKS = "LIBRARY_REMOVE_TRACKS", + LIBRARY_HIGHLIGHT_PLAYING_TRACK = "LIBRARY_HIGHLIGHT_PLAYING_TRACK", + + PLAYLISTS_REFRESH = "PLAYLISTS_REFRESH", + PLAYLISTS_LOAD_ONE = "PLAYLISTS_LOAD_ONE", + PLAYLIST_REORDER_TRACKS = "PLAYLIST_REORDER_TRACKS", + + TOAST_ADD = "TOAST_ADD", + TOAST_REMOVE = "TOAST_REMOVE", + + SET_LIBRARY_LAYOUT_STATE = "SET_LIBRARY_LAYOUT_STATE", + + NOTIFICATION_NEW = "NOTIFICATION_NEW", } export default ActionTypes; diff --git a/src/renderer/store/actions/LibraryActions.ts b/src/renderer/store/actions/LibraryActions.ts index 785d3d367..8d295cb93 100644 --- a/src/renderer/store/actions/LibraryActions.ts +++ b/src/renderer/store/actions/LibraryActions.ts @@ -67,6 +67,21 @@ export const sort = (sortBy: SortBy): void => { }); }; +// idk what to name or where to put this +// also rename types.SET_LIBRARY_LAYOUT_STATE to something more appropiate as well +// FIXME +export interface LibraryLayoutSettings { + collapse_artist?: boolean; + visibility: string[]; +} + +export const set_context_state = (state: LibraryLayoutSettings): void => { + store.dispatch({ + type: types.SET_LIBRARY_LAYOUT_STATE, + payload: state, + }); +}; + const scanPlaylists = async (paths: string[]) => { return Promise.all( paths.map(async (filePath) => { diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index 1f9989840..007a18a49 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -1,206 +1,221 @@ -import types from '../action-types'; +import types from "../action-types"; -import { config } from '../../lib/app'; -import * as utils from '../../lib/utils'; -import { Action, TrackModel, SortBy, SortOrder } from '../../../shared/types/museeks'; +import { config } from "../../lib/app"; +import * as utils from "../../lib/utils"; +import { Action, TrackModel, SortBy, SortOrder } from "../../../shared/types/museeks"; +import { LibraryLayoutSettings as LibraryLayoutSettings } from "../actions/LibraryActions"; export interface LibrarySort { - by: SortBy; - order: SortOrder; + by: SortBy; + order: SortOrder; } export interface LibraryState { - tracks: { - library: TrackModel[]; // List of tracks in Library view - playlist: TrackModel[]; // List of tracks in Playlist view - }; - search: string; - sort: LibrarySort; - loading: boolean; - refreshing: boolean; - refresh: { - processed: number; - total: number; - }; - highlightPlayingTrack: boolean; + tracks: { + library: TrackModel[]; // List of tracks in Library view + playlist: TrackModel[]; // List of tracks in Playlist view + }; + search: string; + sort: LibrarySort; + loading: boolean; + refreshing: boolean; + refresh: { + processed: number; + total: number; + }; + highlightPlayingTrack: boolean; + libraryLayoutSettings: LibraryLayoutSettings; } const initialState: LibraryState = { - tracks: { - library: [], - playlist: [], - }, - search: '', - sort: config.get('librarySort'), - loading: true, - refreshing: false, - refresh: { - processed: 0, - total: 0, - }, - highlightPlayingTrack: false, + tracks: { + library: [], + playlist: [], + }, + search: "", + sort: config.get("librarySort"), + loading: true, + refreshing: false, + refresh: { + processed: 0, + total: 0, + }, + highlightPlayingTrack: false, + libraryLayoutSettings: config.get("libraryLayoutSettings"), }; export default (state = initialState, action: Action): LibraryState => { - switch (action.type) { - case types.LIBRARY_REFRESH: { - return { - ...state, - tracks: { - library: [...action.payload.tracks], - playlist: [], - }, - loading: false, - }; - } - - case types.LIBRARY_SORT: { - const { sortBy } = action.payload; - const prevSort = state.sort; - - if (sortBy === prevSort.by) { - return { - ...state, - sort: { - ...state.sort, - order: prevSort.order === SortOrder.ASC ? SortOrder.DSC : SortOrder.ASC, - }, - }; - } - - const sort: LibrarySort = { - by: sortBy, - order: SortOrder.ASC, - }; - - config.set('librarySort', sort); - config.save(); - - return { - ...state, - sort, - }; - } - - case types.FILTER_SEARCH: { - return { - ...state, - search: utils.stripAccents(action.payload.search), - }; - } - - // case (types.LIBRARY_ADD_FOLDERS): { // TODO Redux -> move to a thunk - // const { folders } = action.payload; - // let musicFolders = app.config.get('musicFolders'); - - // // Check if we received folders - // if (folders !== undefined) { - // musicFolders = musicFolders.concat(folders); - - // // Remove duplicates, useless children, ect... - // musicFolders = utils.removeUselessFolders(musicFolders); - - // musicFolders.sort(); - - // app.config.set('musicFolders', musicFolders); - // app.config.saveSync(); - // } - - // return { ...state }; - // } - - // case (types.LIBRARY_REMOVE_FOLDER): { // TODO Redux -> move to a thunk - // if (!state.library.refreshing) { - // const musicFolders = app.config.get('musicFolders'); - - // musicFolders.splice(action.index, 1); - - // app.config.set('musicFolders', musicFolders); - // app.config.saveSync(); - - // return { ...state }; - // } - - // return state; - // } - - case types.LIBRARY_RESET: { - return initialState; - } - - case types.LIBRARY_REFRESH_START: { - return { - ...state, - refreshing: true, - }; - } - - case types.LIBRARY_REFRESH_END: { - return { - ...state, - refreshing: false, - refresh: { - processed: 0, - total: 0, - }, - }; - } - - case types.LIBRARY_REFRESH_PROGRESS: { - return { - ...state, - refresh: { - processed: action.payload.processed, - total: action.payload.total, - }, - }; - } - - case types.LIBRARY_REMOVE_TRACKS: { - const { tracksIds } = action.payload; - const removeTrack = (track: TrackModel) => !tracksIds.includes(track._id); - - const tracks = { - library: [...state.tracks.library].filter(removeTrack), - playlist: [...state.tracks.playlist].filter(removeTrack), - }; - - return { - ...state, - tracks, - }; - } - - case types.LIBRARY_ADD_TRACKS: { - const { tracks } = action.payload; - - const libraryTracks: TrackModel[] = [...state.tracks.library, ...tracks]; - - return { - ...state, - tracks: { - playlist: state.tracks.playlist, - library: libraryTracks, - }, - }; - } - - case types.LIBRARY_HIGHLIGHT_PLAYING_TRACK: { - return { - ...state, - highlightPlayingTrack: action.payload.highlight, - }; - } - - case types.PLAYLISTS_LOAD_ONE: { - const newState = { ...state }; - newState.tracks.playlist = [...action.payload.tracks]; - - return newState; - } - - default: { - return state; - } - } + switch (action.type) { + case types.LIBRARY_REFRESH: { + return { + ...state, + tracks: { + library: [...action.payload.tracks], + playlist: [], + }, + loading: false, + }; + } + + case types.LIBRARY_SORT: { + const { sortBy } = action.payload; + const prevSort = state.sort; + + if (sortBy === prevSort.by) { + return { + ...state, + sort: { + ...state.sort, + order: prevSort.order === SortOrder.ASC ? SortOrder.DSC : SortOrder.ASC, + }, + }; + } + + const sort: LibrarySort = { + by: sortBy, + order: SortOrder.ASC, + }; + + config.set("librarySort", sort); + config.save(); + + return { + ...state, + sort, + }; + } + + case types.SET_LIBRARY_LAYOUT_STATE: { + // const prevState = state.sort; + + config.set("libraryLayoutSettings", action.payload); + config.save(); + + return { + ...state, + libraryLayoutSettings: action.payload, + }; + } + + case types.FILTER_SEARCH: { + return { + ...state, + search: utils.stripAccents(action.payload.search), + }; + } + + // case (types.LIBRARY_ADD_FOLDERS): { // TODO Redux -> move to a thunk + // const { folders } = action.payload; + // let musicFolders = app.config.get('musicFolders'); + + // // Check if we received folders + // if (folders !== undefined) { + // musicFolders = musicFolders.concat(folders); + + // // Remove duplicates, useless children, ect... + // musicFolders = utils.removeUselessFolders(musicFolders); + + // musicFolders.sort(); + + // app.config.set('musicFolders', musicFolders); + // app.config.saveSync(); + // } + + // return { ...state }; + // } + + // case (types.LIBRARY_REMOVE_FOLDER): { // TODO Redux -> move to a thunk + // if (!state.library.refreshing) { + // const musicFolders = app.config.get('musicFolders'); + + // musicFolders.splice(action.index, 1); + + // app.config.set('musicFolders', musicFolders); + // app.config.saveSync(); + + // return { ...state }; + // } + + // return state; + // } + + case types.LIBRARY_RESET: { + return initialState; + } + + case types.LIBRARY_REFRESH_START: { + return { + ...state, + refreshing: true, + }; + } + + case types.LIBRARY_REFRESH_END: { + return { + ...state, + refreshing: false, + refresh: { + processed: 0, + total: 0, + }, + }; + } + + case types.LIBRARY_REFRESH_PROGRESS: { + return { + ...state, + refresh: { + processed: action.payload.processed, + total: action.payload.total, + }, + }; + } + + case types.LIBRARY_REMOVE_TRACKS: { + const { tracksIds } = action.payload; + const removeTrack = (track: TrackModel) => !tracksIds.includes(track._id); + + const tracks = { + library: [...state.tracks.library].filter(removeTrack), + playlist: [...state.tracks.playlist].filter(removeTrack), + }; + + return { + ...state, + tracks, + }; + } + + case types.LIBRARY_ADD_TRACKS: { + const { tracks } = action.payload; + + const libraryTracks: TrackModel[] = [...state.tracks.library, ...tracks]; + + return { + ...state, + tracks: { + playlist: state.tracks.playlist, + library: libraryTracks, + }, + }; + } + + case types.LIBRARY_HIGHLIGHT_PLAYING_TRACK: { + return { + ...state, + highlightPlayingTrack: action.payload.highlight, + }; + } + + case types.PLAYLISTS_LOAD_ONE: { + const newState = { ...state }; + newState.tracks.playlist = [...action.payload.tracks]; + + return newState; + } + + default: { + return state; + } + } }; diff --git a/src/renderer/views/Library/Library.tsx b/src/renderer/views/Library/Library.tsx index 968e7e8ce..69af9919f 100644 --- a/src/renderer/views/Library/Library.tsx +++ b/src/renderer/views/Library/Library.tsx @@ -77,6 +77,7 @@ const Library: React.FC = () => { return ( Date: Tue, 21 Jun 2022 14:18:42 +0200 Subject: [PATCH 05/18] add ability to hide columns --- README.md | 2 + .../components/Header/Header.module.css | 1 - .../components/Playlists/Playlist.tsx | 170 +++++++++--------- .../components/TrackRow/TrackRow.module.css | 2 +- src/renderer/components/TrackRow/TrackRow.tsx | 43 +++-- .../TracksList/TracksList.module.css | 1 - .../components/TracksList/TracksList.tsx | 5 +- .../TracksListHeader.module.css | 13 +- .../TracksListHeader/TracksListHeader.tsx | 114 ++++++++---- .../TracksListHeaderCell.tsx | 1 + src/renderer/constants/sort-orders.ts | 9 + src/renderer/store/reducers/library.ts | 7 +- src/shared/types/museeks.ts | 1 + 13 files changed, 233 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 817b82286..6cfb41dd5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ This fork seeks to implement at least a section of issue #401 and slight interfa Everything tagged with FIXME Casing (i'm not used to camelCase) +Indentation (I couldn't stand spaces:2) +Playlist always shows "Your search returned no results" # Museeks diff --git a/src/renderer/components/Header/Header.module.css b/src/renderer/components/Header/Header.module.css index b6b8a6ce7..3416c0aac 100644 --- a/src/renderer/components/Header/Header.module.css +++ b/src/renderer/components/Header/Header.module.css @@ -19,7 +19,6 @@ display: flex; align-items: center; justify-content: space-between; - height: 50px; padding: 5px; flex: 0 0 auto; diff --git a/src/renderer/components/Playlists/Playlist.tsx b/src/renderer/components/Playlists/Playlist.tsx index 4f8b7db01..9ae127178 100644 --- a/src/renderer/components/Playlists/Playlist.tsx +++ b/src/renderer/components/Playlists/Playlist.tsx @@ -1,99 +1,105 @@ -import React, { useCallback, useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { useParams } from 'react-router'; -import { Link } from 'react-router-dom'; +import React, { useCallback, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; +import { Link } from "react-router-dom"; -import TracksList from '../TracksList/TracksList'; -import * as ViewMessage from '../../elements/ViewMessage/ViewMessage'; +import TracksList from "../TracksList/TracksList"; +import * as ViewMessage from "../../elements/ViewMessage/ViewMessage"; -import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; -import { filterTracks } from '../../lib/utils-library'; -import { RootState } from '../../store/reducers'; +import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; +import { filterTracks } from "../../lib/utils-library"; +import { RootState } from "../../store/reducers"; const Playlist: React.FC = () => { - const params = useParams(); - const playlistId = params.playlistId; + const params = useParams(); + const playlistId = params.playlistId; - const { tracks, trackPlayingId, playerStatus, playlists, currentPlaylist } = useSelector((state: RootState) => { - const { library, player, playlists } = state; + const { tracks, trackPlayingId, playerStatus, playlists, currentPlaylist, library } = useSelector( + (state: RootState) => { + const { library, player, playlists } = state; - const { search, tracks } = library; - const filteredTracks = filterTracks(tracks.playlist, search); + const { search, tracks } = library; + const filteredTracks = filterTracks(tracks.playlist, search); - const currentPlaylist = playlists.list.find((p) => p._id === playlistId); + const currentPlaylist = playlists.list.find((p) => p._id === playlistId); - return { - playlists: playlists.list, - currentPlaylist, - tracks: filteredTracks, - playerStatus: player.playerStatus, - trackPlayingId: - player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null, - }; - }); + return { + library: library, + playlists: playlists.list, + currentPlaylist, + tracks: filteredTracks, + playerStatus: player.playerStatus, + trackPlayingId: + player.queue.length > 0 && player.queueCursor !== null + ? player.queue[player.queueCursor]._id + : null, + }; + } + ); - useEffect(() => { - if (playlistId) { - PlaylistsActions.load(playlistId); - } - }, [playlistId]); + useEffect(() => { + if (playlistId) { + PlaylistsActions.load(playlistId); + } + }, [playlistId]); - const onReorder = useCallback( - (playlistId: string, tracksIds: string[], targetTrackId: string, position: 'above' | 'below') => { - PlaylistsActions.reorderTracks(playlistId, tracksIds, targetTrackId, position); - }, - [] - ); + const onReorder = useCallback( + (playlistId: string, tracksIds: string[], targetTrackId: string, position: "above" | "below") => { + PlaylistsActions.reorderTracks(playlistId, tracksIds, targetTrackId, position); + }, + [] + ); - if (currentPlaylist && currentPlaylist.tracks.length === 0) { - return ( - -

Empty playlist

- - You can add tracks from the{' '} - - library view - - -
- ); - } + if (currentPlaylist && currentPlaylist.tracks.length === 0) { + return ( + +

Empty playlist

+ + You can add tracks from the{" "} + + library view + + +
+ ); + } - if (tracks.length === 0) { - return ( - -

Your search returned no results

-
- ); - } + if (tracks.length === 0) { + return ( + +

Your search returned no results

+
+ ); + } - // A bit hacky though - if (currentPlaylist && currentPlaylist.tracks.length === 0) { - return ( - -

Empty playlist

- - You can add tracks from the{' '} - - library view - - -
- ); - } + // A bit hacky though + if (currentPlaylist && currentPlaylist.tracks.length === 0) { + return ( + +

Empty playlist

+ + You can add tracks from the{" "} + + library view + + +
+ ); + } - return ( - - ); + return ( + + ); }; export default Playlist; diff --git a/src/renderer/components/TrackRow/TrackRow.module.css b/src/renderer/components/TrackRow/TrackRow.module.css index 170849040..f20b73886 100644 --- a/src/renderer/components/TrackRow/TrackRow.module.css +++ b/src/renderer/components/TrackRow/TrackRow.module.css @@ -11,7 +11,7 @@ position: relative; display: flex; outline: none; - height: 50px; + height: 30px; padding: 5px; &:nth-child(odd) { diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 4175f0d75..3c50e058f 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -7,6 +7,7 @@ import { TrackModel } from "../../../shared/types/museeks"; import cellStyles from "../TracksListHeader/TracksListHeader.module.css"; import styles from "./TrackRow.module.css"; +import { LibraryLayoutSettings } from "src/renderer/store/actions/LibraryActions"; interface Props { selected: boolean; @@ -24,6 +25,8 @@ interface Props { onDragOver?: (trackId: string, position: "above" | "below") => void; onDragEnd?: () => void; onDrop?: (targetTrackId: string, position: "above" | "below") => void; + + layout: LibraryLayoutSettings; } interface State { @@ -103,7 +106,7 @@ export default class TrackRow extends React.PureComponent { }; render() { - const { track, selected, reordered, draggable } = this.props; + const { track, selected, reordered, draggable, layout } = this.props; const { reorderOver, reorderPosition } = this.state; const trackClasses = cx(styles.track, { @@ -114,6 +117,29 @@ export default class TrackRow extends React.PureComponent { [styles.isBelow]: reorderPosition === "below", }); + let sorts = [ + [ + layout.visibility.includes("title"), +
{track.title}
, + ], + [ + layout.visibility.includes("duration"), +
{parseDuration(track.duration)}
, + ], + [ + layout.visibility.includes("artist"), +
{track.artist.sort().join(", ")}
, + ], + [ + layout.visibility.includes("album"), +
{track.album}
, + ], + [ + layout.visibility.includes("genre"), +
{track.genre.join(", ")}
, + ], + ]; + return (
{
{this.props.isPlaying ? : null}
-
-
{track.title}
-
{track.artist.sort().join(", ")}
-
-
{parseDuration(track.duration)}
-
{track.album}
-
+ {/* FIXME shit code */} + {this.props.layout.collapse_artist ? "Lol" : undefined} + {sorts.filter((value) => value[0] && value[1])} + + {/*
{new Date(track.added || 0).toDateString()} -
-
{track.genre.join(", ")}
+
*/}
); } diff --git a/src/renderer/components/TracksList/TracksList.module.css b/src/renderer/components/TracksList/TracksList.module.css index cefb949a4..101e9c4e0 100644 --- a/src/renderer/components/TracksList/TracksList.module.css +++ b/src/renderer/components/TracksList/TracksList.module.css @@ -17,6 +17,5 @@ .tile { position: absolute; width: 100%; - height: 60px; z-index: 10; } diff --git a/src/renderer/components/TracksList/TracksList.tsx b/src/renderer/components/TracksList/TracksList.tsx index 45f939d2d..1ca7a9960 100644 --- a/src/renderer/components/TracksList/TracksList.tsx +++ b/src/renderer/components/TracksList/TracksList.tsx @@ -28,7 +28,7 @@ import library from "src/renderer/store/reducers/library"; const { shell } = electron; const CHUNK_LENGTH = 20; -const ROW_HEIGHT = 50; // FIXME +const ROW_HEIGHT = 30; // FIXME const TILES_TO_DISPLAY = 5; const TILE_HEIGHT = ROW_HEIGHT * CHUNK_LENGTH; @@ -495,6 +495,7 @@ const TracksList: React.FC = (props) => { return ( = (props) => { return (
- +
{trackTiles} diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.module.css b/src/renderer/components/TracksListHeader/TracksListHeader.module.css index 25f4457d5..5200e2f82 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.module.css +++ b/src/renderer/components/TracksListHeader/TracksListHeader.module.css @@ -7,7 +7,7 @@ } .cellTrackPlaying { - width: 20px; + width: 30px; text-align: center; } @@ -19,14 +19,19 @@ width: 7%; } +.cellArtist, .cellAlbum, -.cellAdded, +.cellTitle, .cellSection, .cellGenre { width: 20%; } .cellSection { - height: 100%; - width: 30%; + width: 20%; + height: 45px; +} + +.resetWidth { + width: unset; } diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 64322b4aa..54c3a6c75 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -18,7 +18,7 @@ interface OwnProps { enableSort: boolean; // this should be in the interface below, but it doesn't want to work there // FIXME? - libraryLayoutSettings: LibraryLayoutSettings; + layout: LibraryLayoutSettings; } interface InjectedProps { @@ -27,6 +27,11 @@ interface InjectedProps { type Props = OwnProps & InjectedProps; +const LAYOUT_LISTS = ["artist", "duration", "title", "added", "genre"]; +const capitalize = (str: string) => { + return str.toUpperCase()[0] + str.substring(1); +}; + class TracksListHeader extends React.Component { static getIcon = (sort: LibrarySort | undefined, sortType: SortBy) => { if (sort && sort.by === sortType) { @@ -48,26 +53,35 @@ class TracksListHeader extends React.Component { console.log(props); } - showContextMenu(_e: React.MouseEvent, selected: string, title: string) { + showContextMenu(_e: React.MouseEvent, id: string) { const template: electron.MenuItemConstructorOptions[] = [ { - label: `Selected: ${title}`, + label: `Selected: ${capitalize(id)}`, enabled: false, }, - { + ]; + + LAYOUT_LISTS.forEach((tag) => { + template.push({ type: "checkbox", - label: "Collapse Artist and Title", - checked: this.props.libraryLayoutSettings.collapse_artist, + label: capitalize(tag), + checked: this.props.layout.visibility.includes(tag), click: () => { - // I didn't know a better way to do this - // FIXME - set_context_state({ - visibility: this.props.libraryLayoutSettings.visibility, - collapse_artist: !this.props.libraryLayoutSettings.collapse_artist, - }); + // A very confusing toggle mechanism + const visibility = this.props.layout.visibility; + console.log(visibility); + if (visibility.includes(tag)) { + set_context_state({ + visibility: visibility.filter((value) => value !== tag), + }); + } else { + set_context_state({ + visibility: [...visibility, tag], + }); + } }, - }, - ]; + }); + }); const context = Menu.buildFromTemplate(template); @@ -75,47 +89,79 @@ class TracksListHeader extends React.Component { } render() { - const { enableSort, sort } = this.props; + const { enableSort, sort, layout } = this.props; - return ( -
- {" "} - + let sorts = [ + [ + layout.visibility.includes("title"), this.showContextMenu(e, "title", "Title")} - className={styles.cellSection} + onContextMenu={(e) => this.showContextMenu(e, "title")} + className={styles.cellTrack} title="Title" sortBy={enableSort ? SortBy.TITLE : null} icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} - /> + layout={this.props.layout} + />, + ], + [ + layout.visibility.includes("duration"), this.showContextMenu(e, "duration", "Duration")} + onContextMenu={(e) => this.showContextMenu(e, "duration")} className={styles.cellDuration} title="Duration" sortBy={enableSort ? SortBy.DURATION : null} icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} - /> + layout={this.props.layout} + />, + ], + [ + layout.visibility.includes("artist"), this.showContextMenu(e, "album", "Album")} + onContextMenu={(e) => this.showContextMenu(e, "artist")} + className={styles.cellArtist} + title="Artist" + sortBy={enableSort ? SortBy.ARTIST : null} + icon={TracksListHeader.getIcon(sort, SortBy.ARTIST)} + layout={this.props.layout} + />, + ], + [ + layout.visibility.includes("album"), + this.showContextMenu(e, "album")} className={styles.cellAlbum} title="Album" sortBy={enableSort ? SortBy.ALBUM : null} icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} - /> + layout={this.props.layout} + />, + ], + [ + layout.visibility.includes("genre"), this.showContextMenu(e, "genre")} + className={styles.cellGenre} + title="Genre" + sortBy={enableSort ? SortBy.GENRE : null} + icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} + layout={this.props.layout} + />, + ], + ]; + + return ( +
+ {" "} + + {sorts.filter((value) => value[0] && value[1])} + {/* this.showContextMenu(e, "added", "Added")} className={styles.cellAdded} title="Date Added" sortBy={enableSort ? SortBy.ADDED : null} icon={TracksListHeader.getIcon(sort, SortBy.ADDED)} - /> - this.showContextMenu(e, "genre", "Genre")} - className={styles.cellGenre} - title="Genre" - sortBy={enableSort ? SortBy.GENRE : null} - icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} - /> + layout={this.props.layout} + /> */}
); } diff --git a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx index 9c610aecf..ac2d71a5a 100644 --- a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx +++ b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx @@ -12,6 +12,7 @@ interface Props { className?: string; sortBy?: SortBy | null; icon?: string | null; + layout: LibraryActions.LibraryLayoutSettings; onContextMenu?: (event: React.MouseEvent) => void; } diff --git a/src/renderer/constants/sort-orders.ts b/src/renderer/constants/sort-orders.ts index 8e2eb4d2a..8172a6a08 100644 --- a/src/renderer/constants/sort-orders.ts +++ b/src/renderer/constants/sort-orders.ts @@ -7,6 +7,15 @@ const parseGenre = (t: Track): string => t.loweredMetas.genre.toString(); // Declarations const sortOrders = { + [SortBy.ARTIST]: { + [SortOrder.ASC]: [ + // Default + [parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], + null, + ], + [SortOrder.DSC]: [[parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], + }, + // FIXME [SortBy.ADDED]: { [SortOrder.ASC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], [SortOrder.DSC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index 007a18a49..cfa8ab628 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -41,7 +41,12 @@ const initialState: LibraryState = { total: 0, }, highlightPlayingTrack: false, - libraryLayoutSettings: config.get("libraryLayoutSettings"), + // I'm not sure if this is a good layout for default settings + // FIXME + libraryLayoutSettings: config.get("libraryLayoutSettings") || { + visibility: ["title", "duration", "artist", "genre"], + collapse_artist: false, + }, }; export default (state = initialState, action: Action): LibraryState => { diff --git a/src/shared/types/museeks.ts b/src/shared/types/museeks.ts index a7b6844e6..777007eaf 100644 --- a/src/shared/types/museeks.ts +++ b/src/shared/types/museeks.ts @@ -15,6 +15,7 @@ export enum Repeat { export enum SortBy { ALBUM = "album", + ARTIST = "artist", TITLE = "title", DURATION = "duration", GENRE = "genre", From 5347b944daecaa614a30946edd2e385521877443 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 14:31:35 +0200 Subject: [PATCH 06/18] ability to hide columns --- src/renderer/components/TrackRow/TrackRow.tsx | 5 ++--- .../TracksListHeader/TracksListHeader.tsx | 15 ++++++++++++--- src/renderer/store/actions/LibraryActions.ts | 1 - src/renderer/store/reducers/library.ts | 1 - 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 3c50e058f..9f462d20c 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -166,9 +166,8 @@ export default class TrackRow extends React.PureComponent {
{this.props.isPlaying ? : null}
- {/* FIXME shit code */} - {this.props.layout.collapse_artist ? "Lol" : undefined} - {sorts.filter((value) => value[0] && value[1])} + {/* {this.props.layout.collapse_artist ? "Lol" : undefined} */} + {...sorts.filter((value) => value[0] && value[1])} {/*
{new Date(track.added || 0).toDateString()} diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 54c3a6c75..6edeef4b3 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -27,7 +27,7 @@ interface InjectedProps { type Props = OwnProps & InjectedProps; -const LAYOUT_LISTS = ["artist", "duration", "title", "added", "genre"]; +const LAYOUT_LISTS = ["artist", "duration", "title", "genre"]; const capitalize = (str: string) => { return str.toUpperCase()[0] + str.substring(1); }; @@ -101,6 +101,7 @@ class TracksListHeader extends React.Component { sortBy={enableSort ? SortBy.TITLE : null} icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} layout={this.props.layout} + key="Title" />, ], [ @@ -112,6 +113,7 @@ class TracksListHeader extends React.Component { sortBy={enableSort ? SortBy.DURATION : null} icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} layout={this.props.layout} + key="Duration" />, ], [ @@ -123,6 +125,7 @@ class TracksListHeader extends React.Component { sortBy={enableSort ? SortBy.ARTIST : null} icon={TracksListHeader.getIcon(sort, SortBy.ARTIST)} layout={this.props.layout} + key="Artist" />, ], [ @@ -134,6 +137,7 @@ class TracksListHeader extends React.Component { sortBy={enableSort ? SortBy.ALBUM : null} icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} layout={this.props.layout} + key="Album" />, ], [ @@ -145,15 +149,20 @@ class TracksListHeader extends React.Component { sortBy={enableSort ? SortBy.GENRE : null} icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} layout={this.props.layout} + key="Genre" />, ], ]; + let headers = sorts.filter((value) => value[0] && value[1]); return ( -
+
this.showContextMenu(e, "background") : undefined} + > {" "} - {sorts.filter((value) => value[0] && value[1])} + {...headers} {/* this.showContextMenu(e, "added", "Added")} className={styles.cellAdded} diff --git a/src/renderer/store/actions/LibraryActions.ts b/src/renderer/store/actions/LibraryActions.ts index 8d295cb93..c3639fdde 100644 --- a/src/renderer/store/actions/LibraryActions.ts +++ b/src/renderer/store/actions/LibraryActions.ts @@ -71,7 +71,6 @@ export const sort = (sortBy: SortBy): void => { // also rename types.SET_LIBRARY_LAYOUT_STATE to something more appropiate as well // FIXME export interface LibraryLayoutSettings { - collapse_artist?: boolean; visibility: string[]; } diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index cfa8ab628..cfd64f446 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -45,7 +45,6 @@ const initialState: LibraryState = { // FIXME libraryLayoutSettings: config.get("libraryLayoutSettings") || { visibility: ["title", "duration", "artist", "genre"], - collapse_artist: false, }, }; From 36622e84281d03d7e9acac1be610c375ccc04285 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 14:35:28 +0200 Subject: [PATCH 07/18] update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6cfb41dd5..6509bb898 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # blackshibe/museeks -This fork seeks to implement at least a section of issue #401 and slight interface changes. -![image](https://user-images.githubusercontent.com/57596776/174771584-8b38884e-ba57-4963-87cf-db8ede305211.png) +This fork seeks to implement at least a section of issue #401. +![image](https://user-images.githubusercontent.com/57596776/174799989-68dbb31a-2b10-4e90-9567-06ca77afc10c.png) # Issues From 3c7e4fa970bb293624498b61f2a157c93146f2df Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 14:53:25 +0200 Subject: [PATCH 08/18] fix incorrect column array filtering and context menu order --- README.md | 9 +++++---- src/renderer/components/TrackRow/TrackRow.tsx | 13 +++++++------ .../TracksListHeader.module.css | 4 ---- .../TracksListHeader/TracksListHeader.tsx | 18 +++++++----------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6509bb898..f0d20ae6a 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ This fork seeks to implement at least a section of issue #401. # Issues -Everything tagged with FIXME -Casing (i'm not used to camelCase) -Indentation (I couldn't stand spaces:2) -Playlist always shows "Your search returned no results" +- Everything tagged with FIXME +- Fix occasional snake_case casing +- Indentation in some files +- Playlist always shows "Your search returned no results" +- Warning: Each child in a list should have a unique "key" prop. See https://reactjs.org/link/warning-keys for more information. # Museeks diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 9f462d20c..11a2960ca 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -117,7 +117,7 @@ export default class TrackRow extends React.PureComponent { [styles.isBelow]: reorderPosition === "below", }); - let sorts = [ + let sorts: [boolean, React.ReactElement][] = [ [ layout.visibility.includes("title"),
{track.title}
, @@ -140,6 +140,11 @@ export default class TrackRow extends React.PureComponent { ], ]; + let rows: React.ReactElement[] = []; + sorts.forEach((element) => { + if (element[0]) rows.push(element[1]); + }); + return (
{ {this.props.isPlaying ? : null}
{/* {this.props.layout.collapse_artist ? "Lol" : undefined} */} - {...sorts.filter((value) => value[0] && value[1])} - - {/*
- {new Date(track.added || 0).toDateString()} -
*/} + {rows.length !== 0 ? rows : undefined}
); } diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.module.css b/src/renderer/components/TracksListHeader/TracksListHeader.module.css index 5200e2f82..a0e09cd77 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.module.css +++ b/src/renderer/components/TracksListHeader/TracksListHeader.module.css @@ -31,7 +31,3 @@ width: 20%; height: 45px; } - -.resetWidth { - width: unset; -} diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 6edeef4b3..8d316ca7f 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -27,7 +27,7 @@ interface InjectedProps { type Props = OwnProps & InjectedProps; -const LAYOUT_LISTS = ["artist", "duration", "title", "genre"]; +const LAYOUT_LISTS = ["title", "duration", "artist", "genre"]; const capitalize = (str: string) => { return str.toUpperCase()[0] + str.substring(1); }; @@ -91,7 +91,7 @@ class TracksListHeader extends React.Component { render() { const { enableSort, sort, layout } = this.props; - let sorts = [ + let sorts: [boolean, React.ReactElement][] = [ [ layout.visibility.includes("title"), { ], ]; - let headers = sorts.filter((value) => value[0] && value[1]); + let headers: React.ReactElement[] = []; + sorts.forEach((element) => { + if (element[0]) headers.push(element[1]); + }); + return (
{ {" "} {...headers} - {/* this.showContextMenu(e, "added", "Added")} - className={styles.cellAdded} - title="Date Added" - sortBy={enableSort ? SortBy.ADDED : null} - icon={TracksListHeader.getIcon(sort, SortBy.ADDED)} - layout={this.props.layout} - /> */}
); } From 926ea71e856bac76951255b95fadba391319bfb0 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:00:13 +0200 Subject: [PATCH 09/18] undo indentation changes, uhhhhh --- README.md | 1 + src/renderer/components/TracksListHeader/TracksListHeader.tsx | 4 ---- src/renderer/store/actions/LibraryActions.ts | 2 -- src/renderer/store/reducers/library.ts | 2 +- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f0d20ae6a..5ec40491b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This fork seeks to implement at least a section of issue #401. - Indentation in some files - Playlist always shows "Your search returned no results" - Warning: Each child in a list should have a unique "key" prop. See https://reactjs.org/link/warning-keys for more information. +- Cannot use the toggling context menu on empty sections of the header # Museeks diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 8d316ca7f..573136358 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -10,14 +10,10 @@ import { LibrarySort } from "../../store/reducers/library"; import { Menu } from "@electron/remote"; import styles from "./TracksListHeader.module.css"; import electron from "electron"; -import { type } from "os"; -import playlists from "src/renderer/store/reducers/playlists"; import { LibraryLayoutSettings, set_context_state } from "src/renderer/store/actions/LibraryActions"; interface OwnProps { enableSort: boolean; - // this should be in the interface below, but it doesn't want to work there - // FIXME? layout: LibraryLayoutSettings; } diff --git a/src/renderer/store/actions/LibraryActions.ts b/src/renderer/store/actions/LibraryActions.ts index c3639fdde..6168438f9 100644 --- a/src/renderer/store/actions/LibraryActions.ts +++ b/src/renderer/store/actions/LibraryActions.ts @@ -68,8 +68,6 @@ export const sort = (sortBy: SortBy): void => { }; // idk what to name or where to put this -// also rename types.SET_LIBRARY_LAYOUT_STATE to something more appropiate as well -// FIXME export interface LibraryLayoutSettings { visibility: string[]; } diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index cfd64f446..2c9ad8a15 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -41,7 +41,7 @@ const initialState: LibraryState = { total: 0, }, highlightPlayingTrack: false, - // I'm not sure if this is a good layout for default settings + // I'm not sure if this is a good place for the default settings // FIXME libraryLayoutSettings: config.get("libraryLayoutSettings") || { visibility: ["title", "duration", "artist", "genre"], From fa9054557442b25b825d7bc273f5f6014e42e655 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:13:16 +0200 Subject: [PATCH 10/18] revert indentation, fix playlists --- .prettierrc | 26 +- .stylelintrc | 20 +- README.md | 14 - src/main/modules/config.ts | 206 ++-- src/renderer/App.tsx | 166 +-- src/renderer/Root.tsx | 72 +- .../components/Playlists/Playlist.tsx | 174 ++- .../components/PlaylistsNav/PlaylistsNav.tsx | 306 +++-- src/renderer/components/TrackRow/TrackRow.tsx | 336 ++--- .../components/TracksList/TracksList.tsx | 1077 ++++++++--------- .../TracksListHeader.module.css | 24 +- .../TracksListHeader/TracksListHeader.tsx | 313 +++-- .../TracksListHeaderCell.tsx | 108 +- src/renderer/constants/sort-orders.ts | 64 +- src/renderer/main.tsx | 50 +- src/renderer/store/action-types.ts | 86 +- src/renderer/store/actions/LibraryActions.ts | 603 +++++---- src/renderer/store/reducers/library.ts | 424 +++---- src/renderer/views/Library/Library.tsx | 150 +-- 19 files changed, 2091 insertions(+), 2128 deletions(-) diff --git a/.prettierrc b/.prettierrc index dd118dd5c..f3a07ab47 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,16 +1,14 @@ { - "printWidth": 120, - "singleQuote": false, - "jsxSingleQuote": false, - "arrowParens": "always", - "tabWidth": 4, - "useTabs": true, - "overrides": [ - { - "files": ["**/*.css"], - "options": { - "printWidth": 1000 - } - } - ] + "printWidth": 120, + "singleQuote": true, + "jsxSingleQuote": true, + "arrowParens": "always", + "overrides": [ + { + "files": ["**/*.css"], + "options": { + "printWidth": 1000 + } + } + ] } diff --git a/.stylelintrc b/.stylelintrc index 365ea20e2..173020772 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,13 +1,11 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-css-modules"], - "ignoreFiles": ["./src/**/*.tsx", "./src/**/*.ts"], - "rules": { - "no-descending-specificity": null, - "string-quotes": "double", - "max-line-length": 500, - "selector-class-pattern": null, - "tabWidth": 4, - "useTabs": true, - "alpha-value-notation": "number" - } + "extends": ["stylelint-config-standard", "stylelint-config-css-modules"], + "ignoreFiles": ["./src/**/*.tsx", "./src/**/*.ts"], + "rules": { + "no-descending-specificity": null, + "string-quotes": "single", + "max-line-length": 500, + "selector-class-pattern": null, + "alpha-value-notation": "number" + } } diff --git a/README.md b/README.md index 5ec40491b..7926a53d9 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,3 @@ -# blackshibe/museeks - -This fork seeks to implement at least a section of issue #401. -![image](https://user-images.githubusercontent.com/57596776/174799989-68dbb31a-2b10-4e90-9567-06ca77afc10c.png) - -# Issues - -- Everything tagged with FIXME -- Fix occasional snake_case casing -- Indentation in some files -- Playlist always shows "Your search returned no results" -- Warning: Each child in a list should have a unique "key" prop. See https://reactjs.org/link/warning-keys for more information. -- Cannot use the toggling context menu on empty sections of the header - # Museeks ![Build Status](https://github.com/martpie/museeks/workflows/build/badge.svg) diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index e8e0760ec..e3ea9416c 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -2,114 +2,114 @@ * Essential module for creating/loading the app config */ -import path from "path"; -import electron from "electron"; -import teeny from "teeny-conf"; +import path from 'path'; +import electron from 'electron'; +import teeny from 'teeny-conf'; -import { Config, Repeat, SortBy, SortOrder } from "../../shared/types/museeks"; -import Module from "./module"; +import { Config, Repeat, SortBy, SortOrder } from '../../shared/types/museeks'; +import Module from './module'; const { app } = electron; class ConfigModule extends Module { - protected workArea: Electron.Rectangle; - protected conf: teeny | undefined; - - constructor() { - super(); - - this.workArea = electron.screen.getPrimaryDisplay().workArea; - } - - async load(): Promise { - const defaultConfig: Partial = this.getDefaultConfig(); - const pathUserData = app.getPath("userData"); - - this.conf = new teeny(path.join(pathUserData, "config.json"), defaultConfig); - - // Check if config update - let configChanged = false; - - (Object.keys(defaultConfig) as (keyof Config)[]).forEach((key) => { - if (this.conf && this.conf.get(key) === undefined) { - this.conf.set(key, defaultConfig[key]); - configChanged = true; - } - }); - - // save config if changed - if (configChanged) this.conf.save(); - } - - getDefaultConfig(): Config { - const config: Config = { - theme: "__system", - audioVolume: 1, - audioPlaybackRate: 1, - audioOutputDevice: "default", - audioMuted: false, - audioShuffle: false, - audioRepeat: Repeat.NONE, - defaultView: "library", - librarySort: { - by: SortBy.TITLE, - order: SortOrder.ASC, - }, - // musicFolders: [], - sleepBlocker: false, - autoUpdateChecker: true, - minimizeToTray: false, - displayNotifications: true, - bounds: { - width: 1000, - height: 600, - x: Math.round(this.workArea.width / 2), - y: Math.round(this.workArea.height / 2), - }, - }; - - return config; - } - - getConfig(): Config { - if (!this.conf) { - throw new Error("Config not loaded"); - } - - return this.conf.get() as Config; // Maybe possible to type TeenyConf with Generics? - } - - get(key: T): Config[T] { - if (!this.conf) { - throw new Error("Config not loaded"); - } - - return this.conf.get(key); - } - - set(key: T, value: Config[T]): void { - if (!this.conf) { - throw new Error("Config not loaded"); - } - - return this.conf.set(key, value); - } - - save(): void { - if (!this.conf) { - throw new Error("Config not loaded"); - } - - return this.conf.save(); - } - - reload(): void { - if (!this.conf) { - throw new Error("Config not loaded"); - } - - this.conf.reload(); - } + protected workArea: Electron.Rectangle; + protected conf: teeny | undefined; + + constructor() { + super(); + + this.workArea = electron.screen.getPrimaryDisplay().workArea; + } + + async load(): Promise { + const defaultConfig: Partial = this.getDefaultConfig(); + const pathUserData = app.getPath('userData'); + + this.conf = new teeny(path.join(pathUserData, 'config.json'), defaultConfig); + + // Check if config update + let configChanged = false; + + (Object.keys(defaultConfig) as (keyof Config)[]).forEach((key) => { + if (this.conf && this.conf.get(key) === undefined) { + this.conf.set(key, defaultConfig[key]); + configChanged = true; + } + }); + + // save config if changed + if (configChanged) this.conf.save(); + } + + getDefaultConfig(): Config { + const config: Config = { + theme: '__system', + audioVolume: 1, + audioPlaybackRate: 1, + audioOutputDevice: 'default', + audioMuted: false, + audioShuffle: false, + audioRepeat: Repeat.NONE, + defaultView: 'library', + librarySort: { + by: SortBy.TITLE, + order: SortOrder.ASC, + }, + // musicFolders: [], + sleepBlocker: false, + autoUpdateChecker: true, + minimizeToTray: false, + displayNotifications: true, + bounds: { + width: 1000, + height: 600, + x: Math.round(this.workArea.width / 2), + y: Math.round(this.workArea.height / 2), + }, + }; + + return config; + } + + getConfig(): Config { + if (!this.conf) { + throw new Error('Config not loaded'); + } + + return this.conf.get() as Config; // Maybe possible to type TeenyConf with Generics? + } + + get(key: T): Config[T] { + if (!this.conf) { + throw new Error('Config not loaded'); + } + + return this.conf.get(key); + } + + set(key: T, value: Config[T]): void { + if (!this.conf) { + throw new Error('Config not loaded'); + } + + return this.conf.set(key, value); + } + + save(): void { + if (!this.conf) { + throw new Error('Config not loaded'); + } + + return this.conf.save(); + } + + reload(): void { + if (!this.conf) { + throw new Error('Config not loaded'); + } + + this.conf.reload(); + } } export default ConfigModule; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bcf4043cb..49d23cd37 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,22 +1,22 @@ -import os from "os"; -import React, { useCallback, useEffect } from "react"; -import KeyBinding from "react-keybinding-component"; -import { useNavigate } from "react-router"; -import { useDrop } from "react-dnd"; -import { NativeTypes } from "react-dnd-html5-backend"; +import os from 'os'; +import React, { useCallback, useEffect } from 'react'; +import KeyBinding from 'react-keybinding-component'; +import { useNavigate } from 'react-router'; +import { useDrop } from 'react-dnd'; +import { NativeTypes } from 'react-dnd-html5-backend'; -import Header from "./components/Header/Header"; -import Footer from "./components/Footer/Footer"; -import Toasts from "./components/Toasts/Toasts"; +import Header from './components/Header/Header'; +import Footer from './components/Footer/Footer'; +import Toasts from './components/Toasts/Toasts'; -import AppActions from "./store/actions/AppActions"; -import * as LibraryActions from "./store/actions/LibraryActions"; -import * as PlayerActions from "./store/actions/PlayerActions"; +import AppActions from './store/actions/AppActions'; +import * as LibraryActions from './store/actions/LibraryActions'; +import * as PlayerActions from './store/actions/PlayerActions'; -import styles from "./App.module.css"; -import { isCtrlKey } from "./lib/utils-platform"; -import Player from "./lib/player"; -import DropzoneImport from "./components/DropzoneImport/DropzoneImport"; +import styles from './App.module.css'; +import { isCtrlKey } from './lib/utils-platform'; +import Player from './lib/player'; +import DropzoneImport from './components/DropzoneImport/DropzoneImport'; /* |-------------------------------------------------------------------------- @@ -25,81 +25,81 @@ import DropzoneImport from "./components/DropzoneImport/DropzoneImport"; */ type Props = { - children: React.ReactNode; + children: React.ReactNode; }; const Museeks: React.FC = (props) => { - const navigate = useNavigate(); + const navigate = useNavigate(); - // App shortcuts (not using Electron's global shortcuts API to avoid conflicts - // with other applications) - const onKey = useCallback( - async (e: KeyboardEvent) => { - switch (e.key) { - case " ": - e.preventDefault(); - e.stopPropagation(); - PlayerActions.playPause(); - break; - case ",": - if (isCtrlKey(e)) { - e.preventDefault(); - e.stopPropagation(); - navigate("/settings"); - } - break; - case "ArrowLeft": - e.preventDefault(); - e.stopPropagation(); - PlayerActions.jumpTo(Player.getCurrentTime() - 10); - break; - case "ArrowRight": - e.preventDefault(); - e.stopPropagation(); - PlayerActions.jumpTo(Player.getCurrentTime() + 10); - break; - default: - break; - } - }, - [navigate] - ); + // App shortcuts (not using Electron's global shortcuts API to avoid conflicts + // with other applications) + const onKey = useCallback( + async (e: KeyboardEvent) => { + switch (e.key) { + case ' ': + e.preventDefault(); + e.stopPropagation(); + PlayerActions.playPause(); + break; + case ',': + if (isCtrlKey(e)) { + e.preventDefault(); + e.stopPropagation(); + navigate('/settings'); + } + break; + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); + PlayerActions.jumpTo(Player.getCurrentTime() - 10); + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + PlayerActions.jumpTo(Player.getCurrentTime() + 10); + break; + default: + break; + } + }, + [navigate] + ); - useEffect(() => { - AppActions.init(); - }, [navigate]); + useEffect(() => { + AppActions.init(); + }, [navigate]); - // Drop behavior to add tracks to the library from any string - const [{ isOver }, drop] = useDrop(() => { - return { - accept: [NativeTypes.FILE], - drop(item: { files: Array }) { - const files = item.files.map((file) => file.path); + // Drop behavior to add tracks to the library from any string + const [{ isOver }, drop] = useDrop(() => { + return { + accept: [NativeTypes.FILE], + drop(item: { files: Array }) { + const files = item.files.map((file) => file.path); - LibraryActions.add(files) - .then((_importedTracks) => { - // TODO: Import to playlist here - }) - .catch((err) => { - console.warn(err); - }); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }; - }); + LibraryActions.add(files) + .then((_importedTracks) => { + // TODO: Import to playlist here + }) + .catch((err) => { + console.warn(err); + }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }; + }); - return ( -
- -
-
{props.children}
-
- - -
- ); + return ( +
+ +
+
{props.children}
+
+ + +
+ ); }; export default Museeks; diff --git a/src/renderer/Root.tsx b/src/renderer/Root.tsx index 8b330cabb..bc1c5743c 100644 --- a/src/renderer/Root.tsx +++ b/src/renderer/Root.tsx @@ -1,48 +1,48 @@ -import React from "react"; +import React from 'react'; -import * as ViewMessage from "./elements/ViewMessage/ViewMessage"; -import ExternalLink from "./elements/ExternalLink/ExternalLink"; +import * as ViewMessage from './elements/ViewMessage/ViewMessage'; +import ExternalLink from './elements/ExternalLink/ExternalLink'; type Props = { - children: React.ReactNode; + children: React.ReactNode; }; type State = { - hasError: boolean; + hasError: boolean; }; class Root extends React.Component { - constructor(props: Props) { - super(props); - this.state = { hasError: false }; - } - - componentDidCatch(err: Error) { - // RIP - console.error(`Museeks crashed: ${err}`); - this.setState({ hasError: true }); - } - - render() { - if (this.state.hasError) { - return ( - -

- - 💥 - {" "} - Something wrong happened -

- - If it happens again, please{" "} - report an issue - -
- ); - } - - return this.props.children; - } + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(err: Error) { + // RIP + console.error(`Museeks crashed: ${err}`); + this.setState({ hasError: true }); + } + + render() { + if (this.state.hasError) { + return ( + +

+ + 💥 + {' '} + Something wrong happened +

+ + If it happens again, please{' '} + report an issue + +
+ ); + } + + return this.props.children; + } } export default Root; diff --git a/src/renderer/components/Playlists/Playlist.tsx b/src/renderer/components/Playlists/Playlist.tsx index 9ae127178..3e2063d34 100644 --- a/src/renderer/components/Playlists/Playlist.tsx +++ b/src/renderer/components/Playlists/Playlist.tsx @@ -1,105 +1,103 @@ -import React, { useCallback, useEffect } from "react"; -import { useSelector } from "react-redux"; -import { useParams } from "react-router"; -import { Link } from "react-router-dom"; +import React, { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useParams } from 'react-router'; +import { Link } from 'react-router-dom'; -import TracksList from "../TracksList/TracksList"; -import * as ViewMessage from "../../elements/ViewMessage/ViewMessage"; +import TracksList from '../TracksList/TracksList'; +import * as ViewMessage from '../../elements/ViewMessage/ViewMessage'; -import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; -import { filterTracks } from "../../lib/utils-library"; -import { RootState } from "../../store/reducers"; +import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; +import { filterTracks } from '../../lib/utils-library'; +import { RootState } from '../../store/reducers'; const Playlist: React.FC = () => { - const params = useParams(); - const playlistId = params.playlistId; + const params = useParams(); + const playlistId = params.playlistId; - const { tracks, trackPlayingId, playerStatus, playlists, currentPlaylist, library } = useSelector( - (state: RootState) => { - const { library, player, playlists } = state; + const { tracks, trackPlayingId, playerStatus, playlists, currentPlaylist, libraryLayoutSettings } = useSelector( + (state: RootState) => { + const { library, player, playlists } = state; - const { search, tracks } = library; - const filteredTracks = filterTracks(tracks.playlist, search); + const { search, tracks } = library; + const filteredTracks = filterTracks(tracks.playlist, search); - const currentPlaylist = playlists.list.find((p) => p._id === playlistId); + const currentPlaylist = playlists.list.find((p) => p._id === playlistId); - return { - library: library, - playlists: playlists.list, - currentPlaylist, - tracks: filteredTracks, - playerStatus: player.playerStatus, - trackPlayingId: - player.queue.length > 0 && player.queueCursor !== null - ? player.queue[player.queueCursor]._id - : null, - }; - } - ); + return { + libraryLayoutSettings: library.libraryLayoutSettings, + playlists: playlists.list, + currentPlaylist, + tracks: filteredTracks, + playerStatus: player.playerStatus, + trackPlayingId: + player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null, + }; + } + ); - useEffect(() => { - if (playlistId) { - PlaylistsActions.load(playlistId); - } - }, [playlistId]); + useEffect(() => { + if (playlistId) { + PlaylistsActions.load(playlistId); + } + }, [playlistId]); - const onReorder = useCallback( - (playlistId: string, tracksIds: string[], targetTrackId: string, position: "above" | "below") => { - PlaylistsActions.reorderTracks(playlistId, tracksIds, targetTrackId, position); - }, - [] - ); + const onReorder = useCallback( + (playlistId: string, tracksIds: string[], targetTrackId: string, position: 'above' | 'below') => { + PlaylistsActions.reorderTracks(playlistId, tracksIds, targetTrackId, position); + }, + [] + ); - if (currentPlaylist && currentPlaylist.tracks.length === 0) { - return ( - -

Empty playlist

- - You can add tracks from the{" "} - - library view - - -
- ); - } + if (currentPlaylist && currentPlaylist.tracks.length === 0) { + return ( + +

Empty playlist

+ + You can add tracks from the{' '} + + library view + + +
+ ); + } - if (tracks.length === 0) { - return ( - -

Your search returned no results

-
- ); - } + if (tracks.length === 0) { + return ( + +

Your search returned no results

+
+ ); + } - // A bit hacky though - if (currentPlaylist && currentPlaylist.tracks.length === 0) { - return ( - -

Empty playlist

- - You can add tracks from the{" "} - - library view - - -
- ); - } + // A bit hacky though + if (currentPlaylist && currentPlaylist.tracks.length === 0) { + return ( + +

Empty playlist

+ + You can add tracks from the{' '} + + library view + + +
+ ); + } - return ( - - ); + return ( + + ); }; export default Playlist; diff --git a/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx b/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx index acc143174..9050723de 100644 --- a/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx +++ b/src/renderer/components/PlaylistsNav/PlaylistsNav.tsx @@ -1,170 +1,166 @@ /* eslint-disable jsx-a11y/no-autofocus */ -import electron from "electron"; -import { Menu } from "@electron/remote"; -import React from "react"; -import Icon from "react-fontawesome"; +import electron from 'electron'; +import { Menu } from '@electron/remote'; +import React from 'react'; +import Icon from 'react-fontawesome'; -import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; -import PlaylistsNavLink from "../PlaylistsNavLink/PlaylistsNavLink"; -import { PlaylistModel } from "../../../shared/types/museeks"; +import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; +import PlaylistsNavLink from '../PlaylistsNavLink/PlaylistsNavLink'; +import { PlaylistModel } from '../../../shared/types/museeks'; -import styles from "./PlaylistsNav.module.css"; +import styles from './PlaylistsNav.module.css'; interface Props { - playlists: PlaylistModel[]; + playlists: PlaylistModel[]; } interface State { - renamed: string | null; + renamed: string | null; } class PlaylistsNav extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - renamed: null, // the playlist being renamed if there's one - }; - - this.blur = this.blur.bind(this); - this.focus = this.focus.bind(this); - this.keyDown = this.keyDown.bind(this); - this.showContextMenu = this.showContextMenu.bind(this); - this.createPlaylist = this.createPlaylist.bind(this); - } - - showContextMenu(playlistId: string) { - const template: electron.MenuItemConstructorOptions[] = [ - { - label: "Rename", - click: () => { - this.setState({ renamed: playlistId }); - }, - }, - { - label: "Delete", - click: async () => { - await PlaylistsActions.remove(playlistId); - }, - }, - { - type: "separator", - }, - { - label: "Duplicate", - click: async () => { - await PlaylistsActions.duplicate(playlistId); - }, - }, - { - type: "separator", - }, - { - label: "Export", - click: async () => { - await PlaylistsActions.exportToM3u(playlistId); - }, - }, - ]; - - const context = Menu.buildFromTemplate(template); - - context.popup({}); // Let it appear - } - - async createPlaylist() { - // Todo 'new playlist 1', 'new playlist 2' ... - await PlaylistsActions.create("New playlist", [], false, true); - } - - async rename(_id: string, name: string) { - await PlaylistsActions.rename(_id, name); - } - - async keyDown(e: React.KeyboardEvent) { - e.persist(); - - switch (e.nativeEvent.code) { - case "Enter": { - // Enter - if (this.state.renamed && e.currentTarget) { - await this.rename(this.state.renamed, e.currentTarget.value); - this.setState({ renamed: null }); - } - break; - } - case "Escape": { - // Escape - this.setState({ renamed: null }); - break; - } - default: { - break; - } - } - } - - async blur(e: React.FocusEvent) { - if (this.state.renamed) { - await this.rename(this.state.renamed, e.currentTarget.value); - } - - this.setState({ renamed: null }); - } - - focus(e: React.FocusEvent) { - e.currentTarget.select(); - } - - render() { - const { playlists } = this.props; - - // TODO (y.solovyov): extract into separate method that returns items - const nav = playlists.map((elem) => { - let navItemContent; - - if (elem._id === this.state.renamed) { - navItemContent = ( - - ); - } else { - navItemContent = ( - - {elem.name} - - ); - } - - return
{navItemContent}
; - }); - - return ( -
-
-

Playlists

-
- -
-
-
{nav}
-
- ); - } + constructor(props: Props) { + super(props); + + this.state = { + renamed: null, // the playlist being renamed if there's one + }; + + this.blur = this.blur.bind(this); + this.focus = this.focus.bind(this); + this.keyDown = this.keyDown.bind(this); + this.showContextMenu = this.showContextMenu.bind(this); + this.createPlaylist = this.createPlaylist.bind(this); + } + + showContextMenu(playlistId: string) { + const template: electron.MenuItemConstructorOptions[] = [ + { + label: 'Rename', + click: () => { + this.setState({ renamed: playlistId }); + }, + }, + { + label: 'Delete', + click: async () => { + await PlaylistsActions.remove(playlistId); + }, + }, + { + type: 'separator', + }, + { + label: 'Duplicate', + click: async () => { + await PlaylistsActions.duplicate(playlistId); + }, + }, + { + type: 'separator', + }, + { + label: 'Export', + click: async () => { + await PlaylistsActions.exportToM3u(playlistId); + }, + }, + ]; + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + } + + async createPlaylist() { + // Todo 'new playlist 1', 'new playlist 2' ... + await PlaylistsActions.create('New playlist', [], false, true); + } + + async rename(_id: string, name: string) { + await PlaylistsActions.rename(_id, name); + } + + async keyDown(e: React.KeyboardEvent) { + e.persist(); + + switch (e.nativeEvent.code) { + case 'Enter': { + // Enter + if (this.state.renamed && e.currentTarget) { + await this.rename(this.state.renamed, e.currentTarget.value); + this.setState({ renamed: null }); + } + break; + } + case 'Escape': { + // Escape + this.setState({ renamed: null }); + break; + } + default: { + break; + } + } + } + + async blur(e: React.FocusEvent) { + if (this.state.renamed) { + await this.rename(this.state.renamed, e.currentTarget.value); + } + + this.setState({ renamed: null }); + } + + focus(e: React.FocusEvent) { + e.currentTarget.select(); + } + + render() { + const { playlists } = this.props; + + // TODO (y.solovyov): extract into separate method that returns items + const nav = playlists.map((elem) => { + let navItemContent; + + if (elem._id === this.state.renamed) { + navItemContent = ( + + ); + } else { + navItemContent = ( + + {elem.name} + + ); + } + + return
{navItemContent}
; + }); + + return ( +
+
+

Playlists

+
+ +
+
+
{nav}
+
+ ); + } } export default PlaylistsNav; diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 11a2960ca..8e93e6d9d 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -1,179 +1,179 @@ -import React from "react"; -import cx from "classnames"; +import React from 'react'; +import cx from 'classnames'; -import PlayingIndicator from "../PlayingIndicator/PlayingIndicator"; -import { parseDuration } from "../../lib/utils"; -import { TrackModel } from "../../../shared/types/museeks"; +import PlayingIndicator from '../PlayingIndicator/PlayingIndicator'; +import { parseDuration } from '../../lib/utils'; +import { TrackModel } from '../../../shared/types/museeks'; -import cellStyles from "../TracksListHeader/TracksListHeader.module.css"; -import styles from "./TrackRow.module.css"; -import { LibraryLayoutSettings } from "src/renderer/store/actions/LibraryActions"; +import cellStyles from '../TracksListHeader/TracksListHeader.module.css'; +import styles from './TrackRow.module.css'; +import { LibraryLayoutSettings } from 'src/renderer/store/actions/LibraryActions'; interface Props { - selected: boolean; - track: TrackModel; - index: number; - isPlaying: boolean; - onDoubleClick: (trackId: string) => void; - onMouseDown: (event: React.MouseEvent, trackId: string, index: number) => void; - onContextMenu: (event: React.MouseEvent, index: number) => void; - onClick: (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => void; - - draggable?: boolean; - reordered?: boolean; - onDragStart?: () => void; - onDragOver?: (trackId: string, position: "above" | "below") => void; - onDragEnd?: () => void; - onDrop?: (targetTrackId: string, position: "above" | "below") => void; - - layout: LibraryLayoutSettings; + selected: boolean; + track: TrackModel; + index: number; + isPlaying: boolean; + onDoubleClick: (trackId: string) => void; + onMouseDown: (event: React.MouseEvent, trackId: string, index: number) => void; + onContextMenu: (event: React.MouseEvent, index: number) => void; + onClick: (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => void; + + draggable?: boolean; + reordered?: boolean; + onDragStart?: () => void; + onDragOver?: (trackId: string, position: 'above' | 'below') => void; + onDragEnd?: () => void; + onDrop?: (targetTrackId: string, position: 'above' | 'below') => void; + + layout: LibraryLayoutSettings; } interface State { - reorderOver: boolean; - reorderPosition: "above" | "below" | null; + reorderOver: boolean; + reorderPosition: 'above' | 'below' | null; } export default class TrackRow extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - reorderOver: false, - reorderPosition: null, - }; - } - - onMouseDown = (e: React.MouseEvent) => { - this.props.onMouseDown(e, this.props.track._id, this.props.index); - }; - - onClick = (e: React.MouseEvent | React.KeyboardEvent) => { - this.props.onClick(e, this.props.track._id); - }; - - onContextMenu = (e: React.MouseEvent) => { - this.props.onContextMenu(e, this.props.index); - }; - - onDoubleClick = () => { - this.props.onDoubleClick(this.props.track._id); - }; - - onDragStart = (event: React.DragEvent) => { - const { onDragStart } = this.props; - - if (onDragStart) { - event.dataTransfer.setData("text/plain", this.props.track._id); - event.dataTransfer.dropEffect = "move"; - event.dataTransfer.effectAllowed = "move"; - - onDragStart(); - } - }; - - onDragOver = (event: React.DragEvent) => { - event.preventDefault(); - - const relativePosition = event.nativeEvent.offsetY / event.currentTarget.offsetHeight; - const dragPosition = relativePosition < 0.5 ? "above" : "below"; - - this.setState({ - reorderOver: true, - reorderPosition: dragPosition, - }); - }; - - onDragLeave = (_event: React.DragEvent) => { - this.setState({ - reorderOver: false, - reorderPosition: null, - }); - }; - - onDrop = (_event: React.DragEvent) => { - const { reorderPosition } = this.state; - const { onDrop } = this.props; - - if (reorderPosition && onDrop) { - onDrop(this.props.track._id, reorderPosition); - } - - this.setState({ - reorderOver: false, - reorderPosition: null, - }); - }; - - render() { - const { track, selected, reordered, draggable, layout } = this.props; - const { reorderOver, reorderPosition } = this.state; - - const trackClasses = cx(styles.track, { - [styles.selected]: selected, - [styles.reordered]: reordered, - [styles.isReorderedOver]: reorderOver, - [styles.isAbove]: reorderPosition === "above", - [styles.isBelow]: reorderPosition === "below", - }); - - let sorts: [boolean, React.ReactElement][] = [ - [ - layout.visibility.includes("title"), -
{track.title}
, - ], - [ - layout.visibility.includes("duration"), -
{parseDuration(track.duration)}
, - ], - [ - layout.visibility.includes("artist"), -
{track.artist.sort().join(", ")}
, - ], - [ - layout.visibility.includes("album"), -
{track.album}
, - ], - [ - layout.visibility.includes("genre"), -
{track.genre.join(", ")}
, - ], - ]; - - let rows: React.ReactElement[] = []; - sorts.forEach((element) => { - if (element[0]) rows.push(element[1]); - }); - - return ( -
{ - if (e.key === "Enter") { - this.onClick(e); - } - }} - onContextMenu={this.onContextMenu} - role="option" - aria-selected={selected} - tabIndex={-1} // we do not want trackrows to be focusable by the keyboard - draggable={draggable} - onDragStart={(draggable && this.onDragStart) || undefined} - onDragOver={(draggable && this.onDragOver) || undefined} - onDragLeave={(draggable && this.onDragLeave) || undefined} - onDrop={(draggable && this.onDrop) || undefined} - onDragEnd={(draggable && this.props.onDragEnd) || undefined} - {...(this.props.isPlaying ? { "data-is-playing": true } : {})} - > -
- {this.props.isPlaying ? : null} -
- {/* {this.props.layout.collapse_artist ? "Lol" : undefined} */} - {rows.length !== 0 ? rows : undefined} -
- ); - } + constructor(props: Props) { + super(props); + + this.state = { + reorderOver: false, + reorderPosition: null, + }; + } + + onMouseDown = (e: React.MouseEvent) => { + this.props.onMouseDown(e, this.props.track._id, this.props.index); + }; + + onClick = (e: React.MouseEvent | React.KeyboardEvent) => { + this.props.onClick(e, this.props.track._id); + }; + + onContextMenu = (e: React.MouseEvent) => { + this.props.onContextMenu(e, this.props.index); + }; + + onDoubleClick = () => { + this.props.onDoubleClick(this.props.track._id); + }; + + onDragStart = (event: React.DragEvent) => { + const { onDragStart } = this.props; + + if (onDragStart) { + event.dataTransfer.setData('text/plain', this.props.track._id); + event.dataTransfer.dropEffect = 'move'; + event.dataTransfer.effectAllowed = 'move'; + + onDragStart(); + } + }; + + onDragOver = (event: React.DragEvent) => { + event.preventDefault(); + + const relativePosition = event.nativeEvent.offsetY / event.currentTarget.offsetHeight; + const dragPosition = relativePosition < 0.5 ? 'above' : 'below'; + + this.setState({ + reorderOver: true, + reorderPosition: dragPosition, + }); + }; + + onDragLeave = (_event: React.DragEvent) => { + this.setState({ + reorderOver: false, + reorderPosition: null, + }); + }; + + onDrop = (_event: React.DragEvent) => { + const { reorderPosition } = this.state; + const { onDrop } = this.props; + + if (reorderPosition && onDrop) { + onDrop(this.props.track._id, reorderPosition); + } + + this.setState({ + reorderOver: false, + reorderPosition: null, + }); + }; + + render() { + const { track, selected, reordered, draggable, layout } = this.props; + const { reorderOver, reorderPosition } = this.state; + + const trackClasses = cx(styles.track, { + [styles.selected]: selected, + [styles.reordered]: reordered, + [styles.isReorderedOver]: reorderOver, + [styles.isAbove]: reorderPosition === 'above', + [styles.isBelow]: reorderPosition === 'below', + }); + + let sorts: [boolean, React.ReactElement][] = [ + [ + layout.visibility.includes('title'), +
{track.title}
, + ], + [ + layout.visibility.includes('duration'), +
{parseDuration(track.duration)}
, + ], + [ + layout.visibility.includes('artist'), +
{track.artist.sort().join(', ')}
, + ], + [ + layout.visibility.includes('album'), +
{track.album}
, + ], + [ + layout.visibility.includes('genre'), +
{track.genre.join(', ')}
, + ], + ]; + + let rows: React.ReactElement[] = []; + sorts.forEach((element) => { + if (element[0]) rows.push(element[1]); + }); + + return ( +
{ + if (e.key === 'Enter') { + this.onClick(e); + } + }} + onContextMenu={this.onContextMenu} + role='option' + aria-selected={selected} + tabIndex={-1} // we do not want trackrows to be focusable by the keyboard + draggable={draggable} + onDragStart={(draggable && this.onDragStart) || undefined} + onDragOver={(draggable && this.onDragOver) || undefined} + onDragLeave={(draggable && this.onDragLeave) || undefined} + onDrop={(draggable && this.onDrop) || undefined} + onDragEnd={(draggable && this.props.onDragEnd) || undefined} + {...(this.props.isPlaying ? { 'data-is-playing': true } : {})} + > +
+ {this.props.isPlaying ? : null} +
+ {/* {this.props.layout.collapse_artist ? "Lol" : undefined} */} + {...rows} +
+ ); + } } diff --git a/src/renderer/components/TracksList/TracksList.tsx b/src/renderer/components/TracksList/TracksList.tsx index 1ca7a9960..4b64876ba 100644 --- a/src/renderer/components/TracksList/TracksList.tsx +++ b/src/renderer/components/TracksList/TracksList.tsx @@ -1,29 +1,29 @@ -import electron from "electron"; -import { Menu } from "@electron/remote"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import KeyBinding from "react-keybinding-component"; -import chunk from "lodash-es/chunk"; -import { useSelector } from "react-redux"; - -import { useNavigate } from "react-router"; -import TrackRow from "../TrackRow/TrackRow"; -import CustomScrollbar from "../CustomScrollbar/CustomScrollbar"; -import TracksListHeader from "../TracksListHeader/TracksListHeader"; - -import * as LibraryActions from "../../store/actions/LibraryActions"; -import * as PlaylistsActions from "../../store/actions/PlaylistsActions"; -import * as PlayerActions from "../../store/actions/PlayerActions"; -import * as QueueActions from "../../store/actions/QueueActions"; - -import { isLeftClick, isRightClick } from "../../lib/utils-events"; -import { isCtrlKey, isAltKey } from "../../lib/utils-platform"; -import { PlaylistModel, TrackModel, PlayerStatus } from "../../../shared/types/museeks"; -import { RootState } from "../../store/reducers"; - -import scrollbarStyles from "../CustomScrollbar/CustomScrollbar.module.css"; -import headerStyles from "../Header/Header.module.css"; -import styles from "./TracksList.module.css"; -import library from "src/renderer/store/reducers/library"; +import electron from 'electron'; +import { Menu } from '@electron/remote'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import KeyBinding from 'react-keybinding-component'; +import chunk from 'lodash-es/chunk'; +import { useSelector } from 'react-redux'; + +import { useNavigate } from 'react-router'; +import TrackRow from '../TrackRow/TrackRow'; +import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; +import TracksListHeader from '../TracksListHeader/TracksListHeader'; + +import * as LibraryActions from '../../store/actions/LibraryActions'; +import * as PlaylistsActions from '../../store/actions/PlaylistsActions'; +import * as PlayerActions from '../../store/actions/PlayerActions'; +import * as QueueActions from '../../store/actions/QueueActions'; + +import { isLeftClick, isRightClick } from '../../lib/utils-events'; +import { isCtrlKey, isAltKey } from '../../lib/utils-platform'; +import { PlaylistModel, TrackModel, PlayerStatus } from '../../../shared/types/museeks'; +import { RootState } from '../../store/reducers'; + +import scrollbarStyles from '../CustomScrollbar/CustomScrollbar.module.css'; +import headerStyles from '../Header/Header.module.css'; +import styles from './TracksList.module.css'; +import library from 'src/renderer/store/reducers/library'; const { shell } = electron; @@ -37,522 +37,521 @@ const TILE_HEIGHT = ROW_HEIGHT * CHUNK_LENGTH; // -------------------------------------------------------------------------- interface Props { - type: string; - playerStatus: string; - tracks: TrackModel[]; - trackPlayingId: string | null; - playlists: PlaylistModel[]; - currentPlaylist?: string; - reorderable?: boolean; - layout: LibraryActions.LibraryLayoutSettings; - onReorder?: (playlistId: string, tracksIds: string[], targetTrackId: string, position: "above" | "below") => void; + type: string; + playerStatus: string; + tracks: TrackModel[]; + trackPlayingId: string | null; + playlists: PlaylistModel[]; + currentPlaylist?: string; + reorderable?: boolean; + layout: LibraryActions.LibraryLayoutSettings; + onReorder?: (playlistId: string, tracksIds: string[], targetTrackId: string, position: 'above' | 'below') => void; } const TracksList: React.FC = (props) => { - const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists, layout } = - props; - - const [tilesScrolled, setTilesScrolled] = useState(0); - const [selected, setSelected] = useState([]); - const [reordered, setReordered] = useState([]); - const [renderView, setRenderView] = useState(null); - const navigate = useNavigate(); - - const highlight = useSelector((state) => state.library.highlightPlayingTrack); - - // Highlight playing track and scroll to it - useEffect(() => { - if (highlight === true && trackPlayingId && renderView) { - setSelected([trackPlayingId]); - - const playingTrackIndex = tracks.findIndex((track) => track._id === trackPlayingId); - - if (playingTrackIndex >= 0) { - const nodeOffsetTop = playingTrackIndex * ROW_HEIGHT; - - renderView.scrollTop = nodeOffsetTop; - } - - LibraryActions.highlightPlayingTrack(false); - } - }, [highlight, trackPlayingId, renderView, tracks]); - - // FIXME: find a way to use a real ref for the render view - useEffect(() => { - const element = document.querySelector(`.${scrollbarStyles.renderView}`); - - if (element instanceof HTMLElement) setRenderView(element); - }, []); - - /** - * Helpers - */ - - const startPlayback = useCallback( - async (_id: string) => { - PlayerActions.start(tracks, _id); - }, - [tracks] - ); - - /** - * Keyboard navigations events/helpers - */ - const onEnter = useCallback(async (i: number, tracks: TrackModel[]) => { - if (i !== -1) PlayerActions.start(tracks, tracks[i]._id); - }, []); - - const onControlAll = useCallback( - (i: number, tracks: TrackModel[]) => { - setSelected(tracks.map((track) => track._id)); - const nodeOffsetTop = (i - 1) * ROW_HEIGHT; - - if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; - }, - [renderView] - ); - - const onUp = useCallback( - (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { - if (i - 1 >= 0) { - // Issue #489, shift key modifier - let newSelected = selected; - - if (shiftKeyPressed) newSelected = [tracks[i - 1]._id, ...selected]; - else newSelected = [tracks[i - 1]._id]; - - setSelected(newSelected); - const nodeOffsetTop = (i - 1) * ROW_HEIGHT; - if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; - } - }, - [renderView, selected] - ); - - const onDown = useCallback( - (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { - if (i + 1 < tracks.length) { - // Issue #489, shift key modifier - let newSelected = selected; - if (shiftKeyPressed) newSelected.push(tracks[i + 1]._id); - else newSelected = [tracks[i + 1]._id]; - - setSelected(newSelected); - const nodeOffsetTop = (i + 1) * ROW_HEIGHT; - - if (renderView && renderView.scrollTop + renderView.offsetHeight <= nodeOffsetTop + ROW_HEIGHT) { - renderView.scrollTop = nodeOffsetTop - renderView.offsetHeight + ROW_HEIGHT; - } - } - }, - [renderView, selected] - ); - - const onKey = useCallback( - async (e: KeyboardEvent) => { - let firstSelectedTrackId = tracks.findIndex((track) => selected.includes(track._id)); - - switch (e.code) { - case "KeyA": - if (isCtrlKey(e)) { - onControlAll(firstSelectedTrackId, tracks); - e.preventDefault(); - } - break; - - case "ArrowUp": - e.preventDefault(); - onUp(firstSelectedTrackId, tracks, e.shiftKey); - break; - - case "ArrowDown": - // This effectively becomes lastSelectedTrackID - firstSelectedTrackId = tracks.findIndex((track) => selected[selected.length - 1] === track._id); - e.preventDefault(); - onDown(firstSelectedTrackId, tracks, e.shiftKey); - break; - - case "Enter": - e.preventDefault(); - await onEnter(firstSelectedTrackId, tracks); - break; - - default: - break; - } - }, - [onControlAll, onDown, onUp, onEnter, selected, tracks] - ); - - /** - * Playlists re-order events handlers - */ - const onReorderStart = useCallback(() => setReordered(selected), [selected]); - const onReorderEnd = useCallback(() => setReordered(null), []); - - const onDrop = useCallback( - async (targetTrackId: string, position: "above" | "below") => { - if (onReorder && currentPlaylist && reordered) { - onReorder(currentPlaylist, reordered, targetTrackId, position); - } - }, - [currentPlaylist, onReorder, reordered] - ); - - /** - * Tracks selection - */ - const isSelectableTrack = useCallback((id: string) => !selected.includes(id), [selected]); - - const sortSelected = useCallback( - (a: string, b: string): number => { - const allTracksIds = tracks.map((track) => track._id); - - return allTracksIds.indexOf(a) - allTracksIds.indexOf(b); - }, - [tracks] - ); - - const toggleSelectionById = useCallback( - (id: string) => { - let newSelected = [...selected]; - - if (newSelected.includes(id)) { - // remove track - newSelected.splice(newSelected.indexOf(id), 1); - } else { - // add track - newSelected.push(id); - } - - newSelected = newSelected.sort(sortSelected); - setSelected(newSelected); - }, - [selected, sortSelected] - ); - - const multiSelect = useCallback( - (index: number) => { - const selectedInt = []; - - // Prefer destructuring - for (let i = 0; i < tracks.length; i++) { - if (selected.includes(tracks[i]._id)) { - selectedInt.push(i); - } - } - - let base; - const min = Math.min(...selectedInt); - const max = Math.max(...selectedInt); - - if (index < min) { - base = max; - } else { - base = min; - } - - const newSelected = []; - - if (index < min) { - for (let i = 0; i <= Math.abs(index - base); i++) { - newSelected.push(tracks[base - i]._id); - } - } else if (index > max) { - for (let i = 0; i <= Math.abs(index - base); i++) { - newSelected.push(tracks[base + i]._id); - } - } - - setSelected(newSelected.sort(sortSelected)); - }, - [selected, sortSelected, tracks] - ); - - const selectTrack = useCallback( - (event: React.MouseEvent, trackId: string, index: number) => { - // To allow selection drag-and-drop, we need to prevent track selection - // when selection a track that is already selected - if (selected.includes(trackId) && !event.metaKey && !event.ctrlKey && !event.shiftKey) { - return; - } - - if (isLeftClick(event) || (isRightClick(event) && isSelectableTrack(trackId))) { - if (isCtrlKey(event)) { - toggleSelectionById(trackId); - } else if (event.shiftKey) { - if (selected.length === 0) { - const newSelected = [trackId]; - setSelected(newSelected); - } else { - multiSelect(index); - } - } else { - if (!isAltKey(event)) { - const newSelected = [trackId]; - setSelected(newSelected); - } - } - } - }, - [selected, multiSelect, toggleSelectionById, isSelectableTrack] - ); - - const selectTrackClick = useCallback( - (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => { - if (!event.metaKey && !event.ctrlKey && !event.shiftKey && selected.includes(trackId)) { - setSelected([trackId]); - } - }, - [selected] - ); - - /** - * Context menus - */ - const showContextMenu = useCallback( - (_e: React.MouseEvent, index: number) => { - const selectedCount = selected.length; - const track = tracks[index]; - let shownPlaylists = playlists; - - // Hide current playlist if needed - if (type === "playlist") { - shownPlaylists = playlists.filter((elem) => elem._id !== currentPlaylist); - } - - const playlistTemplate: electron.MenuItemConstructorOptions[] = []; - let addToQueueTemplate: electron.MenuItemConstructorOptions[] = []; - - if (shownPlaylists) { - playlistTemplate.push( - { - label: "Create new playlist...", - click: async () => { - await PlaylistsActions.create("New playlist", selected); - }, - }, - { - type: "separator", - } - ); - - if (shownPlaylists.length === 0) { - playlistTemplate.push({ - label: "No playlists", - enabled: false, - }); - } else { - shownPlaylists.forEach((playlist) => { - playlistTemplate.push({ - label: playlist.name, - click: async () => { - await PlaylistsActions.addTracks(playlist._id, selected); - }, - }); - }); - } - } - - if (playerStatus !== PlayerStatus.STOP) { - addToQueueTemplate = [ - { - label: "Add to queue", - click: async () => { - await QueueActions.addAfter(selected); - }, - }, - { - label: "Play next", - click: async () => { - await QueueActions.addNext(selected); - }, - }, - { - type: "separator", - }, - ]; - } - - const template: electron.MenuItemConstructorOptions[] = [ - { - label: selectedCount > 1 ? `${selectedCount} tracks selected` : `${selectedCount} track selected`, - enabled: false, - }, - { - type: "separator", - }, - ...addToQueueTemplate, - { - label: "Add to playlist", - submenu: playlistTemplate, - }, - { - type: "separator", - }, - ]; - - for (const artist of track.artist) { - template.push({ - label: `Search for "${artist}" `, - click: () => { - // HACK - const searchInput: HTMLInputElement | null = document.querySelector( - `input[type="text"].${headerStyles.header__search__input}` - ); - - if (searchInput) { - searchInput.value = track.artist[0]; - searchInput.dispatchEvent(new Event("input", { bubbles: true })); - } - }, - }); - } - - template.push({ - label: `Search for "${track.album}"`, - click: () => { - // HACK - const searchInput: HTMLInputElement | null = document.querySelector( - `input[type="text"].${headerStyles.header__search__input}` - ); - - if (searchInput) { - searchInput.value = track.album; - searchInput.dispatchEvent(new Event("input", { bubbles: true })); - } - }, - }); - - if (type === "playlist" && currentPlaylist) { - template.push( - { - type: "separator", - }, - { - label: "Remove from playlist", - click: async () => { - await PlaylistsActions.removeTracks(currentPlaylist, selected); - }, - } - ); - } - - template.push( - { - type: "separator", - }, - { - label: "Edit track", - click: () => { - navigate(`/details/${track._id}`); - }, - }, - { - type: "separator", - }, - { - label: "Show in file manager", - click: () => { - shell.showItemInFolder(track.path); - }, - }, - { - label: "Remove from library", - click: () => { - LibraryActions.remove(selected); - }, - } - ); - - const context = Menu.buildFromTemplate(template); - - context.popup({}); // Let it appear - }, - [currentPlaylist, playerStatus, playlists, selected, tracks, type, navigate] - ); - - /** - * Tracks list virtualization + rendering - */ - - const onScroll = useCallback(() => { - if (renderView) { - const nextTilesScrolled = Math.floor(renderView.scrollTop / TILE_HEIGHT); - - if (tilesScrolled !== nextTilesScrolled) { - setTilesScrolled(nextTilesScrolled); - } - } - }, [tilesScrolled, renderView]); - - const trackTiles = useMemo(() => { - const tracksChunked = chunk(tracks, CHUNK_LENGTH); - - return tracksChunked.splice(tilesScrolled, TILES_TO_DISPLAY).map((tracksChunk, indexChunk) => { - const list = tracksChunk.map((track, index) => { - const trackRowIndex = (tilesScrolled + indexChunk) * CHUNK_LENGTH + index; - - return ( - - ); - }); - - const translationDistance = - tilesScrolled * ROW_HEIGHT * CHUNK_LENGTH + indexChunk * ROW_HEIGHT * CHUNK_LENGTH; - const tracksListTileStyles = { - transform: `translate3d(0, ${translationDistance}px, 0)`, - }; - - return ( -
- {list} -
- ); - }); - }, [ - reordered, - selected, - tilesScrolled, - reorderable, - trackPlayingId, - tracks, - onDrop, - onReorderStart, - onReorderEnd, - selectTrack, - selectTrackClick, - showContextMenu, - startPlayback, - ]); - - return ( -
- - - -
- {trackTiles} -
-
-
- ); + const { tracks, type, trackPlayingId, reorderable, currentPlaylist, onReorder, playerStatus, playlists, layout } = + props; + + const [tilesScrolled, setTilesScrolled] = useState(0); + const [selected, setSelected] = useState([]); + const [reordered, setReordered] = useState([]); + const [renderView, setRenderView] = useState(null); + const navigate = useNavigate(); + + const highlight = useSelector((state) => state.library.highlightPlayingTrack); + + // Highlight playing track and scroll to it + useEffect(() => { + if (highlight === true && trackPlayingId && renderView) { + setSelected([trackPlayingId]); + + const playingTrackIndex = tracks.findIndex((track) => track._id === trackPlayingId); + + if (playingTrackIndex >= 0) { + const nodeOffsetTop = playingTrackIndex * ROW_HEIGHT; + + renderView.scrollTop = nodeOffsetTop; + } + + LibraryActions.highlightPlayingTrack(false); + } + }, [highlight, trackPlayingId, renderView, tracks]); + + // FIXME: find a way to use a real ref for the render view + useEffect(() => { + const element = document.querySelector(`.${scrollbarStyles.renderView}`); + + if (element instanceof HTMLElement) setRenderView(element); + }, []); + + /** + * Helpers + */ + + const startPlayback = useCallback( + async (_id: string) => { + PlayerActions.start(tracks, _id); + }, + [tracks] + ); + + /** + * Keyboard navigations events/helpers + */ + const onEnter = useCallback(async (i: number, tracks: TrackModel[]) => { + if (i !== -1) PlayerActions.start(tracks, tracks[i]._id); + }, []); + + const onControlAll = useCallback( + (i: number, tracks: TrackModel[]) => { + setSelected(tracks.map((track) => track._id)); + const nodeOffsetTop = (i - 1) * ROW_HEIGHT; + + if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; + }, + [renderView] + ); + + const onUp = useCallback( + (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { + if (i - 1 >= 0) { + // Issue #489, shift key modifier + let newSelected = selected; + + if (shiftKeyPressed) newSelected = [tracks[i - 1]._id, ...selected]; + else newSelected = [tracks[i - 1]._id]; + + setSelected(newSelected); + const nodeOffsetTop = (i - 1) * ROW_HEIGHT; + if (renderView && renderView.scrollTop > nodeOffsetTop) renderView.scrollTop = nodeOffsetTop; + } + }, + [renderView, selected] + ); + + const onDown = useCallback( + (i: number, tracks: TrackModel[], shiftKeyPressed: boolean) => { + if (i + 1 < tracks.length) { + // Issue #489, shift key modifier + let newSelected = selected; + if (shiftKeyPressed) newSelected.push(tracks[i + 1]._id); + else newSelected = [tracks[i + 1]._id]; + + setSelected(newSelected); + const nodeOffsetTop = (i + 1) * ROW_HEIGHT; + + if (renderView && renderView.scrollTop + renderView.offsetHeight <= nodeOffsetTop + ROW_HEIGHT) { + renderView.scrollTop = nodeOffsetTop - renderView.offsetHeight + ROW_HEIGHT; + } + } + }, + [renderView, selected] + ); + + const onKey = useCallback( + async (e: KeyboardEvent) => { + let firstSelectedTrackId = tracks.findIndex((track) => selected.includes(track._id)); + + switch (e.code) { + case 'KeyA': + if (isCtrlKey(e)) { + onControlAll(firstSelectedTrackId, tracks); + e.preventDefault(); + } + break; + + case 'ArrowUp': + e.preventDefault(); + onUp(firstSelectedTrackId, tracks, e.shiftKey); + break; + + case 'ArrowDown': + // This effectively becomes lastSelectedTrackID + firstSelectedTrackId = tracks.findIndex((track) => selected[selected.length - 1] === track._id); + e.preventDefault(); + onDown(firstSelectedTrackId, tracks, e.shiftKey); + break; + + case 'Enter': + e.preventDefault(); + await onEnter(firstSelectedTrackId, tracks); + break; + + default: + break; + } + }, + [onControlAll, onDown, onUp, onEnter, selected, tracks] + ); + + /** + * Playlists re-order events handlers + */ + const onReorderStart = useCallback(() => setReordered(selected), [selected]); + const onReorderEnd = useCallback(() => setReordered(null), []); + + const onDrop = useCallback( + async (targetTrackId: string, position: 'above' | 'below') => { + if (onReorder && currentPlaylist && reordered) { + onReorder(currentPlaylist, reordered, targetTrackId, position); + } + }, + [currentPlaylist, onReorder, reordered] + ); + + /** + * Tracks selection + */ + const isSelectableTrack = useCallback((id: string) => !selected.includes(id), [selected]); + + const sortSelected = useCallback( + (a: string, b: string): number => { + const allTracksIds = tracks.map((track) => track._id); + + return allTracksIds.indexOf(a) - allTracksIds.indexOf(b); + }, + [tracks] + ); + + const toggleSelectionById = useCallback( + (id: string) => { + let newSelected = [...selected]; + + if (newSelected.includes(id)) { + // remove track + newSelected.splice(newSelected.indexOf(id), 1); + } else { + // add track + newSelected.push(id); + } + + newSelected = newSelected.sort(sortSelected); + setSelected(newSelected); + }, + [selected, sortSelected] + ); + + const multiSelect = useCallback( + (index: number) => { + const selectedInt = []; + + // Prefer destructuring + for (let i = 0; i < tracks.length; i++) { + if (selected.includes(tracks[i]._id)) { + selectedInt.push(i); + } + } + + let base; + const min = Math.min(...selectedInt); + const max = Math.max(...selectedInt); + + if (index < min) { + base = max; + } else { + base = min; + } + + const newSelected = []; + + if (index < min) { + for (let i = 0; i <= Math.abs(index - base); i++) { + newSelected.push(tracks[base - i]._id); + } + } else if (index > max) { + for (let i = 0; i <= Math.abs(index - base); i++) { + newSelected.push(tracks[base + i]._id); + } + } + + setSelected(newSelected.sort(sortSelected)); + }, + [selected, sortSelected, tracks] + ); + + const selectTrack = useCallback( + (event: React.MouseEvent, trackId: string, index: number) => { + // To allow selection drag-and-drop, we need to prevent track selection + // when selection a track that is already selected + if (selected.includes(trackId) && !event.metaKey && !event.ctrlKey && !event.shiftKey) { + return; + } + + if (isLeftClick(event) || (isRightClick(event) && isSelectableTrack(trackId))) { + if (isCtrlKey(event)) { + toggleSelectionById(trackId); + } else if (event.shiftKey) { + if (selected.length === 0) { + const newSelected = [trackId]; + setSelected(newSelected); + } else { + multiSelect(index); + } + } else { + if (!isAltKey(event)) { + const newSelected = [trackId]; + setSelected(newSelected); + } + } + } + }, + [selected, multiSelect, toggleSelectionById, isSelectableTrack] + ); + + const selectTrackClick = useCallback( + (event: React.MouseEvent | React.KeyboardEvent, trackId: string) => { + if (!event.metaKey && !event.ctrlKey && !event.shiftKey && selected.includes(trackId)) { + setSelected([trackId]); + } + }, + [selected] + ); + + /** + * Context menus + */ + const showContextMenu = useCallback( + (_e: React.MouseEvent, index: number) => { + const selectedCount = selected.length; + const track = tracks[index]; + let shownPlaylists = playlists; + + // Hide current playlist if needed + if (type === 'playlist') { + shownPlaylists = playlists.filter((elem) => elem._id !== currentPlaylist); + } + + const playlistTemplate: electron.MenuItemConstructorOptions[] = []; + let addToQueueTemplate: electron.MenuItemConstructorOptions[] = []; + + if (shownPlaylists) { + playlistTemplate.push( + { + label: 'Create new playlist...', + click: async () => { + await PlaylistsActions.create('New playlist', selected); + }, + }, + { + type: 'separator', + } + ); + + if (shownPlaylists.length === 0) { + playlistTemplate.push({ + label: 'No playlists', + enabled: false, + }); + } else { + shownPlaylists.forEach((playlist) => { + playlistTemplate.push({ + label: playlist.name, + click: async () => { + await PlaylistsActions.addTracks(playlist._id, selected); + }, + }); + }); + } + } + + if (playerStatus !== PlayerStatus.STOP) { + addToQueueTemplate = [ + { + label: 'Add to queue', + click: async () => { + await QueueActions.addAfter(selected); + }, + }, + { + label: 'Play next', + click: async () => { + await QueueActions.addNext(selected); + }, + }, + { + type: 'separator', + }, + ]; + } + + const template: electron.MenuItemConstructorOptions[] = [ + { + label: selectedCount > 1 ? `${selectedCount} tracks selected` : `${selectedCount} track selected`, + enabled: false, + }, + { + type: 'separator', + }, + ...addToQueueTemplate, + { + label: 'Add to playlist', + submenu: playlistTemplate, + }, + { + type: 'separator', + }, + ]; + + for (const artist of track.artist) { + template.push({ + label: `Search for "${artist}" `, + click: () => { + // HACK + const searchInput: HTMLInputElement | null = document.querySelector( + `input[type="text"].${headerStyles.header__search__input}` + ); + + if (searchInput) { + searchInput.value = track.artist[0]; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, + }); + } + + template.push({ + label: `Search for "${track.album}"`, + click: () => { + // HACK + const searchInput: HTMLInputElement | null = document.querySelector( + `input[type="text"].${headerStyles.header__search__input}` + ); + + if (searchInput) { + searchInput.value = track.album; + searchInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, + }); + + if (type === 'playlist' && currentPlaylist) { + template.push( + { + type: 'separator', + }, + { + label: 'Remove from playlist', + click: async () => { + await PlaylistsActions.removeTracks(currentPlaylist, selected); + }, + } + ); + } + + template.push( + { + type: 'separator', + }, + { + label: 'Edit track', + click: () => { + navigate(`/details/${track._id}`); + }, + }, + { + type: 'separator', + }, + { + label: 'Show in file manager', + click: () => { + shell.showItemInFolder(track.path); + }, + }, + { + label: 'Remove from library', + click: () => { + LibraryActions.remove(selected); + }, + } + ); + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + }, + [currentPlaylist, playerStatus, playlists, selected, tracks, type, navigate] + ); + + /** + * Tracks list virtualization + rendering + */ + + const onScroll = useCallback(() => { + if (renderView) { + const nextTilesScrolled = Math.floor(renderView.scrollTop / TILE_HEIGHT); + + if (tilesScrolled !== nextTilesScrolled) { + setTilesScrolled(nextTilesScrolled); + } + } + }, [tilesScrolled, renderView]); + + const trackTiles = useMemo(() => { + const tracksChunked = chunk(tracks, CHUNK_LENGTH); + + return tracksChunked.splice(tilesScrolled, TILES_TO_DISPLAY).map((tracksChunk, indexChunk) => { + const list = tracksChunk.map((track, index) => { + const trackRowIndex = (tilesScrolled + indexChunk) * CHUNK_LENGTH + index; + + return ( + + ); + }); + + const translationDistance = tilesScrolled * ROW_HEIGHT * CHUNK_LENGTH + indexChunk * ROW_HEIGHT * CHUNK_LENGTH; + const tracksListTileStyles = { + transform: `translate3d(0, ${translationDistance}px, 0)`, + }; + + return ( +
+ {list} +
+ ); + }); + }, [ + reordered, + selected, + tilesScrolled, + reorderable, + trackPlayingId, + tracks, + onDrop, + onReorderStart, + onReorderEnd, + selectTrack, + selectTrackClick, + showContextMenu, + startPlayback, + ]); + + return ( +
+ + + +
+ {trackTiles} +
+
+
+ ); }; export default TracksList; diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.module.css b/src/renderer/components/TracksListHeader/TracksListHeader.module.css index a0e09cd77..f3709b542 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.module.css +++ b/src/renderer/components/TracksListHeader/TracksListHeader.module.css @@ -1,22 +1,22 @@ .tracksListHeader { - border-bottom: 1px solid var(--border-color); - color: var(--tracks-header-color); - background-color: var(--tracks-header-bg); - display: flex; - width: 100%; + border-bottom: 1px solid var(--border-color); + color: var(--tracks-header-color); + background-color: var(--tracks-header-bg); + display: flex; + width: 100%; } .cellTrackPlaying { - width: 30px; - text-align: center; + width: 30px; + text-align: center; } .cellTrack { - flex: 1; + flex: 1; } .cellDuration { - width: 7%; + width: 7%; } .cellArtist, @@ -24,10 +24,10 @@ .cellTitle, .cellSection, .cellGenre { - width: 20%; + width: 20%; } .cellSection { - width: 20%; - height: 45px; + width: 20%; + height: 45px; } diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 573136358..c50976adb 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -1,181 +1,178 @@ -import React, { useCallback } from "react"; -import { connect } from "react-redux"; +import React, { useCallback } from 'react'; +import { connect } from 'react-redux'; -import TracksListHeaderCell from "../TracksListHeaderCell/TracksListHeaderCell"; +import TracksListHeaderCell from '../TracksListHeaderCell/TracksListHeaderCell'; -import { PlayerStatus, SortBy, SortOrder } from "../../../shared/types/museeks"; -import { RootState } from "../../store/reducers"; -import { LibrarySort } from "../../store/reducers/library"; +import { PlayerStatus, SortBy, SortOrder } from '../../../shared/types/museeks'; +import { RootState } from '../../store/reducers'; +import { LibrarySort } from '../../store/reducers/library'; -import { Menu } from "@electron/remote"; -import styles from "./TracksListHeader.module.css"; -import electron from "electron"; -import { LibraryLayoutSettings, set_context_state } from "src/renderer/store/actions/LibraryActions"; +import { Menu } from '@electron/remote'; +import styles from './TracksListHeader.module.css'; +import electron from 'electron'; +import { LibraryLayoutSettings, set_context_state } from 'src/renderer/store/actions/LibraryActions'; interface OwnProps { - enableSort: boolean; - layout: LibraryLayoutSettings; + enableSort: boolean; + layout: LibraryLayoutSettings; } interface InjectedProps { - sort?: LibrarySort; + sort?: LibrarySort; } type Props = OwnProps & InjectedProps; -const LAYOUT_LISTS = ["title", "duration", "artist", "genre"]; +const LAYOUT_LISTS = ['title', 'duration', 'artist', 'genre']; const capitalize = (str: string) => { - return str.toUpperCase()[0] + str.substring(1); + return str.toUpperCase()[0] + str.substring(1); }; class TracksListHeader extends React.Component { - static getIcon = (sort: LibrarySort | undefined, sortType: SortBy) => { - if (sort && sort.by === sortType) { - if (sort.order === SortOrder.ASC) { - return "angle-up"; - } - - // Must be DSC then - return "angle-down"; - } - - return null; - }; - - // questionable? - constructor(props: Props, state: LibraryLayoutSettings) { - super(props, state); - - console.log(props); - } - - showContextMenu(_e: React.MouseEvent, id: string) { - const template: electron.MenuItemConstructorOptions[] = [ - { - label: `Selected: ${capitalize(id)}`, - enabled: false, - }, - ]; - - LAYOUT_LISTS.forEach((tag) => { - template.push({ - type: "checkbox", - label: capitalize(tag), - checked: this.props.layout.visibility.includes(tag), - click: () => { - // A very confusing toggle mechanism - const visibility = this.props.layout.visibility; - console.log(visibility); - if (visibility.includes(tag)) { - set_context_state({ - visibility: visibility.filter((value) => value !== tag), - }); - } else { - set_context_state({ - visibility: [...visibility, tag], - }); - } - }, - }); - }); - - const context = Menu.buildFromTemplate(template); - - context.popup({}); // Let it appear - } - - render() { - const { enableSort, sort, layout } = this.props; - - let sorts: [boolean, React.ReactElement][] = [ - [ - layout.visibility.includes("title"), - this.showContextMenu(e, "title")} - className={styles.cellTrack} - title="Title" - sortBy={enableSort ? SortBy.TITLE : null} - icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} - layout={this.props.layout} - key="Title" - />, - ], - [ - layout.visibility.includes("duration"), - this.showContextMenu(e, "duration")} - className={styles.cellDuration} - title="Duration" - sortBy={enableSort ? SortBy.DURATION : null} - icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} - layout={this.props.layout} - key="Duration" - />, - ], - [ - layout.visibility.includes("artist"), - this.showContextMenu(e, "artist")} - className={styles.cellArtist} - title="Artist" - sortBy={enableSort ? SortBy.ARTIST : null} - icon={TracksListHeader.getIcon(sort, SortBy.ARTIST)} - layout={this.props.layout} - key="Artist" - />, - ], - [ - layout.visibility.includes("album"), - this.showContextMenu(e, "album")} - className={styles.cellAlbum} - title="Album" - sortBy={enableSort ? SortBy.ALBUM : null} - icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} - layout={this.props.layout} - key="Album" - />, - ], - [ - layout.visibility.includes("genre"), - this.showContextMenu(e, "genre")} - className={styles.cellGenre} - title="Genre" - sortBy={enableSort ? SortBy.GENRE : null} - icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} - layout={this.props.layout} - key="Genre" - />, - ], - ]; - - let headers: React.ReactElement[] = []; - sorts.forEach((element) => { - if (element[0]) headers.push(element[1]); - }); - - return ( -
this.showContextMenu(e, "background") : undefined} - > - {" "} - - {...headers} -
- ); - } + static getIcon = (sort: LibrarySort | undefined, sortType: SortBy) => { + if (sort && sort.by === sortType) { + if (sort.order === SortOrder.ASC) { + return 'angle-up'; + } + + // Must be DSC then + return 'angle-down'; + } + + return null; + }; + + // questionable? + constructor(props: Props, state: LibraryLayoutSettings) { + super(props, state); + } + + showContextMenu(_e: React.MouseEvent, id: string) { + const template: electron.MenuItemConstructorOptions[] = [ + { + label: `Selected: ${capitalize(id)}`, + enabled: false, + }, + ]; + + LAYOUT_LISTS.forEach((tag) => { + template.push({ + type: 'checkbox', + label: capitalize(tag), + checked: this.props.layout.visibility.includes(tag), + click: () => { + // toggles the existence of props.layout.visibility[tag] + const visibility = this.props.layout.visibility; + if (visibility.includes(tag)) { + set_context_state({ + visibility: visibility.filter((value) => value !== tag), + }); + } else { + set_context_state({ + visibility: [...visibility, tag], + }); + } + }, + }); + }); + + const context = Menu.buildFromTemplate(template); + + context.popup({}); // Let it appear + } + + render() { + const { enableSort, sort, layout } = this.props; + + let sorts: [boolean, React.ReactElement][] = [ + [ + layout.visibility.includes('title'), + this.showContextMenu(e, 'title')} + className={styles.cellTrack} + title='Title' + sortBy={enableSort ? SortBy.TITLE : null} + icon={TracksListHeader.getIcon(sort, SortBy.TITLE)} + layout={this.props.layout} + key='Title' + />, + ], + [ + layout.visibility.includes('duration'), + this.showContextMenu(e, 'duration')} + className={styles.cellDuration} + title='Duration' + sortBy={enableSort ? SortBy.DURATION : null} + icon={TracksListHeader.getIcon(sort, SortBy.DURATION)} + layout={this.props.layout} + key='Duration' + />, + ], + [ + layout.visibility.includes('artist'), + this.showContextMenu(e, 'artist')} + className={styles.cellArtist} + title='Artist' + sortBy={enableSort ? SortBy.ARTIST : null} + icon={TracksListHeader.getIcon(sort, SortBy.ARTIST)} + layout={this.props.layout} + key='Artist' + />, + ], + [ + layout.visibility.includes('album'), + this.showContextMenu(e, 'album')} + className={styles.cellAlbum} + title='Album' + sortBy={enableSort ? SortBy.ALBUM : null} + icon={TracksListHeader.getIcon(sort, SortBy.ALBUM)} + layout={this.props.layout} + key='Album' + />, + ], + [ + layout.visibility.includes('genre'), + this.showContextMenu(e, 'genre')} + className={styles.cellGenre} + title='Genre' + sortBy={enableSort ? SortBy.GENRE : null} + icon={TracksListHeader.getIcon(sort, SortBy.GENRE)} + layout={this.props.layout} + key='Genre' + />, + ], + ]; + + let headers: React.ReactElement[] = []; + sorts.forEach((element) => { + if (element[0]) headers.push(element[1]); + }); + + return ( +
this.showContextMenu(e, 'background') : undefined} + > + {' '} + + {...headers} +
+ ); + } } const mapStateToProps = (state: RootState, ownProps: OwnProps): InjectedProps => { - if (ownProps.enableSort) { - return { - sort: state.library.sort, - }; - } + if (ownProps.enableSort) { + return { + sort: state.library.sort, + }; + } - return {}; + return {}; }; export default connect(mapStateToProps)(TracksListHeader); diff --git a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx index ac2d71a5a..5a83aea9a 100644 --- a/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx +++ b/src/renderer/components/TracksListHeaderCell/TracksListHeaderCell.tsx @@ -1,71 +1,71 @@ -import React from "react"; -import cx from "classnames"; -import Icon from "react-fontawesome"; +import React from 'react'; +import cx from 'classnames'; +import Icon from 'react-fontawesome'; -import * as LibraryActions from "../../store/actions/LibraryActions"; -import { SortBy } from "../../../shared/types/museeks"; +import * as LibraryActions from '../../store/actions/LibraryActions'; +import { SortBy } from '../../../shared/types/museeks'; -import styles from "./TracksListHeaderCell.module.css"; +import styles from './TracksListHeaderCell.module.css'; interface Props { - title: string; - className?: string; - sortBy?: SortBy | null; - icon?: string | null; - layout: LibraryActions.LibraryLayoutSettings; - onContextMenu?: (event: React.MouseEvent) => void; + title: string; + className?: string; + sortBy?: SortBy | null; + icon?: string | null; + layout: LibraryActions.LibraryLayoutSettings; + onContextMenu?: (event: React.MouseEvent) => void; } class TracksListHeaderCell extends React.Component { - static defaultProps = { - className: "", - sortBy: null, - icon: null, - }; + static defaultProps = { + className: '', + sortBy: null, + icon: null, + }; - constructor(props: Props) { - super(props); - this.sort = this.sort.bind(this); - } + constructor(props: Props) { + super(props); + this.sort = this.sort.bind(this); + } - sort() { - if (this.props.sortBy) { - LibraryActions.sort(this.props.sortBy); - } - } + sort() { + if (this.props.sortBy) { + LibraryActions.sort(this.props.sortBy); + } + } - render() { - const { sortBy, className, title, icon } = this.props; + render() { + const { sortBy, className, title, icon } = this.props; - const classes = cx(styles.trackCellHeader, className, { - [styles.sort]: sortBy, - }); + const classes = cx(styles.trackCellHeader, className, { + [styles.sort]: sortBy, + }); - const content = ( - -
{title}
- {icon && ( -
- -
- )} -
- ); + const content = ( + +
{title}
+ {icon && ( +
+ +
+ )} +
+ ); - if (sortBy) { - return ( - - ); - } + if (sortBy) { + return ( + + ); + } - return ( -
- {content} -
- ); - } + return ( +
+ {content} +
+ ); + } } export default TracksListHeaderCell; diff --git a/src/renderer/constants/sort-orders.ts b/src/renderer/constants/sort-orders.ts index 8172a6a08..a16c0c72e 100644 --- a/src/renderer/constants/sort-orders.ts +++ b/src/renderer/constants/sort-orders.ts @@ -1,4 +1,4 @@ -import { Track, SortOrder, SortBy } from "../../shared/types/museeks"; +import { Track, SortOrder, SortBy } from '../../shared/types/museeks'; // For perforances reasons, otherwise _.orderBy will perform weird check // the is far more resource/time impactful @@ -7,41 +7,33 @@ const parseGenre = (t: Track): string => t.loweredMetas.genre.toString(); // Declarations const sortOrders = { - [SortBy.ARTIST]: { - [SortOrder.ASC]: [ - // Default - [parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], - null, - ], - [SortOrder.DSC]: [[parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], - }, - // FIXME - [SortBy.ADDED]: { - [SortOrder.ASC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], - [SortOrder.DSC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], - }, - [SortBy.TITLE]: { - [SortOrder.ASC]: [ - ["loweredMetas.title", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], - null, - ], - [SortOrder.DSC]: [ - ["loweredMetas.title", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], - ["desc"], - ], - }, - [SortBy.DURATION]: { - [SortOrder.ASC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], - [SortOrder.DSC]: [["duration", parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], - }, - [SortBy.ALBUM]: { - [SortOrder.ASC]: [["loweredMetas.album", parseArtist, "year", "disk.no", "track.no"], null], - [SortOrder.DSC]: [["loweredMetas.album", parseArtist, "year", "disk.no", "track.no"], ["desc"]], - }, - [SortBy.GENRE]: { - [SortOrder.ASC]: [[parseGenre, parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], null], - [SortOrder.DSC]: [[parseGenre, parseArtist, "year", "loweredMetas.album", "disk.no", "track.no"], ["desc"]], - }, + [SortBy.ARTIST]: { + [SortOrder.ASC]: [ + // Default + [parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], + null, + ], + [SortOrder.DSC]: [[parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], + }, + [SortBy.TITLE]: { + [SortOrder.ASC]: [['loweredMetas.title', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], + [SortOrder.DSC]: [ + ['loweredMetas.title', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], + ['desc'], + ], + }, + [SortBy.DURATION]: { + [SortOrder.ASC]: [['duration', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], + [SortOrder.DSC]: [['duration', parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], + }, + [SortBy.ALBUM]: { + [SortOrder.ASC]: [['loweredMetas.album', parseArtist, 'year', 'disk.no', 'track.no'], null], + [SortOrder.DSC]: [['loweredMetas.album', parseArtist, 'year', 'disk.no', 'track.no'], ['desc']], + }, + [SortBy.GENRE]: { + [SortOrder.ASC]: [[parseGenre, parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], null], + [SortOrder.DSC]: [[parseGenre, parseArtist, 'year', 'loweredMetas.album', 'disk.no', 'track.no'], ['desc']], + }, }; export default sortOrders; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 8e8d460c1..9a057be7b 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -4,15 +4,15 @@ |-------------------------------------------------------------------------- */ -import React from "react"; -import * as ReactDOM from "react-dom/client"; -import { Provider } from "react-redux"; -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; +import React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; -import Root from "./Root"; -import Router from "./Router"; -import store from "./store/store"; +import Root from './Root'; +import Router from './Router'; +import store from './store/store'; /* |-------------------------------------------------------------------------- @@ -20,10 +20,10 @@ import store from "./store/store"; |-------------------------------------------------------------------------- */ -import "../../node_modules/normalize.css/normalize.css"; -import "../../node_modules/font-awesome/css/font-awesome.css"; -import "../../node_modules/react-rangeslider/lib/index.css"; -import "./styles/main.module.css"; +import '../../node_modules/normalize.css/normalize.css'; +import '../../node_modules/font-awesome/css/font-awesome.css'; +import '../../node_modules/react-rangeslider/lib/index.css'; +import './styles/main.module.css'; /* |-------------------------------------------------------------------------- @@ -31,19 +31,19 @@ import "./styles/main.module.css"; |-------------------------------------------------------------------------- */ -const wrap = document.getElementById("wrap"); +const wrap = document.getElementById('wrap'); if (wrap) { - const root = ReactDOM.createRoot(wrap); - root.render( - - - - - - - - - - ); + const root = ReactDOM.createRoot(wrap); + root.render( + + + + + + + + + + ); } diff --git a/src/renderer/store/action-types.ts b/src/renderer/store/action-types.ts index 93bf4a5fe..5ae36497f 100644 --- a/src/renderer/store/action-types.ts +++ b/src/renderer/store/action-types.ts @@ -1,47 +1,47 @@ enum ActionTypes { - LIBRARY_REFRESH = "LIBRARY_REFRESH", - REFRESH_CONFIG = "REFRESH_CONFIG", - - FILTER_SEARCH = "FILTER_SEARCH", - - PLAYER_START = "PLAYER_START", - PLAYER_TOGGLE = "PLAYER_TOGGLE", - PLAYER_PLAY = "PLAYER_PLAY", - PLAYER_PAUSE = "PLAYER_PAUSE", - PLAYER_STOP = "PLAYER_STOP", - PLAYER_NEXT = "PLAYER_NEXT", - PLAYER_PREVIOUS = "PLAYER_PREVIOUS", - PLAYER_JUMP_TO = "PLAYER_JUMP_TO", - - PLAYER_SHUFFLE = "PLAYER_SHUFFLE", - PLAYER_REPEAT = "PLAYER_REPEAT", - - QUEUE_START = "QUEUE_START", - QUEUE_CLEAR = "QUEUE_CLEAR", - QUEUE_REMOVE = "QUEUE_REMOVE", - QUEUE_ADD = "QUEUE_ADD", - QUEUE_ADD_NEXT = "QUEUE_ADD_NEXT", - QUEUE_SET_QUEUE = "QUEUE_SET_QUEUE", - - LIBRARY_SORT = "LIBRARY_SORT", - LIBRARY_RESET = "LIBRARY_RESET", - LIBRARY_REFRESH_START = "LIBRARY_REFRESH_START", - LIBRARY_REFRESH_END = "LIBRARY_REFRESH_END", - LIBRARY_REFRESH_PROGRESS = "LIBRARY_REFRESH_PROGRESS", - LIBRARY_ADD_TRACKS = "LIBRARY_ADD_TRACKS", - LIBRARY_REMOVE_TRACKS = "LIBRARY_REMOVE_TRACKS", - LIBRARY_HIGHLIGHT_PLAYING_TRACK = "LIBRARY_HIGHLIGHT_PLAYING_TRACK", - - PLAYLISTS_REFRESH = "PLAYLISTS_REFRESH", - PLAYLISTS_LOAD_ONE = "PLAYLISTS_LOAD_ONE", - PLAYLIST_REORDER_TRACKS = "PLAYLIST_REORDER_TRACKS", - - TOAST_ADD = "TOAST_ADD", - TOAST_REMOVE = "TOAST_REMOVE", - - SET_LIBRARY_LAYOUT_STATE = "SET_LIBRARY_LAYOUT_STATE", - - NOTIFICATION_NEW = "NOTIFICATION_NEW", + LIBRARY_REFRESH = 'LIBRARY_REFRESH', + REFRESH_CONFIG = 'REFRESH_CONFIG', + + FILTER_SEARCH = 'FILTER_SEARCH', + + PLAYER_START = 'PLAYER_START', + PLAYER_TOGGLE = 'PLAYER_TOGGLE', + PLAYER_PLAY = 'PLAYER_PLAY', + PLAYER_PAUSE = 'PLAYER_PAUSE', + PLAYER_STOP = 'PLAYER_STOP', + PLAYER_NEXT = 'PLAYER_NEXT', + PLAYER_PREVIOUS = 'PLAYER_PREVIOUS', + PLAYER_JUMP_TO = 'PLAYER_JUMP_TO', + + PLAYER_SHUFFLE = 'PLAYER_SHUFFLE', + PLAYER_REPEAT = 'PLAYER_REPEAT', + + QUEUE_START = 'QUEUE_START', + QUEUE_CLEAR = 'QUEUE_CLEAR', + QUEUE_REMOVE = 'QUEUE_REMOVE', + QUEUE_ADD = 'QUEUE_ADD', + QUEUE_ADD_NEXT = 'QUEUE_ADD_NEXT', + QUEUE_SET_QUEUE = 'QUEUE_SET_QUEUE', + + LIBRARY_SORT = 'LIBRARY_SORT', + LIBRARY_RESET = 'LIBRARY_RESET', + LIBRARY_REFRESH_START = 'LIBRARY_REFRESH_START', + LIBRARY_REFRESH_END = 'LIBRARY_REFRESH_END', + LIBRARY_REFRESH_PROGRESS = 'LIBRARY_REFRESH_PROGRESS', + LIBRARY_ADD_TRACKS = 'LIBRARY_ADD_TRACKS', + LIBRARY_REMOVE_TRACKS = 'LIBRARY_REMOVE_TRACKS', + LIBRARY_HIGHLIGHT_PLAYING_TRACK = 'LIBRARY_HIGHLIGHT_PLAYING_TRACK', + + PLAYLISTS_REFRESH = 'PLAYLISTS_REFRESH', + PLAYLISTS_LOAD_ONE = 'PLAYLISTS_LOAD_ONE', + PLAYLIST_REORDER_TRACKS = 'PLAYLIST_REORDER_TRACKS', + + TOAST_ADD = 'TOAST_ADD', + TOAST_REMOVE = 'TOAST_REMOVE', + + SET_LIBRARY_LAYOUT = 'SET_LIBRARY_LAYOUT', + + NOTIFICATION_NEW = 'NOTIFICATION_NEW', } export default ActionTypes; diff --git a/src/renderer/store/actions/LibraryActions.ts b/src/renderer/store/actions/LibraryActions.ts index 6168438f9..ce4e61501 100644 --- a/src/renderer/store/actions/LibraryActions.ts +++ b/src/renderer/store/actions/LibraryActions.ts @@ -1,349 +1,348 @@ -import * as fs from "fs"; -import path from "path"; -import * as util from "util"; -import electron, { ipcRenderer } from "electron"; -import globby from "globby"; -import queue from "queue"; - -import store from "../store"; -import types from "../action-types"; - -import * as app from "../../lib/app"; -import * as utils from "../../lib/utils"; -import * as m3u from "../../lib/utils-m3u"; -import { TrackEditableFields, SortBy, TrackModel } from "../../../shared/types/museeks"; -import { SUPPORTED_PLAYLISTS_EXTENSIONS, SUPPORTED_TRACKS_EXTENSIONS } from "../../../shared/constants"; -import channels from "../../../shared/lib/ipc-channels"; - -import * as PlaylistsActions from "./PlaylistsActions"; -import * as ToastsActions from "./ToastsActions"; +import * as fs from 'fs'; +import path from 'path'; +import * as util from 'util'; +import electron, { ipcRenderer } from 'electron'; +import globby from 'globby'; +import queue from 'queue'; + +import store from '../store'; +import types from '../action-types'; + +import * as app from '../../lib/app'; +import * as utils from '../../lib/utils'; +import * as m3u from '../../lib/utils-m3u'; +import { TrackEditableFields, SortBy, TrackModel } from '../../../shared/types/museeks'; +import { SUPPORTED_PLAYLISTS_EXTENSIONS, SUPPORTED_TRACKS_EXTENSIONS } from '../../../shared/constants'; +import channels from '../../../shared/lib/ipc-channels'; + +import * as PlaylistsActions from './PlaylistsActions'; +import * as ToastsActions from './ToastsActions'; const stat = util.promisify(fs.stat); interface ScanFile { - path: string; - stat: fs.Stats; + path: string; + stat: fs.Stats; } /** * Load tracks from database */ export const refresh = async (): Promise => { - try { - const tracks = await app.db.Track.find().execAsync(); - - store.dispatch({ - type: types.LIBRARY_REFRESH, - payload: { - tracks, - }, - }); - } catch (err) { - console.warn(err); - } + try { + const tracks = await app.db.Track.find().execAsync(); + + store.dispatch({ + type: types.LIBRARY_REFRESH, + payload: { + tracks, + }, + }); + } catch (err) { + console.warn(err); + } }; /** * Filter tracks by search */ export const search = (value: string): void => { - store.dispatch({ - type: types.FILTER_SEARCH, - payload: { - search: value, - }, - }); + store.dispatch({ + type: types.FILTER_SEARCH, + payload: { + search: value, + }, + }); }; /** * Filter tracks by sort query */ export const sort = (sortBy: SortBy): void => { - store.dispatch({ - type: types.LIBRARY_SORT, - payload: { - sortBy, - }, - }); + store.dispatch({ + type: types.LIBRARY_SORT, + payload: { + sortBy, + }, + }); }; // idk what to name or where to put this export interface LibraryLayoutSettings { - visibility: string[]; + visibility: string[]; } export const set_context_state = (state: LibraryLayoutSettings): void => { - store.dispatch({ - type: types.SET_LIBRARY_LAYOUT_STATE, - payload: state, - }); + store.dispatch({ + type: types.SET_LIBRARY_LAYOUT, + payload: state, + }); }; const scanPlaylists = async (paths: string[]) => { - return Promise.all( - paths.map(async (filePath) => { - try { - const playlistFiles = m3u.parse(filePath); - const playlistName = path.parse(filePath).name; - - const existingTracks: TrackModel[] = await app.db.Track.findAsync({ - $or: playlistFiles.map((filePath) => ({ path: filePath })), - }); - - await PlaylistsActions.create( - playlistName, - existingTracks.map((track) => track._id), - filePath - ); - } catch (err) { - console.warn(err); - } - }) - ); + return Promise.all( + paths.map(async (filePath) => { + try { + const playlistFiles = m3u.parse(filePath); + const playlistName = path.parse(filePath).name; + + const existingTracks: TrackModel[] = await app.db.Track.findAsync({ + $or: playlistFiles.map((filePath) => ({ path: filePath })), + }); + + await PlaylistsActions.create( + playlistName, + existingTracks.map((track) => track._id), + filePath + ); + } catch (err) { + console.warn(err); + } + }) + ); }; const scan = { - processed: 0, - total: 0, + processed: 0, + total: 0, }; const scanTracks = async (paths: string[]): Promise => { - return new Promise((resolve, reject) => { - if (paths.length === 0) resolve([]); - - try { - // Instantiate queue - let scannedFiles: TrackModel[] = []; - - // eslint-disable-next-line - // @ts-ignore Outdated types - // https://github.com/jessetane/queue/pull/15#issuecomment-414091539 - const scanQueue = queue(); - scanQueue.concurrency = 32; - scanQueue.autostart = true; - - scanQueue.on("end", async () => { - scan.processed = 0; - scan.total = 0; - - resolve(scannedFiles); - }); - - scanQueue.on("success", () => { - // Every 100 scans, update progress bar - if (scan.processed % 100 === 0) { - // Progress bar update - store.dispatch({ - type: types.LIBRARY_REFRESH_PROGRESS, - payload: { - processed: scan.processed, - total: scan.total, - }, - }); - - // Add tracks to the library view - const tracks = [...scannedFiles]; - scannedFiles = []; // Reset current selection - store.dispatch({ - type: types.LIBRARY_ADD_TRACKS, - payload: { - tracks, - }, - }); - } - }); - // End queue instantiation - - scan.total += paths.length; - - paths.forEach((filePath) => { - scanQueue.push(async (callback) => { - try { - // Normalize (back)slashes on Windows - filePath = path.resolve(filePath); - - // Check if there is an existing record in the DB - const existingDoc = await app.db.Track.findOneAsync({ path: filePath }); - - // If there is existing document - if (!existingDoc) { - // Get metadata - const track = await utils.getMetadata(filePath); - track.added = new Date().getTime(); - const insertedDoc: TrackModel = await app.db.Track.insertAsync(track); - scannedFiles.push(insertedDoc); - } - - scan.processed++; - } catch (err) { - console.warn(err); - } - - if (callback) callback(); - }); - }); - } catch (err) { - reject(err); - } - }); + return new Promise((resolve, reject) => { + if (paths.length === 0) resolve([]); + + try { + // Instantiate queue + let scannedFiles: TrackModel[] = []; + + // eslint-disable-next-line + // @ts-ignore Outdated types + // https://github.com/jessetane/queue/pull/15#issuecomment-414091539 + const scanQueue = queue(); + scanQueue.concurrency = 32; + scanQueue.autostart = true; + + scanQueue.on('end', async () => { + scan.processed = 0; + scan.total = 0; + + resolve(scannedFiles); + }); + + scanQueue.on('success', () => { + // Every 100 scans, update progress bar + if (scan.processed % 100 === 0) { + // Progress bar update + store.dispatch({ + type: types.LIBRARY_REFRESH_PROGRESS, + payload: { + processed: scan.processed, + total: scan.total, + }, + }); + + // Add tracks to the library view + const tracks = [...scannedFiles]; + scannedFiles = []; // Reset current selection + store.dispatch({ + type: types.LIBRARY_ADD_TRACKS, + payload: { + tracks, + }, + }); + } + }); + // End queue instantiation + + scan.total += paths.length; + + paths.forEach((filePath) => { + scanQueue.push(async (callback) => { + try { + // Normalize (back)slashes on Windows + filePath = path.resolve(filePath); + + // Check if there is an existing record in the DB + const existingDoc = await app.db.Track.findOneAsync({ path: filePath }); + + // If there is existing document + if (!existingDoc) { + // Get metadata + const track = await utils.getMetadata(filePath); + const insertedDoc: TrackModel = await app.db.Track.insertAsync(track); + scannedFiles.push(insertedDoc); + } + + scan.processed++; + } catch (err) { + console.warn(err); + } + + if (callback) callback(); + }); + }); + } catch (err) { + reject(err); + } + }); }; /** * Add tracks to Library */ export const add = async (pathsToScan: string[]): Promise => { - store.dispatch({ - type: types.LIBRARY_REFRESH_START, - }); - - try { - // 1. Get the stats for all the files/paths - const statsPromises: Promise[] = pathsToScan.map(async (folderPath) => ({ - path: folderPath, - stat: await stat(folderPath), - })); - - const paths = await Promise.all(statsPromises); - - // 2. Split directories and files - const files: string[] = []; - const folders: string[] = []; - - paths.forEach((elem) => { - if (elem.stat.isFile()) files.push(elem.path); - if (elem.stat.isDirectory() || elem.stat.isSymbolicLink()) folders.push(elem.path); - }); - - // 3. Scan all the directories with globby - const globbies = folders.map((folder) => { - // Normalize slashes and escape regex special characters - const pattern = `${folder.replace(/\\/g, "/").replace(/([$^*+?()\[\]])/g, "\\$1")}/**/*.*`; - - return globby(pattern, { followSymbolicLinks: true }); - }); - - const subDirectoriesFiles = await Promise.all(globbies); - // Scan folders and add files to library - - // 4. Merge all path arrays together and filter them with the extensions we support - const allFiles = subDirectoriesFiles.reduce((acc, array) => acc.concat(array), [] as string[]).concat(files); // Add the initial files - - const supportedTrackFiles = allFiles.filter((filePath) => { - const extension = path.extname(filePath).toLowerCase(); - return SUPPORTED_TRACKS_EXTENSIONS.includes(extension); - }); - - const supportedPlaylistsFiles = allFiles.filter((filePath) => { - const extension = path.extname(filePath).toLowerCase(); - return SUPPORTED_PLAYLISTS_EXTENSIONS.includes(extension); - }); - - if (supportedTrackFiles.length === 0 && supportedPlaylistsFiles.length === 0) { - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - - return []; - } - - // 5. Scan tracks then scan playlists - const importedTracks = await scanTracks(supportedTrackFiles); - await scanPlaylists(supportedPlaylistsFiles); - - await refresh(); - await PlaylistsActions.refresh(); - - return importedTracks; - } catch (err) { - ToastsActions.add("danger", "An error occured when scanning the library"); - console.warn(err); - return []; - } finally { - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - } + store.dispatch({ + type: types.LIBRARY_REFRESH_START, + }); + + try { + // 1. Get the stats for all the files/paths + const statsPromises: Promise[] = pathsToScan.map(async (folderPath) => ({ + path: folderPath, + stat: await stat(folderPath), + })); + + const paths = await Promise.all(statsPromises); + + // 2. Split directories and files + const files: string[] = []; + const folders: string[] = []; + + paths.forEach((elem) => { + if (elem.stat.isFile()) files.push(elem.path); + if (elem.stat.isDirectory() || elem.stat.isSymbolicLink()) folders.push(elem.path); + }); + + // 3. Scan all the directories with globby + const globbies = folders.map((folder) => { + // Normalize slashes and escape regex special characters + const pattern = `${folder.replace(/\\/g, '/').replace(/([$^*+?()\[\]])/g, '\\$1')}/**/*.*`; + + return globby(pattern, { followSymbolicLinks: true }); + }); + + const subDirectoriesFiles = await Promise.all(globbies); + // Scan folders and add files to library + + // 4. Merge all path arrays together and filter them with the extensions we support + const allFiles = subDirectoriesFiles.reduce((acc, array) => acc.concat(array), [] as string[]).concat(files); // Add the initial files + + const supportedTrackFiles = allFiles.filter((filePath) => { + const extension = path.extname(filePath).toLowerCase(); + return SUPPORTED_TRACKS_EXTENSIONS.includes(extension); + }); + + const supportedPlaylistsFiles = allFiles.filter((filePath) => { + const extension = path.extname(filePath).toLowerCase(); + return SUPPORTED_PLAYLISTS_EXTENSIONS.includes(extension); + }); + + if (supportedTrackFiles.length === 0 && supportedPlaylistsFiles.length === 0) { + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + + return []; + } + + // 5. Scan tracks then scan playlists + const importedTracks = await scanTracks(supportedTrackFiles); + await scanPlaylists(supportedPlaylistsFiles); + + await refresh(); + await PlaylistsActions.refresh(); + + return importedTracks; + } catch (err) { + ToastsActions.add('danger', 'An error occured when scanning the library'); + console.warn(err); + return []; + } finally { + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + } }; /** * remove tracks from library */ export const remove = async (tracksIds: string[]): Promise => { - // not calling await on it as it calls the synchonous message box - const options: Electron.MessageBoxOptions = { - buttons: ["Cancel", "Remove"], - title: "Remove tracks from library?", - message: `Are you sure you want to remove ${tracksIds.length} element(s) from your library?`, - type: "warning", - }; - - const result: electron.MessageBoxReturnValue = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); - - if (result.response === 1) { - // button possition, here 'remove' - // Remove tracks from the Track collection - app.db.Track.removeAsync({ _id: { $in: tracksIds } }, { multi: true }); - - store.dispatch({ - type: types.LIBRARY_REMOVE_TRACKS, - payload: { - tracksIds, - }, - }); - // That would be great to remove those ids from all the playlists, but it's not easy - // and should not cause strange behaviors, all PR for that would be really appreciated - // TODO: see if it's possible to remove the Ids from the selected state of TracksList as it "could" lead to strange behaviors - } + // not calling await on it as it calls the synchonous message box + const options: Electron.MessageBoxOptions = { + buttons: ['Cancel', 'Remove'], + title: 'Remove tracks from library?', + message: `Are you sure you want to remove ${tracksIds.length} element(s) from your library?`, + type: 'warning', + }; + + const result: electron.MessageBoxReturnValue = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); + + if (result.response === 1) { + // button possition, here 'remove' + // Remove tracks from the Track collection + app.db.Track.removeAsync({ _id: { $in: tracksIds } }, { multi: true }); + + store.dispatch({ + type: types.LIBRARY_REMOVE_TRACKS, + payload: { + tracksIds, + }, + }); + // That would be great to remove those ids from all the playlists, but it's not easy + // and should not cause strange behaviors, all PR for that would be really appreciated + // TODO: see if it's possible to remove the Ids from the selected state of TracksList as it "could" lead to strange behaviors + } }; /** * Reset the library */ export const reset = async (): Promise => { - try { - const options: Electron.MessageBoxOptions = { - buttons: ["Cancel", "Reset"], - title: "Reset library?", - message: "Are you sure you want to reset your library? All your tracks and playlists will be cleared.", - type: "warning", - }; - - const result = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); - - if (result.response === 1) { - store.dispatch({ - type: types.LIBRARY_REFRESH_START, - }); - - await app.db.Track.removeAsync({}, { multi: true }); - await app.db.Playlist.removeAsync({}, { multi: true }); - - store.dispatch({ - type: types.LIBRARY_RESET, - }); - - store.dispatch({ - type: types.LIBRARY_REFRESH_END, - }); - - await refresh(); - } - } catch (err) { - console.error(err); - } + try { + const options: Electron.MessageBoxOptions = { + buttons: ['Cancel', 'Reset'], + title: 'Reset library?', + message: 'Are you sure you want to reset your library? All your tracks and playlists will be cleared.', + type: 'warning', + }; + + const result = await ipcRenderer.invoke(channels.DIALOG_MESSAGE_BOX, options); + + if (result.response === 1) { + store.dispatch({ + type: types.LIBRARY_REFRESH_START, + }); + + await app.db.Track.removeAsync({}, { multi: true }); + await app.db.Playlist.removeAsync({}, { multi: true }); + + store.dispatch({ + type: types.LIBRARY_RESET, + }); + + store.dispatch({ + type: types.LIBRARY_REFRESH_END, + }); + + await refresh(); + } + } catch (err) { + console.error(err); + } }; /** * Update the play count attribute. */ export const incrementPlayCount = async (source: string): Promise => { - const query = { src: source }; // HACK Not great, should be done with an _id - const update = { $inc: { playcount: 1 } }; - try { - await app.db.Track.updateAsync(query, update); - } catch (err) { - console.warn(err); - } + const query = { src: source }; // HACK Not great, should be done with an _id + const update = { $inc: { playcount: 1 } }; + try { + await app.db.Track.updateAsync(query, update); + } catch (err) { + console.warn(err); + } }; /** @@ -355,33 +354,33 @@ export const incrementPlayCount = async (source: string): Promise => { * @param newFields The fields to be updated and their new value */ export const updateTrackMetadata = async (trackId: string, newFields: TrackEditableFields): Promise => { - const query = { _id: trackId }; + const query = { _id: trackId }; - let track: TrackModel = await app.db.Track.findOneAsync(query); + let track: TrackModel = await app.db.Track.findOneAsync(query); - track = { - ...track, - ...newFields, - loweredMetas: utils.getLoweredMeta(newFields), - }; + track = { + ...track, + ...newFields, + loweredMetas: utils.getLoweredMeta(newFields), + }; - if (!track) { - throw new Error("No track found while trying to update track metadata"); - } + if (!track) { + throw new Error('No track found while trying to update track metadata'); + } - await app.db.Track.updateAsync(query, track); + await app.db.Track.updateAsync(query, track); - await refresh(); + await refresh(); }; /** * Set highlight trigger for a track */ export const highlightPlayingTrack = (highlight: boolean): void => { - store.dispatch({ - type: types.LIBRARY_HIGHLIGHT_PLAYING_TRACK, - payload: { - highlight, - }, - }); + store.dispatch({ + type: types.LIBRARY_HIGHLIGHT_PLAYING_TRACK, + payload: { + highlight, + }, + }); }; diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index 2c9ad8a15..6287667db 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -1,225 +1,225 @@ -import types from "../action-types"; +import types from '../action-types'; -import { config } from "../../lib/app"; -import * as utils from "../../lib/utils"; -import { Action, TrackModel, SortBy, SortOrder } from "../../../shared/types/museeks"; -import { LibraryLayoutSettings as LibraryLayoutSettings } from "../actions/LibraryActions"; +import { config } from '../../lib/app'; +import * as utils from '../../lib/utils'; +import { Action, TrackModel, SortBy, SortOrder } from '../../../shared/types/museeks'; +import { LibraryLayoutSettings as LibraryLayoutSettings } from '../actions/LibraryActions'; export interface LibrarySort { - by: SortBy; - order: SortOrder; + by: SortBy; + order: SortOrder; } export interface LibraryState { - tracks: { - library: TrackModel[]; // List of tracks in Library view - playlist: TrackModel[]; // List of tracks in Playlist view - }; - search: string; - sort: LibrarySort; - loading: boolean; - refreshing: boolean; - refresh: { - processed: number; - total: number; - }; - highlightPlayingTrack: boolean; - libraryLayoutSettings: LibraryLayoutSettings; + tracks: { + library: TrackModel[]; // List of tracks in Library view + playlist: TrackModel[]; // List of tracks in Playlist view + }; + search: string; + sort: LibrarySort; + loading: boolean; + refreshing: boolean; + refresh: { + processed: number; + total: number; + }; + highlightPlayingTrack: boolean; + libraryLayoutSettings: LibraryLayoutSettings; } const initialState: LibraryState = { - tracks: { - library: [], - playlist: [], - }, - search: "", - sort: config.get("librarySort"), - loading: true, - refreshing: false, - refresh: { - processed: 0, - total: 0, - }, - highlightPlayingTrack: false, - // I'm not sure if this is a good place for the default settings - // FIXME - libraryLayoutSettings: config.get("libraryLayoutSettings") || { - visibility: ["title", "duration", "artist", "genre"], - }, + tracks: { + library: [], + playlist: [], + }, + search: '', + sort: config.get('librarySort'), + loading: true, + refreshing: false, + refresh: { + processed: 0, + total: 0, + }, + highlightPlayingTrack: false, + // I'm not sure if this is a good place for the default settings + // FIXME + libraryLayoutSettings: config.get('libraryLayoutSettings') || { + visibility: ['title', 'duration', 'artist', 'genre'], + }, }; export default (state = initialState, action: Action): LibraryState => { - switch (action.type) { - case types.LIBRARY_REFRESH: { - return { - ...state, - tracks: { - library: [...action.payload.tracks], - playlist: [], - }, - loading: false, - }; - } - - case types.LIBRARY_SORT: { - const { sortBy } = action.payload; - const prevSort = state.sort; - - if (sortBy === prevSort.by) { - return { - ...state, - sort: { - ...state.sort, - order: prevSort.order === SortOrder.ASC ? SortOrder.DSC : SortOrder.ASC, - }, - }; - } - - const sort: LibrarySort = { - by: sortBy, - order: SortOrder.ASC, - }; - - config.set("librarySort", sort); - config.save(); - - return { - ...state, - sort, - }; - } - - case types.SET_LIBRARY_LAYOUT_STATE: { - // const prevState = state.sort; - - config.set("libraryLayoutSettings", action.payload); - config.save(); - - return { - ...state, - libraryLayoutSettings: action.payload, - }; - } - - case types.FILTER_SEARCH: { - return { - ...state, - search: utils.stripAccents(action.payload.search), - }; - } - - // case (types.LIBRARY_ADD_FOLDERS): { // TODO Redux -> move to a thunk - // const { folders } = action.payload; - // let musicFolders = app.config.get('musicFolders'); - - // // Check if we received folders - // if (folders !== undefined) { - // musicFolders = musicFolders.concat(folders); - - // // Remove duplicates, useless children, ect... - // musicFolders = utils.removeUselessFolders(musicFolders); - - // musicFolders.sort(); - - // app.config.set('musicFolders', musicFolders); - // app.config.saveSync(); - // } - - // return { ...state }; - // } - - // case (types.LIBRARY_REMOVE_FOLDER): { // TODO Redux -> move to a thunk - // if (!state.library.refreshing) { - // const musicFolders = app.config.get('musicFolders'); - - // musicFolders.splice(action.index, 1); - - // app.config.set('musicFolders', musicFolders); - // app.config.saveSync(); - - // return { ...state }; - // } - - // return state; - // } - - case types.LIBRARY_RESET: { - return initialState; - } - - case types.LIBRARY_REFRESH_START: { - return { - ...state, - refreshing: true, - }; - } - - case types.LIBRARY_REFRESH_END: { - return { - ...state, - refreshing: false, - refresh: { - processed: 0, - total: 0, - }, - }; - } - - case types.LIBRARY_REFRESH_PROGRESS: { - return { - ...state, - refresh: { - processed: action.payload.processed, - total: action.payload.total, - }, - }; - } - - case types.LIBRARY_REMOVE_TRACKS: { - const { tracksIds } = action.payload; - const removeTrack = (track: TrackModel) => !tracksIds.includes(track._id); - - const tracks = { - library: [...state.tracks.library].filter(removeTrack), - playlist: [...state.tracks.playlist].filter(removeTrack), - }; - - return { - ...state, - tracks, - }; - } - - case types.LIBRARY_ADD_TRACKS: { - const { tracks } = action.payload; - - const libraryTracks: TrackModel[] = [...state.tracks.library, ...tracks]; - - return { - ...state, - tracks: { - playlist: state.tracks.playlist, - library: libraryTracks, - }, - }; - } - - case types.LIBRARY_HIGHLIGHT_PLAYING_TRACK: { - return { - ...state, - highlightPlayingTrack: action.payload.highlight, - }; - } - - case types.PLAYLISTS_LOAD_ONE: { - const newState = { ...state }; - newState.tracks.playlist = [...action.payload.tracks]; - - return newState; - } - - default: { - return state; - } - } + switch (action.type) { + case types.LIBRARY_REFRESH: { + return { + ...state, + tracks: { + library: [...action.payload.tracks], + playlist: [], + }, + loading: false, + }; + } + + case types.LIBRARY_SORT: { + const { sortBy } = action.payload; + const prevSort = state.sort; + + if (sortBy === prevSort.by) { + return { + ...state, + sort: { + ...state.sort, + order: prevSort.order === SortOrder.ASC ? SortOrder.DSC : SortOrder.ASC, + }, + }; + } + + const sort: LibrarySort = { + by: sortBy, + order: SortOrder.ASC, + }; + + config.set('librarySort', sort); + config.save(); + + return { + ...state, + sort, + }; + } + + case types.SET_LIBRARY_LAYOUT: { + // const prevState = state.sort; + + config.set('libraryLayoutSettings', action.payload); + config.save(); + + return { + ...state, + libraryLayoutSettings: action.payload, + }; + } + + case types.FILTER_SEARCH: { + return { + ...state, + search: utils.stripAccents(action.payload.search), + }; + } + + // case (types.LIBRARY_ADD_FOLDERS): { // TODO Redux -> move to a thunk + // const { folders } = action.payload; + // let musicFolders = app.config.get('musicFolders'); + + // // Check if we received folders + // if (folders !== undefined) { + // musicFolders = musicFolders.concat(folders); + + // // Remove duplicates, useless children, ect... + // musicFolders = utils.removeUselessFolders(musicFolders); + + // musicFolders.sort(); + + // app.config.set('musicFolders', musicFolders); + // app.config.saveSync(); + // } + + // return { ...state }; + // } + + // case (types.LIBRARY_REMOVE_FOLDER): { // TODO Redux -> move to a thunk + // if (!state.library.refreshing) { + // const musicFolders = app.config.get('musicFolders'); + + // musicFolders.splice(action.index, 1); + + // app.config.set('musicFolders', musicFolders); + // app.config.saveSync(); + + // return { ...state }; + // } + + // return state; + // } + + case types.LIBRARY_RESET: { + return initialState; + } + + case types.LIBRARY_REFRESH_START: { + return { + ...state, + refreshing: true, + }; + } + + case types.LIBRARY_REFRESH_END: { + return { + ...state, + refreshing: false, + refresh: { + processed: 0, + total: 0, + }, + }; + } + + case types.LIBRARY_REFRESH_PROGRESS: { + return { + ...state, + refresh: { + processed: action.payload.processed, + total: action.payload.total, + }, + }; + } + + case types.LIBRARY_REMOVE_TRACKS: { + const { tracksIds } = action.payload; + const removeTrack = (track: TrackModel) => !tracksIds.includes(track._id); + + const tracks = { + library: [...state.tracks.library].filter(removeTrack), + playlist: [...state.tracks.playlist].filter(removeTrack), + }; + + return { + ...state, + tracks, + }; + } + + case types.LIBRARY_ADD_TRACKS: { + const { tracks } = action.payload; + + const libraryTracks: TrackModel[] = [...state.tracks.library, ...tracks]; + + return { + ...state, + tracks: { + playlist: state.tracks.playlist, + library: libraryTracks, + }, + }; + } + + case types.LIBRARY_HIGHLIGHT_PLAYING_TRACK: { + return { + ...state, + highlightPlayingTrack: action.payload.highlight, + }; + } + + case types.PLAYLISTS_LOAD_ONE: { + const newState = { ...state }; + newState.tracks.playlist = [...action.payload.tracks]; + + return newState; + } + + default: { + return state; + } + } }; diff --git a/src/renderer/views/Library/Library.tsx b/src/renderer/views/Library/Library.tsx index 69af9919f..4b2944ea7 100644 --- a/src/renderer/views/Library/Library.tsx +++ b/src/renderer/views/Library/Library.tsx @@ -1,92 +1,92 @@ -import React, { useMemo } from "react"; -import { Link } from "react-router-dom"; -import { useSelector } from "react-redux"; +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useSelector } from 'react-redux'; -import * as ViewMessage from "../../elements/ViewMessage/ViewMessage"; -import TracksList from "../../components/TracksList/TracksList"; -import { filterTracks, sortTracks } from "../../lib/utils-library"; -import SORT_ORDERS from "../../constants/sort-orders"; -import { RootState } from "../../store/reducers"; +import * as ViewMessage from '../../elements/ViewMessage/ViewMessage'; +import TracksList from '../../components/TracksList/TracksList'; +import { filterTracks, sortTracks } from '../../lib/utils-library'; +import SORT_ORDERS from '../../constants/sort-orders'; +import { RootState } from '../../store/reducers'; -import appStyles from "../../App.module.css"; -import styles from "./Library.module.css"; +import appStyles from '../../App.module.css'; +import styles from './Library.module.css'; const Library: React.FC = () => { - const library = useSelector((state: RootState) => state.library); - const player = useSelector((state: RootState) => state.player); - const playlists = useSelector((state: RootState) => state.playlists.list); - const tracks = useSelector((state: RootState) => { - const { search, tracks, sort } = state.library; + const library = useSelector((state: RootState) => state.library); + const player = useSelector((state: RootState) => state.player); + const playlists = useSelector((state: RootState) => state.playlists.list); + const tracks = useSelector((state: RootState) => { + const { search, tracks, sort } = state.library; - // Filter and sort TracksList - // sorting being a costly operation, do it after filtering - const filteredTracks = sortTracks(filterTracks(tracks.library, search), SORT_ORDERS[sort.by][sort.order]); + // Filter and sort TracksList + // sorting being a costly operation, do it after filtering + const filteredTracks = sortTracks(filterTracks(tracks.library, search), SORT_ORDERS[sort.by][sort.order]); - return filteredTracks; - }); + return filteredTracks; + }); - const getLibraryComponent = useMemo(() => { - const { playerStatus } = player; + const getLibraryComponent = useMemo(() => { + const { playerStatus } = player; - const trackPlayingId = - player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null; + const trackPlayingId = + player.queue.length > 0 && player.queueCursor !== null ? player.queue[player.queueCursor]._id : null; - // Loading library - if (library.loading) { - return ( - -

Loading library...

-
- ); - } + // Loading library + if (library.loading) { + return ( + +

Loading library...

+
+ ); + } - // Empty library - if (tracks.length === 0 && library.search === "") { - if (library.refreshing) { - return ( - -

Your library is being scanned =)

- hold on... -
- ); - } + // Empty library + if (tracks.length === 0 && library.search === '') { + if (library.refreshing) { + return ( + +

Your library is being scanned =)

+ hold on... +
+ ); + } - return ( - -

Too bad, there is no music in your library =(

- - you can always just drop files and folders anywhere or{" "} - - add your music here - - -
- ); - } + return ( + +

Too bad, there is no music in your library =(

+ + you can always just drop files and folders anywhere or{' '} + + add your music here + + +
+ ); + } - // Empty search - if (tracks.length === 0) { - return ( - -

Your search returned no results

-
- ); - } + // Empty search + if (tracks.length === 0) { + return ( + +

Your search returned no results

+
+ ); + } - // All good ! - return ( - - ); - }, [library, playlists, player, tracks]); + // All good ! + return ( + + ); + }, [library, playlists, player, tracks]); - return
{getLibraryComponent}
; + return
{getLibraryComponent}
; }; export default Library; From 3200ad70815c1ba9c2323e42448d4aac0d4f96fe Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:14:35 +0200 Subject: [PATCH 11/18] i regret ever changing the formatter configuration --- .../components/Header/Header.module.css | 72 ++++++++-------- .../components/TrackRow/TrackRow.module.css | 84 +++++++++---------- .../TracksList/TracksList.module.css | 20 ++--- 3 files changed, 88 insertions(+), 88 deletions(-) diff --git a/src/renderer/components/Header/Header.module.css b/src/renderer/components/Header/Header.module.css index 3416c0aac..00ddea309 100644 --- a/src/renderer/components/Header/Header.module.css +++ b/src/renderer/components/Header/Header.module.css @@ -1,60 +1,60 @@ :global(.os__darwin) .header__mainControls { - padding-left: 65px; /* let some space for titleBarStyle */ + padding-left: 65px; /* let some space for titleBarStyle */ } /* The native frame may be light, so we need to increase the contrast between the frame and the header */ :global(.os__win32), :global(.os__linux) { - .header { - border-top: 1px solid var(--border-color); - } + .header { + border-top: 1px solid var(--border-color); + } } .header { - border-bottom: 1px solid var(--border-color); - background-color: var(--header-bg); - color: var(--header-color); - padding: 0 10px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 5px; - flex: 0 0 auto; + border-bottom: 1px solid var(--border-color); + background-color: var(--header-bg); + color: var(--header-color); + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px; + flex: 0 0 auto; - /* Draggable region (zone able to move the window) */ - -webkit-app-region: drag; + /* Draggable region (zone able to move the window) */ + -webkit-app-region: drag; } .header__mainControls { - width: 220px; - flex: 0 0 auto; - display: flex; - align-items: center; - padding-right: 10px; + width: 220px; + flex: 0 0 auto; + display: flex; + align-items: center; + padding-right: 10px; } .header__search { - -webkit-app-region: no-drag; - width: 220px; - flex: 0 0 auto; - display: flex; - justify-content: flex-end; + -webkit-app-region: no-drag; + width: 220px; + flex: 0 0 auto; + display: flex; + justify-content: flex-end; } .header__search__input { - display: block; - font-size: inherit; - width: 100%; - padding: 6px 12px; - background-color: var(--search-bg); - border: 1px solid var(--border-color); - color: var(--text); - border-radius: var(--border-radius); + display: block; + font-size: inherit; + width: 100%; + padding: 6px 12px; + background-color: var(--search-bg); + border: 1px solid var(--border-color); + color: var(--text); + border-radius: var(--border-radius); } .header__playingBar { - flex: 1 1 auto; - min-width: 0; - max-width: 600px; + flex: 1 1 auto; + min-width: 0; + max-width: 600px; } diff --git a/src/renderer/components/TrackRow/TrackRow.module.css b/src/renderer/components/TrackRow/TrackRow.module.css index f20b73886..41d2704a8 100644 --- a/src/renderer/components/TrackRow/TrackRow.module.css +++ b/src/renderer/components/TrackRow/TrackRow.module.css @@ -1,32 +1,32 @@ .cell { - padding: 3px 4px; - cursor: default; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 15px; + padding: 3px 4px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 15px; } .track { - position: relative; - display: flex; - outline: none; - height: 30px; - padding: 5px; + position: relative; + display: flex; + outline: none; + height: 30px; + padding: 5px; - &:nth-child(odd) { - background-color: var(--tracks-bg-odd); - } + &:nth-child(odd) { + background-color: var(--tracks-bg-odd); + } - &:nth-child(even) { - background-color: var(--tracks-bg-even); - } + &:nth-child(even) { + background-color: var(--tracks-bg-even); + } - &.selected { - background-color: var(--main-color); - color: white; + &.selected { + background-color: var(--main-color); + color: white; - /* // put that elsewhere someday + /* // put that elsewhere someday .playingIndicator { .animation { @@ -36,29 +36,29 @@ } } } */ - } + } - &.reordered { - opacity: 0.5; - } + &.reordered { + opacity: 0.5; + } - &.isReorderedOver { - &::after { - pointer-events: none; - position: absolute; - z-index: 1; - display: block; - width: 100%; - content: ""; - border-bottom: solid 2px var(--main-color); - } + &.isReorderedOver { + &::after { + pointer-events: none; + position: absolute; + z-index: 1; + display: block; + width: 100%; + content: ''; + border-bottom: solid 2px var(--main-color); + } - &.isAbove::after { - top: -1px; - } + &.isAbove::after { + top: -1px; + } - &.isBelow::after { - bottom: -1px; - } - } + &.isBelow::after { + bottom: -1px; + } + } } diff --git a/src/renderer/components/TracksList/TracksList.module.css b/src/renderer/components/TracksList/TracksList.module.css index 101e9c4e0..5afd11b09 100644 --- a/src/renderer/components/TracksList/TracksList.module.css +++ b/src/renderer/components/TracksList/TracksList.module.css @@ -1,21 +1,21 @@ .tracksList { - outline: none; - display: flex; - flex-direction: column; - flex: 1 1 auto; + outline: none; + display: flex; + flex-direction: column; + flex: 1 1 auto; } .tracksListBody { - overflow: auto; - flex: 1 1 auto; + overflow: auto; + flex: 1 1 auto; } .tiles { - position: relative; + position: relative; } .tile { - position: absolute; - width: 100%; - z-index: 10; + position: absolute; + width: 100%; + z-index: 10; } From b74795761854f8be7bb7585ff2b076ff22419c06 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:17:10 +0200 Subject: [PATCH 12/18] more and more reverting --- .prettierrc | 24 +- src/main/modules/config.ts | 2 +- src/renderer/components/TrackRow/TrackRow.tsx | 1 - src/renderer/lib/utils.ts | 310 +++++++++--------- src/shared/types/museeks.ts | 190 +++++------ 5 files changed, 263 insertions(+), 264 deletions(-) diff --git a/.prettierrc b/.prettierrc index f3a07ab47..9a773cdf8 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,14 +1,14 @@ { - "printWidth": 120, - "singleQuote": true, - "jsxSingleQuote": true, - "arrowParens": "always", - "overrides": [ - { - "files": ["**/*.css"], - "options": { - "printWidth": 1000 - } - } - ] + "printWidth": 120, + "singleQuote": true, + "jsxSingleQuote": true, + "arrowParens": "always", + "overrides": [ + { + "files": ["**/*.css"], + "options": { + "printWidth": 1000 + } + } + ] } diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index e3ea9416c..b26ee03d7 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -52,7 +52,7 @@ class ConfigModule extends Module { audioRepeat: Repeat.NONE, defaultView: 'library', librarySort: { - by: SortBy.TITLE, + by: SortBy.ARTIST, order: SortOrder.ASC, }, // musicFolders: [], diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 8e93e6d9d..7a763d617 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -171,7 +171,6 @@ export default class TrackRow extends React.PureComponent {
{this.props.isPlaying ? : null}
- {/* {this.props.layout.collapse_artist ? "Lol" : undefined} */} {...rows}
); diff --git a/src/renderer/lib/utils.ts b/src/renderer/lib/utils.ts index 515be4a82..6c5a68cd6 100644 --- a/src/renderer/lib/utils.ts +++ b/src/renderer/lib/utils.ts @@ -1,215 +1,215 @@ -import path from "path"; -import * as mmd from "music-metadata"; -import pickBy from "lodash-es/pickBy"; +import path from 'path'; +import * as mmd from 'music-metadata'; +import pickBy from 'lodash-es/pickBy'; -import { Track, TrackEditableFields } from "../../shared/types/museeks"; +import { Track, TrackEditableFields } from '../../shared/types/museeks'; /** * Parse an int to a more readable string */ export const parseDuration = (duration: number | null): string => { - if (duration !== null) { - const hours = Math.trunc(duration / 3600); - const minutes = Math.trunc(duration / 60) % 60; - const seconds = Math.trunc(duration) % 60; + if (duration !== null) { + const hours = Math.trunc(duration / 3600); + const minutes = Math.trunc(duration / 60) % 60; + const seconds = Math.trunc(duration) % 60; - const hoursStringified = hours < 10 ? `0${hours}` : hours; - const minutesStringified = minutes < 10 ? `0${minutes}` : minutes; - const secondsStringified = seconds < 10 ? `0${seconds}` : seconds; + const hoursStringified = hours < 10 ? `0${hours}` : hours; + const minutesStringified = minutes < 10 ? `0${minutes}` : minutes; + const secondsStringified = seconds < 10 ? `0${seconds}` : seconds; - let result = hoursStringified > 0 ? `${hoursStringified}:` : ""; - result += `${minutesStringified}:${secondsStringified}`; + let result = hoursStringified > 0 ? `${hoursStringified}:` : ''; + result += `${minutesStringified}:${secondsStringified}`; - return result; - } + return result; + } - return "00:00"; + return '00:00'; }; /** * Parse an URI, encoding some characters */ export const parseUri = (uri: string): string => { - const root = process.platform === "win32" ? "" : path.parse(uri).root; + const root = process.platform === 'win32' ? '' : path.parse(uri).root; - const location = path - .resolve(uri) - .split(path.sep) - .map((d, i) => (i === 0 ? d : encodeURIComponent(d))) - .reduce((a, b) => path.join(a, b)); + const location = path + .resolve(uri) + .split(path.sep) + .map((d, i) => (i === 0 ? d : encodeURIComponent(d))) + .reduce((a, b) => path.join(a, b)); - return `file://${root}${location}`; + return `file://${root}${location}`; }; /** * Sort an array of string by ASC or DESC, then remove all duplicates */ -export const simpleSort = (array: string[], sorting: "asc" | "desc"): string[] => { - if (sorting === "asc") { - array.sort((a, b) => (a > b ? 1 : -1)); - } else if (sorting === "desc") { - array.sort((a, b) => (b > a ? -1 : 1)); - } - - const result: string[] = []; - array.forEach((item) => { - if (!result.includes(item)) result.push(item); - }); - - return result; +export const simpleSort = (array: string[], sorting: 'asc' | 'desc'): string[] => { + if (sorting === 'asc') { + array.sort((a, b) => (a > b ? 1 : -1)); + } else if (sorting === 'desc') { + array.sort((a, b) => (b > a ? -1 : 1)); + } + + const result: string[] = []; + array.forEach((item) => { + if (!result.includes(item)) result.push(item); + }); + + return result; }; /** * Strip accent from String. From https://jsperf.com/strip-accents */ export const stripAccents = (str: string): string => { - const accents = "ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž"; - const fixes = "AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz"; - const split = accents.split("").join("|"); - const reg = new RegExp(`(${split})`, "g"); + const accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž'; + const fixes = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz'; + const split = accents.split('').join('|'); + const reg = new RegExp(`(${split})`, 'g'); - function replacement(a: string) { - return fixes[accents.indexOf(a)] || ""; - } + function replacement(a: string) { + return fixes[accents.indexOf(a)] || ''; + } - return str.replace(reg, replacement).toLowerCase(); + return str.replace(reg, replacement).toLowerCase(); }; /** * Remove duplicates (realpath) and useless children folders */ export const removeUselessFolders = (folders: string[]): string[] => { - // Remove duplicates - let filteredFolders = folders.filter((elem, index) => folders.indexOf(elem) === index); + // Remove duplicates + let filteredFolders = folders.filter((elem, index) => folders.indexOf(elem) === index); - const foldersToBeRemoved: string[] = []; + const foldersToBeRemoved: string[] = []; - filteredFolders.forEach((folder, i) => { - filteredFolders.forEach((subfolder, j) => { - if (subfolder.includes(folder) && i !== j && !foldersToBeRemoved.includes(folder)) { - foldersToBeRemoved.push(subfolder); - } - }); - }); + filteredFolders.forEach((folder, i) => { + filteredFolders.forEach((subfolder, j) => { + if (subfolder.includes(folder) && i !== j && !foldersToBeRemoved.includes(folder)) { + foldersToBeRemoved.push(subfolder); + } + }); + }); - filteredFolders = filteredFolders.filter((elem) => !foldersToBeRemoved.includes(elem)); + filteredFolders = filteredFolders.filter((elem) => !foldersToBeRemoved.includes(elem)); - return filteredFolders; + return filteredFolders; }; // TODO export const getDefaultMetadata = (): Track => ({ - album: "Unknown", - artist: ["Unknown artist"], - disk: { - no: 0, - of: 0, - }, - duration: 0, - genre: [], - loweredMetas: { - artist: ["unknown artist"], - album: "unknown", - title: "", - genre: [], - }, - added: 0, - path: "", - playCount: 0, - title: "", - track: { - no: 0, - of: 0, - }, - year: null, + album: 'Unknown', + artist: ['Unknown artist'], + disk: { + no: 0, + of: 0, + }, + duration: 0, + genre: [], + loweredMetas: { + artist: ['unknown artist'], + album: 'unknown', + title: '', + genre: [], + }, + added: 0, + path: '', + playCount: 0, + title: '', + track: { + no: 0, + of: 0, + }, + year: null, }); export const parseMusicMetadata = (data: mmd.IAudioMetadata, trackPath: string): Partial => { - const { common, format } = data; - - const metadata = { - album: common.album, - artist: common.artists || (common.artist && [common.artist]) || (common.albumartist && [common.albumartist]), - disk: common.disk, - duration: format.duration, - genre: common.genre, - title: common.title || path.parse(trackPath).base, - track: common.track, - year: common.year, - }; - - return pickBy(metadata); + const { common, format } = data; + + const metadata = { + album: common.album, + artist: common.artists || (common.artist && [common.artist]) || (common.albumartist && [common.albumartist]), + disk: common.disk, + duration: format.duration, + genre: common.genre, + title: common.title || path.parse(trackPath).base, + track: common.track, + year: common.year, + }; + + return pickBy(metadata); }; -export const getLoweredMeta = (metadata: TrackEditableFields): Track["loweredMetas"] => ({ - artist: metadata.artist.map((meta) => stripAccents(meta.toLowerCase())), - album: stripAccents(metadata.album.toLowerCase()), - title: stripAccents(metadata.title.toLowerCase()), - genre: metadata.genre.map((meta) => stripAccents(meta.toLowerCase())), +export const getLoweredMeta = (metadata: TrackEditableFields): Track['loweredMetas'] => ({ + artist: metadata.artist.map((meta) => stripAccents(meta.toLowerCase())), + album: stripAccents(metadata.album.toLowerCase()), + title: stripAccents(metadata.title.toLowerCase()), + genre: metadata.genre.map((meta) => stripAccents(meta.toLowerCase())), }); export const getAudioDuration = (trackPath: string): Promise => { - const audio = new Audio(); - - return new Promise((resolve, reject) => { - audio.addEventListener("loadedmetadata", () => { - resolve(audio.duration); - }); - - audio.addEventListener("error", (e) => { - // eslint-disable-next-line - // @ts-ignore error event typing is wrong - const message = `Error getting audio duration: (${e.currentTarget.error.code}) ${trackPath}`; - reject(new Error(message)); - }); - - audio.preload = "metadata"; - // HACK no idea what other caracters could fuck things up - audio.src = encodeURI(trackPath).replace("#", "%23"); - }); + const audio = new Audio(); + + return new Promise((resolve, reject) => { + audio.addEventListener('loadedmetadata', () => { + resolve(audio.duration); + }); + + audio.addEventListener('error', (e) => { + // eslint-disable-next-line + // @ts-ignore error event typing is wrong + const message = `Error getting audio duration: (${e.currentTarget.error.code}) ${trackPath}`; + reject(new Error(message)); + }); + + audio.preload = 'metadata'; + // HACK no idea what other caracters could fuck things up + audio.src = encodeURI(trackPath).replace('#', '%23'); + }); }; /** * Get a file metadata */ export const getMetadata = async (trackPath: string): Promise => { - const defaultMetadata = getDefaultMetadata(); - - const basicMetadata: Track = { - ...defaultMetadata, - path: trackPath, - }; - - try { - const data = await mmd.parseFile(trackPath, { - skipCovers: true, - duration: true, - }); - - // Let's try to define something with what we got so far... - const parsedData = parseMusicMetadata(data, trackPath); - - const metadata: Track = { - ...defaultMetadata, - ...parsedData, - path: trackPath, - }; - - metadata.loweredMetas = getLoweredMeta(metadata); - - // Let's try another wat to retrieve a track duration - if (metadata.duration < 0.5) { - try { - metadata.duration = await getAudioDuration(trackPath); - } catch (err) { - console.warn(`An error occured while getting ${trackPath} duration: ${err}`); - } - } - - return metadata; - } catch (err) { - console.warn(`An error occured while reading ${trackPath} id3 tags: ${err}`); - } - - return basicMetadata; + const defaultMetadata = getDefaultMetadata(); + + const basicMetadata: Track = { + ...defaultMetadata, + path: trackPath, + }; + + try { + const data = await mmd.parseFile(trackPath, { + skipCovers: true, + duration: true, + }); + + // Let's try to define something with what we got so far... + const parsedData = parseMusicMetadata(data, trackPath); + + const metadata: Track = { + ...defaultMetadata, + ...parsedData, + path: trackPath, + }; + + metadata.loweredMetas = getLoweredMeta(metadata); + + // Let's try another wat to retrieve a track duration + if (metadata.duration < 0.5) { + try { + metadata.duration = await getAudioDuration(trackPath); + } catch (err) { + console.warn(`An error occured while getting ${trackPath} duration: ${err}`); + } + } + + return metadata; + } catch (err) { + console.warn(`An error occured while reading ${trackPath} id3 tags: ${err}`); + } + + return basicMetadata; }; diff --git a/src/shared/types/museeks.ts b/src/shared/types/museeks.ts index 777007eaf..1d744a2fd 100644 --- a/src/shared/types/museeks.ts +++ b/src/shared/types/museeks.ts @@ -2,100 +2,100 @@ * Player related stuff */ export enum PlayerStatus { - PLAY = "play", - PAUSE = "pause", - STOP = "stop", + PLAY = 'play', + PAUSE = 'pause', + STOP = 'stop', } export enum Repeat { - ALL = "all", - ONE = "one", - NONE = "none", + ALL = 'all', + ONE = 'one', + NONE = 'none', } export enum SortBy { - ALBUM = "album", - ARTIST = "artist", - TITLE = "title", - DURATION = "duration", - GENRE = "genre", - ADDED = "added", + ALBUM = 'album', + ARTIST = 'artist', + TITLE = 'title', + DURATION = 'duration', + GENRE = 'genre', + ADDED = 'added', } export enum SortOrder { - ASC = "asc", - DSC = "dsc", + ASC = 'asc', + DSC = 'dsc', } /** * Redux */ export interface Action { - // TODO action specific types - type: string; - payload?: any; + // TODO action specific types + type: string; + payload?: any; } /** * Untyped libs / helpers */ export type LinvoSchema = { - _id: string; - find: any; - findOne: any; - insert: any; - copy: any; // TODO better types? - remove: any; - save: any; - serialize: any; - update: any; - ensureIndex: any; - // bluebird-injected - findAsync: any; - findOneAsync: any; - insertAsync: any; - copyAsync: any; - removeAsync: any; - saveAsync: any; - serializeAsync: any; - updateAsync: any; + _id: string; + find: any; + findOne: any; + insert: any; + copy: any; // TODO better types? + remove: any; + save: any; + serialize: any; + update: any; + ensureIndex: any; + // bluebird-injected + findAsync: any; + findOneAsync: any; + insertAsync: any; + copyAsync: any; + removeAsync: any; + saveAsync: any; + serializeAsync: any; + updateAsync: any; } & { - [Property in keyof Schema]: Schema[Property]; + [Property in keyof Schema]: Schema[Property]; }; /** * App models */ export interface Track { - album: string; - artist: string[]; - disk: { - no: number; - of: number; - }; - duration: number; - genre: string[]; - added: number; - loweredMetas: { - artist: string[]; - album: string; - title: string; - genre: string[]; - }; - path: string; - playCount: number; - title: string; - track: { - no: number; - of: number; - }; - year: number | null; + album: string; + artist: string[]; + disk: { + no: number; + of: number; + }; + duration: number; + genre: string[]; + added: number; + loweredMetas: { + artist: string[]; + album: string; + title: string; + genre: string[]; + }; + path: string; + playCount: number; + title: string; + track: { + no: number; + of: number; + }; + year: number | null; } export interface Playlist { - name: string; - tracks: string[]; - importPath?: string; // associated m3u file + name: string; + tracks: string[]; + importPath?: string; // associated m3u file } /** @@ -107,52 +107,52 @@ export type PlaylistModel = LinvoSchema; /** * Editable track fields (via right-click -> edit track) */ -export type TrackEditableFields = Pick; +export type TrackEditableFields = Pick; /** * Various */ export interface Toast { - _id: number; - content: string; - type: ToastType; + _id: number; + content: string; + type: ToastType; } -export type ToastType = "success" | "danger" | "warning"; +export type ToastType = 'success' | 'danger' | 'warning'; /** * Config */ export interface ConfigBounds { - width: number; - height: number; - x: number; - y: number; + width: number; + height: number; + x: number; + y: number; } // TODO: how to automate this? Maybe losen types to "string" -type ThemeIds = "dark" | "light" | "dark-legacy"; +type ThemeIds = 'dark' | 'light' | 'dark-legacy'; export interface Config { - theme: ThemeIds | "__system"; - audioVolume: number; - audioPlaybackRate: number; - audioOutputDevice: string; - audioMuted: boolean; - audioShuffle: boolean; - audioRepeat: Repeat; - defaultView: string; - librarySort: { - by: SortBy; - order: SortOrder; - }; - // musicFolders: string[], - sleepBlocker: boolean; - autoUpdateChecker: boolean; - minimizeToTray: boolean; - displayNotifications: boolean; - bounds: ConfigBounds; + theme: ThemeIds | '__system'; + audioVolume: number; + audioPlaybackRate: number; + audioOutputDevice: string; + audioMuted: boolean; + audioShuffle: boolean; + audioRepeat: Repeat; + defaultView: string; + librarySort: { + by: SortBy; + order: SortOrder; + }; + // musicFolders: string[], + sleepBlocker: boolean; + autoUpdateChecker: boolean; + minimizeToTray: boolean; + displayNotifications: boolean; + bounds: ConfigBounds; } /** @@ -160,8 +160,8 @@ export interface Config { */ export interface Theme { - _id: ThemeIds; - name: string; - themeSource: Electron.NativeTheme["themeSource"]; - variables: Record; + _id: ThemeIds; + name: string; + themeSource: Electron.NativeTheme['themeSource']; + variables: Record; } From de00847d927ad1acb703ca60f741f04414d11a45 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:17:53 +0200 Subject: [PATCH 13/18] remove SortBy.added --- src/renderer/lib/utils.ts | 1 - src/shared/types/museeks.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/renderer/lib/utils.ts b/src/renderer/lib/utils.ts index 6c5a68cd6..67364ea59 100644 --- a/src/renderer/lib/utils.ts +++ b/src/renderer/lib/utils.ts @@ -113,7 +113,6 @@ export const getDefaultMetadata = (): Track => ({ title: '', genre: [], }, - added: 0, path: '', playCount: 0, title: '', diff --git a/src/shared/types/museeks.ts b/src/shared/types/museeks.ts index 1d744a2fd..3abf2e472 100644 --- a/src/shared/types/museeks.ts +++ b/src/shared/types/museeks.ts @@ -19,7 +19,6 @@ export enum SortBy { TITLE = 'title', DURATION = 'duration', GENRE = 'genre', - ADDED = 'added', } export enum SortOrder { @@ -75,7 +74,6 @@ export interface Track { }; duration: number; genre: string[]; - added: number; loweredMetas: { artist: string[]; album: string; From 5f600a90889396348f2af5e7ddf57a8eb9d4b23c Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 15:21:03 +0200 Subject: [PATCH 14/18] add back the album column --- .../components/TracksListHeader/TracksListHeader.tsx | 6 +++--- src/renderer/store/reducers/library.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index c50976adb..620004122 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -1,9 +1,9 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { connect } from 'react-redux'; import TracksListHeaderCell from '../TracksListHeaderCell/TracksListHeaderCell'; -import { PlayerStatus, SortBy, SortOrder } from '../../../shared/types/museeks'; +import { SortBy, SortOrder } from '../../../shared/types/museeks'; import { RootState } from '../../store/reducers'; import { LibrarySort } from '../../store/reducers/library'; @@ -23,7 +23,7 @@ interface InjectedProps { type Props = OwnProps & InjectedProps; -const LAYOUT_LISTS = ['title', 'duration', 'artist', 'genre']; +const LAYOUT_LISTS = ['title', 'duration', 'album', 'artist', 'genre']; const capitalize = (str: string) => { return str.toUpperCase()[0] + str.substring(1); }; diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index 6287667db..c02b2dda3 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -44,7 +44,7 @@ const initialState: LibraryState = { // I'm not sure if this is a good place for the default settings // FIXME libraryLayoutSettings: config.get('libraryLayoutSettings') || { - visibility: ['title', 'duration', 'artist', 'genre'], + visibility: ['title', 'duration', 'album', 'artist', 'genre'], }, }; From ba7cc7187134ff943172f2a97f990123f2bf1dc4 Mon Sep 17 00:00:00 2001 From: blackshibe Date: Tue, 21 Jun 2022 18:47:16 +0200 Subject: [PATCH 15/18] fix one single unused import --- src/renderer/components/TracksList/TracksList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/components/TracksList/TracksList.tsx b/src/renderer/components/TracksList/TracksList.tsx index 4b64876ba..acbbb8d95 100644 --- a/src/renderer/components/TracksList/TracksList.tsx +++ b/src/renderer/components/TracksList/TracksList.tsx @@ -23,7 +23,6 @@ import { RootState } from '../../store/reducers'; import scrollbarStyles from '../CustomScrollbar/CustomScrollbar.module.css'; import headerStyles from '../Header/Header.module.css'; import styles from './TracksList.module.css'; -import library from 'src/renderer/store/reducers/library'; const { shell } = electron; From e7c1ec0fccf2ca8dff1792ad32626b83409007ac Mon Sep 17 00:00:00 2001 From: blackshibe Date: Wed, 22 Jun 2022 07:23:51 +0200 Subject: [PATCH 16/18] fix the complaints of the eslint demon --- src/renderer/components/TrackRow/TrackRow.tsx | 8 ++++---- .../components/TracksListHeader/TracksListHeader.tsx | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/TrackRow/TrackRow.tsx b/src/renderer/components/TrackRow/TrackRow.tsx index 7a763d617..db7d2b064 100644 --- a/src/renderer/components/TrackRow/TrackRow.tsx +++ b/src/renderer/components/TrackRow/TrackRow.tsx @@ -1,13 +1,13 @@ import React from 'react'; import cx from 'classnames'; -import PlayingIndicator from '../PlayingIndicator/PlayingIndicator'; +import { LibraryLayoutSettings } from '../../store/actions/LibraryActions'; import { parseDuration } from '../../lib/utils'; import { TrackModel } from '../../../shared/types/museeks'; +import PlayingIndicator from '../PlayingIndicator/PlayingIndicator'; import cellStyles from '../TracksListHeader/TracksListHeader.module.css'; import styles from './TrackRow.module.css'; -import { LibraryLayoutSettings } from 'src/renderer/store/actions/LibraryActions'; interface Props { selected: boolean; @@ -117,7 +117,7 @@ export default class TrackRow extends React.PureComponent { [styles.isBelow]: reorderPosition === 'below', }); - let sorts: [boolean, React.ReactElement][] = [ + const sorts: [boolean, React.ReactElement][] = [ [ layout.visibility.includes('title'),
{track.title}
, @@ -140,7 +140,7 @@ export default class TrackRow extends React.PureComponent { ], ]; - let rows: React.ReactElement[] = []; + const rows: React.ReactElement[] = []; sorts.forEach((element) => { if (element[0]) rows.push(element[1]); }); diff --git a/src/renderer/components/TracksListHeader/TracksListHeader.tsx b/src/renderer/components/TracksListHeader/TracksListHeader.tsx index 620004122..e0446b09f 100644 --- a/src/renderer/components/TracksListHeader/TracksListHeader.tsx +++ b/src/renderer/components/TracksListHeader/TracksListHeader.tsx @@ -1,16 +1,15 @@ import React from 'react'; +import { Menu } from '@electron/remote'; import { connect } from 'react-redux'; +import electron from 'electron'; +import { LibraryLayoutSettings, set_context_state } from '../../store/actions/LibraryActions'; import TracksListHeaderCell from '../TracksListHeaderCell/TracksListHeaderCell'; import { SortBy, SortOrder } from '../../../shared/types/museeks'; import { RootState } from '../../store/reducers'; import { LibrarySort } from '../../store/reducers/library'; - -import { Menu } from '@electron/remote'; import styles from './TracksListHeader.module.css'; -import electron from 'electron'; -import { LibraryLayoutSettings, set_context_state } from 'src/renderer/store/actions/LibraryActions'; interface OwnProps { enableSort: boolean; @@ -84,7 +83,7 @@ class TracksListHeader extends React.Component { render() { const { enableSort, sort, layout } = this.props; - let sorts: [boolean, React.ReactElement][] = [ + const sorts: [boolean, React.ReactElement][] = [ [ layout.visibility.includes('title'), { ], ]; - let headers: React.ReactElement[] = []; + const headers: React.ReactElement[] = []; sorts.forEach((element) => { if (element[0]) headers.push(element[1]); }); From 392ba4dda4676cd0089192cdcdf5661ecd33d91d Mon Sep 17 00:00:00 2001 From: blackshibe Date: Wed, 22 Jun 2022 07:37:25 +0200 Subject: [PATCH 17/18] make the css linter happy --- src/renderer/components/Header/Header.module.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/components/Header/Header.module.css b/src/renderer/components/Header/Header.module.css index 00ddea309..5694571f5 100644 --- a/src/renderer/components/Header/Header.module.css +++ b/src/renderer/components/Header/Header.module.css @@ -15,11 +15,10 @@ border-bottom: 1px solid var(--border-color); background-color: var(--header-bg); color: var(--header-color); - padding: 0 10px; + padding: 5px; display: flex; align-items: center; justify-content: space-between; - padding: 5px; flex: 0 0 auto; /* Draggable region (zone able to move the window) */ From 0f009e92a7215204e5bab901b3f637a00482df2d Mon Sep 17 00:00:00 2001 From: blackshibe Date: Wed, 22 Jun 2022 07:58:18 +0200 Subject: [PATCH 18/18] fix #646 --- src/renderer/components/Playlists/Playlist.tsx | 3 +-- src/renderer/store/actions/PlaylistsActions.ts | 1 + src/renderer/store/reducers/library.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/Playlists/Playlist.tsx b/src/renderer/components/Playlists/Playlist.tsx index 3e2063d34..44c70d953 100644 --- a/src/renderer/components/Playlists/Playlist.tsx +++ b/src/renderer/components/Playlists/Playlist.tsx @@ -17,10 +17,9 @@ const Playlist: React.FC = () => { const { tracks, trackPlayingId, playerStatus, playlists, currentPlaylist, libraryLayoutSettings } = useSelector( (state: RootState) => { const { library, player, playlists } = state; - const { search, tracks } = library; - const filteredTracks = filterTracks(tracks.playlist, search); + const filteredTracks = filterTracks(tracks.playlist, search); const currentPlaylist = playlists.list.find((p) => p._id === playlistId); return { diff --git a/src/renderer/store/actions/PlaylistsActions.ts b/src/renderer/store/actions/PlaylistsActions.ts index 9c0c7e9f3..e92382d49 100644 --- a/src/renderer/store/actions/PlaylistsActions.ts +++ b/src/renderer/store/actions/PlaylistsActions.ts @@ -31,6 +31,7 @@ export const load = async (_id: string): Promise => { try { const playlist = await app.db.Playlist.findOneAsync({ _id }); const tracks = await app.db.Track.findAsync({ _id: { $in: playlist.tracks } }); + store.dispatch({ type: types.PLAYLISTS_LOAD_ONE, payload: { diff --git a/src/renderer/store/reducers/library.ts b/src/renderer/store/reducers/library.ts index c02b2dda3..13ade1e8c 100644 --- a/src/renderer/store/reducers/library.ts +++ b/src/renderer/store/reducers/library.ts @@ -51,11 +51,13 @@ const initialState: LibraryState = { export default (state = initialState, action: Action): LibraryState => { switch (action.type) { case types.LIBRARY_REFRESH: { + const prevTracks = state.tracks.playlist; + return { ...state, tracks: { library: [...action.payload.tracks], - playlist: [], + playlist: prevTracks, }, loading: false, };