diff --git a/.eslintrc.js b/.eslintrc.js index 8f3b3ab..b6e1b8e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,8 @@ module.exports = { ], // Add these new rules 'react/jsx-sort-props': ['error'], + // allow console.error and console.warn + 'no-console': ['error', { allow: ['error', 'warn'] }], }, parserOptions: { ecmaVersion: 2022, diff --git a/README.md b/README.md index 1a94048..c69c286 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ This project was built with the following technologies: * [![zustand][zustand.js]][zustand-url] * [![Tailwind][Tailwind.js]][Tailwind-url] * [![Music Metadata][MusicMetadata.js]][MusicMetadata-url] +* [![Gapless 5][Gapless5.js]][Gapless5-url]

(back to top)

@@ -278,7 +279,7 @@ This project was built with the following technologies: - [x] Show stats about your library somewhere, like GB and # of songs - [x] iTunes-like browser with list of artist and album filters - [x] Quiet mode creating bg music to a video/audiobook/podcast in another app -- [ ] True gapless playback +- [x] True gapless playback - [ ] Edit song metadata - [ ] Hide/show columns in the Library List - [ ] Queue a next-up song @@ -399,6 +400,8 @@ Project Link: [https://github.com/johnnyshankman/hihat](https://github.com/johnn [Typescript-url]: https://typescriptlang.org [zustand.js]: https://img.shields.io/badge/Zustand-20232A?style=for-the-badge&logo=javascript&logoColor=007ACC [zustand-url]: [https://typescriptlang.org](https://github.com/pmndrs/zustand)https://github.com/pmndrs/zustand +[Gapless5.js]: https://img.shields.io/badge/Gapless5-20232A?style=for-the-badge&logo=javascript&logoColor=007ACC +[Gapless5-url]: https://github.com/regosen/Gapless-5 diff --git a/package-lock.json b/package-lock.json index d8e9ce1..e93451b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@regosen/gapless-5": "git+https://github.com/johnnyshankman/Gapless-5.git", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-updater": "^6.1.4", @@ -4286,6 +4287,11 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@regosen/gapless-5": { + "version": "1.5.6", + "resolved": "git+ssh://git@github.com/johnnyshankman/Gapless-5.git#b7479f651862fb72460a8933cb125cca029c9277", + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", diff --git a/package.json b/package.json index 038f671..005cbe0 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ } }, "dependencies": { + "@regosen/gapless-5": "git+https://github.com/johnnyshankman/Gapless-5.git", "electron-debug": "^3.2.0", "electron-log": "^4.4.8", "electron-updater": "^6.1.4", diff --git a/regosen-gapless-5-1.5.6.tgz b/regosen-gapless-5-1.5.6.tgz new file mode 100644 index 0000000..be13548 Binary files /dev/null and b/regosen-gapless-5-1.5.6.tgz differ diff --git a/release/app/package-lock.json b/release/app/package-lock.json index 0891246..dc73622 100644 --- a/release/app/package-lock.json +++ b/release/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "hihat", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hihat", - "version": "1.0.0", + "version": "1.1.0", "hasInstallScript": true, "license": "MIT" } diff --git a/release/app/package.json b/release/app/package.json index 816f45d..b127e83 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,6 +1,6 @@ { "name": "hihat", - "version": "1.0.0", + "version": "1.1.0", "description": "A minimalist offline music player for OSX audiophiles :: Based on iTunes circa 2002 :: by White Lights", "license": "MIT", "author": { diff --git a/src/main/main.ts b/src/main/main.ts index 8daf053..94c6f72 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -459,7 +459,6 @@ ipcMain.on('set-last-played-song', async (event, arg: string) => { Object.keys(userConfig.library).forEach((key) => { if (key === songFilePath) { userConfig.library[key].additionalInfo.lastPlayed = Date.now(); - userConfig.library[key].additionalInfo.playCount += 1; } }); @@ -471,6 +470,16 @@ ipcMain.on('set-last-played-song', async (event, arg: string) => { }); }); +/** + * @dev increments the play count of the requested song + * @note expected the store will manually add 1 to the internal play count until reboot + */ +ipcMain.on('increment-play-count', async (event, arg: { song: string }) => { + const userConfig = getUserConfig(); + userConfig.library[arg.song].additionalInfo.playCount += 1; + writeFileSyncToUserConfig(userConfig); +}); + /** * @dev for requesting the modification of a tag of a media file * and then modifying it in the app as well as cache'ing it @@ -947,7 +956,7 @@ const createWindow = async () => { }); /** - * @importnat Set the global asset path to /assets + * @important Set the global asset path to /assets */ const RESOURCES_PATH = app.isPackaged ? path.join(process.resourcesPath, 'assets') diff --git a/src/main/menu.ts b/src/main/menu.ts index 408a926..a9bcdb2 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export default class MenuBuilder { } buildDarwinTemplate(): MenuItemConstructorOptions[] { - const subMenuAbout: DarwinMenuItemConstructorOptions = { + const subMenuHihat: DarwinMenuItemConstructorOptions = { label: 'hihat', submenu: [ { @@ -121,38 +121,30 @@ export default class MenuBuilder { this.mainWindow.webContents.send('menu-rescan-library'); }, }, + { type: 'separator' }, { - label: 'select library folder', - click: () => { - this.mainWindow.webContents.send('menu-select-library'); - }, - }, - { - label: 'hide duplicate songs', + label: 'max volume', + accelerator: 'Command+Up', click: () => { - this.mainWindow.webContents.send('menu-hide-dupes'); + this.mainWindow.webContents.send('menu-max-volume'); }, }, { - label: 'delete duplicate songs', + label: 'mute volume', + accelerator: 'Command+Down', click: () => { - this.mainWindow.webContents.send('menu-delete-dupes'); + this.mainWindow.webContents.send('menu-mute-volume'); }, }, { - label: 'backup / sync library', + label: 'quiet', + accelerator: 'Option+Command+Down', click: () => { - this.mainWindow.webContents.send('menu-backup-library'); + this.mainWindow.webContents.send('menu-quiet-mode'); }, }, - // { - // label: 'reset all hihat data', - // click: () => { - // this.mainWindow.webContents.send('menu-reset-library'); - // }, - // }, - { type: 'separator' }, + { type: 'separator' }, { label: 'see library stats', click: () => { @@ -166,9 +158,7 @@ export default class MenuBuilder { }); }, }, - { type: 'separator' }, - { label: 'hide hihat', accelerator: 'Command+H', @@ -238,6 +228,42 @@ export default class MenuBuilder { this.mainWindow.webContents.send('menu-toggle-browser'); }, }, + { + label: 'reset all hihat data', + click: () => { + this.mainWindow.webContents.send('menu-reset-library'); + }, + }, + ], + }; + const subMenuAdvanced: DarwinMenuItemConstructorOptions = { + label: 'Advanced', + submenu: [ + { + label: 'change library folder', + click: () => { + this.mainWindow.webContents.send('menu-select-library'); + }, + }, + { + label: 'backup / sync library', + click: () => { + this.mainWindow.webContents.send('menu-backup-library'); + }, + }, + { type: 'separator' }, + { + label: 'hide duplicate songs', + click: () => { + this.mainWindow.webContents.send('menu-hide-dupes'); + }, + }, + { + label: 'delete duplicate songs', + click: () => { + this.mainWindow.webContents.send('menu-delete-dupes'); + }, + }, ], }; const subMenuViewProd: MenuItemConstructorOptions = { @@ -299,7 +325,14 @@ export default class MenuBuilder { ? subMenuViewDev : subMenuViewProd; - return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; + return [ + subMenuHihat, + subMenuEdit, + subMenuView, + subMenuAdvanced, + subMenuWindow, + subMenuHelp, + ]; } buildDefaultTemplate() { diff --git a/src/main/preload.ts b/src/main/preload.ts index 2a2e378..c2e030d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -21,11 +21,15 @@ export type Channels = | 'menu-reset-library' | 'menu-hide-dupes' | 'menu-delete-dupes' + | 'menu-max-volume' + | 'menu-quiet-mode' + | 'menu-mute-volume' | 'song-imported' | 'hide-song' | 'delete-song' | 'delete-album' | 'menu-toggle-browser' + | 'increment-play-count' | 'update-store'; export type ArgsBase = Record; @@ -64,6 +68,9 @@ export interface SendMessageArgs extends ArgsBase { 'show-in-finder': { path: string; }; + 'increment-play-count': { + song: string; + }; } export interface ResponseArgs extends ArgsBase { @@ -84,6 +91,9 @@ export interface ResponseArgs extends ArgsBase { scrollToIndex?: number; }; 'backup-library-success': undefined; + 'menu-quiet-mode': undefined; + 'menu-max-volume': undefined; + 'menu-mute-volume': undefined; } const electronHandler = { diff --git a/src/renderer/components/AlbumArt.tsx b/src/renderer/components/AlbumArt.tsx index 69458c3..6fe8582 100644 --- a/src/renderer/components/AlbumArt.tsx +++ b/src/renderer/components/AlbumArt.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import Draggable from 'react-draggable'; import { TinyText } from './SimpleStyledMaterialUIComponents'; import AlbumArtRightClickMenu from './AlbumArtRightClickMenu'; -import usePlayerStore from '../store/player'; +import useMainStore from '../store/main'; import { useWindowDimensions } from '../hooks/useWindowDimensions'; interface AlbumArtProps { @@ -14,20 +14,23 @@ export default function AlbumArt({ setShowAlbumArtMenu, showAlbumArtMenu, }: AlbumArtProps) { + /** + * @dev window provider hook + */ const { width, height } = useWindowDimensions(); /** - * @dev global store hooks + * @dev store hooks */ - const currentSong = usePlayerStore((store) => store.currentSong); - const currentSongMetadata = usePlayerStore( + const currentSong = useMainStore((store) => store.currentSong); + const currentSongMetadata = useMainStore( (store) => store.currentSongMetadata, ); - const currentSongDataURL = usePlayerStore( + const currentSongDataURL = useMainStore( (store) => store.currentSongArtworkDataURL, ); - const filteredLibrary = usePlayerStore((store) => store.filteredLibrary); - const setOverrideScrollToIndex = usePlayerStore( + const filteredLibrary = useMainStore((store) => store.filteredLibrary); + const setOverrideScrollToIndex = useMainStore( (store) => store.setOverrideScrollToIndex, ); @@ -60,7 +63,7 @@ export default function AlbumArt({ if (!currentSongDataURL) { return (
hihat
+ { + if (!width || !height) return; + const newMaxWidth = albumArtMaxWidth + data.deltaY; + const maxWidthBasedOnWidth = Math.min(320, width * 0.4); + const maxWidthBasedOnHeight = Math.min(320, height * 0.6); + const clampedMaxWidth = Math.max( + 120, + Math.min( + newMaxWidth, + maxWidthBasedOnWidth, + maxWidthBasedOnHeight, + ), + ); + setAlbumArtMaxWidth(clampedMaxWidth); + + /** + * @important dispatch an event to let all components know the width has changed + * @see LibraryList.tsx + */ + window.dispatchEvent(new Event('album-art-width-changed')); + }} + position={{ x: 0, y: 0 }} + > +
+
); } diff --git a/src/renderer/components/Browser.tsx b/src/renderer/components/Browser.tsx index a2fa651..337bed4 100644 --- a/src/renderer/components/Browser.tsx +++ b/src/renderer/components/Browser.tsx @@ -4,7 +4,6 @@ import Draggable from 'react-draggable'; import { IconButton } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import usePlayerStore from '../store/player'; import useMainStore from '../store/main'; import { LightweightAudioMetadata } from '../../common/common'; import { useWindowDimensions } from '../hooks/useWindowDimensions'; @@ -22,16 +21,24 @@ const ROW_HEIGHT = 25.5; // Fixed row height const BROWSER_WIDTH = 800; // Fixed browser width export default function Browser({ onClose }: BrowserProps) { + /** + * @dev window provider hook + */ const { width, height } = useWindowDimensions(); - const setFilteredLibrary = usePlayerStore( - (store) => store.setFilteredLibrary, - ); - const setOverrideScrollToIndex = usePlayerStore( + + /** + * @dev store hooks + */ + const setFilteredLibrary = useMainStore((store) => store.setFilteredLibrary); + const setOverrideScrollToIndex = useMainStore( (store) => store.setOverrideScrollToIndex, ); - const currentSong = usePlayerStore((store) => store.currentSong); + const currentSong = useMainStore((store) => store.currentSong); const storeLibrary = useMainStore((store) => store.library); + /** + * @dev component state + */ const [selection, setSelection] = useState({ artist: null, album: null, @@ -45,6 +52,9 @@ export default function Browser({ onClose }: BrowserProps) { height: height ? height * 0.25 : 400, }); + /** + * @dev component refs + */ const browserRef = useRef(null); useEffect(() => { @@ -94,16 +104,14 @@ export default function Browser({ onClose }: BrowserProps) { useEffect(() => { if (!storeLibrary) return; - const filteredSongs = { ...storeLibrary }; - Object.keys(filteredSongs).forEach((key) => { - const song = filteredSongs[key]; - if ( - (selection.artist && song.common.artist !== selection.artist) || - (selection.album && song.common.album !== selection.album) - ) { - delete filteredSongs[key]; - } - }); + const filteredSongs = Object.fromEntries( + Object.entries(storeLibrary).filter(([_, song]) => { + return ( + (!selection.artist || song.common.artist === selection.artist) && + (!selection.album || song.common.album === selection.album) + ); + }), + ); setFilteredLibrary(filteredSongs); }, [selection, storeLibrary, setFilteredLibrary]); diff --git a/src/renderer/components/ImportNewSongsButtons.tsx b/src/renderer/components/ImportNewSongsButtons.tsx index e6fd528..9c26b7d 100644 --- a/src/renderer/components/ImportNewSongsButtons.tsx +++ b/src/renderer/components/ImportNewSongsButtons.tsx @@ -1,6 +1,5 @@ import Tooltip from '@mui/material/Tooltip'; import { LibraryAdd } from '@mui/icons-material'; -import usePlayerStore from '../store/player'; import useMainStore from '../store/main'; interface ImportNewSongsButtonProps { @@ -11,6 +10,8 @@ interface ImportNewSongsButtonProps { } export default function ImportNewSongsButton({ + // @TODO: I dislike this pattern of having to pass all these functions as props + // but it's the only way to get the async import working atm. setShowImportingProgress, setSongsImported, setTotalSongs, @@ -19,15 +20,16 @@ export default function ImportNewSongsButton({ /** * @dev global store hooks */ - const setFilteredLibrary = usePlayerStore( - (store) => store.setFilteredLibrary, - ); + const setFilteredLibrary = useMainStore((store) => store.setFilteredLibrary); const setLibraryInStore = useMainStore((store) => store.setLibrary); - const setOverrideScrollToIndex = usePlayerStore( + const setOverrideScrollToIndex = useMainStore( (store) => store.setOverrideScrollToIndex, ); const importNewSongs = async () => { + setSongsImported(0); + setTotalSongs(1); + window.electron.ipcRenderer.on('song-imported', (args) => { setShowImportingProgress(true); setSongsImported(args.songsImported); @@ -62,8 +64,8 @@ export default function ImportNewSongsButton({ window.setTimeout(() => { setSongsImported(0); - setTotalSongs(0); - }, 100); + setTotalSongs(1); + }, 1000); }); window.electron.ipcRenderer.sendMessage('add-to-library'); diff --git a/src/renderer/components/LibraryList.tsx b/src/renderer/components/LibraryList.tsx index 1bfbd6e..7d3772c 100644 --- a/src/renderer/components/LibraryList.tsx +++ b/src/renderer/components/LibraryList.tsx @@ -13,7 +13,6 @@ import Draggable from 'react-draggable'; import { LightweightAudioMetadata, StoreStructure } from '../../common/common'; import useMainStore from '../store/main'; import { convertToMMSS } from '../utils/utils'; -import usePlayerStore from '../store/player'; import SongRightClickMenu from './SongRightClickMenu'; import { useWindowDimensions } from '../hooks/useWindowDimensions'; @@ -40,10 +39,6 @@ const FILTER_TYPES: FilterTypes[] = [ type FilterDirections = 'asc' | 'desc'; type LibraryListProps = { - /** - * @dev a hook for when the song is double clicked - */ - playSong: (song: string, info: StoreStructure['library'][string]) => void; /** * @dev a hook for when the user wants to import their library */ @@ -60,10 +55,10 @@ type SongMenuState = } | undefined; -export default function LibraryList({ - playSong, - onImportLibrary, -}: LibraryListProps) { +export default function LibraryList({ onImportLibrary }: LibraryListProps) { + /** + * @dev window provider hook + */ const { width, height } = useWindowDimensions(); /** @@ -71,14 +66,16 @@ export default function LibraryList({ */ const initialized = useMainStore((store) => store.initialized); const storeLibrary = useMainStore((store) => store.library); - const currentSong = usePlayerStore((store) => store.currentSong); - const filteredLibrary = usePlayerStore((store) => store.filteredLibrary); - const overrideScrollToIndex = usePlayerStore( + const currentSong = useMainStore((store) => store.currentSong); + const filteredLibrary = useMainStore((store) => store.filteredLibrary); + const overrideScrollToIndex = useMainStore( (store) => store.overrideScrollToIndex, ); - const setOverrideScrollToIndex = usePlayerStore( + const setOverrideScrollToIndex = useMainStore( (store) => store.setOverrideScrollToIndex, ); + const selectSpecificSong = useMainStore((store) => store.selectSpecificSong); + const setFilteredLibrary = useMainStore((store) => store.setFilteredLibrary); /** * @dev component state @@ -121,46 +118,15 @@ export default function LibraryList({ icon: , }, ]); - - useEffect(() => { - // recalculate the width of each column proportionally using staleWidth as the base - if (staleWidth !== width) { - setColumnUXInfo( - columnUXInfo.map((column) => { - if ( - column.id === 'dateAdded' || - column.id === 'playCount' || - column.id === 'duration' - ) { - return column; // Keep these columns at their initial width - } - return { - ...column, - width: Math.max((column.width / staleWidth) * width, 60), - }; - }), - ); - setStaleWidth(width); - } - }, [width]); // eslint-disable-line react-hooks/exhaustive-deps + const [filterType, setFilterType] = useState('artist'); + const [filterDirection, setFilterDirection] = + useState('desc'); + const [songMenu, setSongMenu] = useState(undefined); /** - * @dev anytime overrideScrollToIndex changes, set a timeout to - * reset it to undefined after 200ms. this is to prevent the - * library list from scrolling to the wrong index when the - * library is updated. + * @dev template vars */ - useEffect(() => { - if (overrideScrollToIndex !== undefined) { - setTimeout(() => { - setOverrideScrollToIndex(-1); - }, 200); - } - }, [overrideScrollToIndex, setOverrideScrollToIndex]); - - const setFilteredLibrary = usePlayerStore( - (store) => store.setFilteredLibrary, - ); + const hasSongs = Object.keys(filteredLibrary || {}).length; const updateColumnWidth = (index: number, deltaX: number) => { const newColumnUXInfo = [...columnUXInfo]; @@ -204,14 +170,6 @@ export default function LibraryList({ setColumnUXInfo(newColumnUXInfo); }; - /** - * @dev state - */ - const [filterType, setFilterType] = useState('artist'); - const [filterDirection, setFilterDirection] = - useState('desc'); - const [songMenu, setSongMenu] = useState(undefined); - // create a monolothic filter function that takes in the filter type as the first param const filterLibrary = (filter: FilterTypes): void => { if (isDragging) return; @@ -337,7 +295,7 @@ export default function LibraryList({ setTimeout(() => { const artContainerHeight = document.querySelector('.art')?.clientHeight || 0; - if (currentWidth > 500) { + if (currentWidth > 600) { const newHeight = currentHeight - artContainerHeight - 106; setRowContainerHeight(newHeight); } else { @@ -346,6 +304,42 @@ export default function LibraryList({ }, 100); }; + useEffect(() => { + // recalculate the width of each column proportionally using staleWidth as the base + if (staleWidth !== width) { + setColumnUXInfo( + columnUXInfo.map((column) => { + if ( + column.id === 'dateAdded' || + column.id === 'playCount' || + column.id === 'duration' + ) { + return column; // Keep these columns at their initial width + } + return { + ...column, + width: Math.max((column.width / staleWidth) * width, 60), + }; + }), + ); + setStaleWidth(width); + } + }, [width]); // eslint-disable-line react-hooks/exhaustive-deps + + /** + * @dev anytime overrideScrollToIndex changes, set a timeout to + * reset it to undefined after 200ms. this is to prevent the + * library list from scrolling to the wrong index when the + * library is updated. + */ + useEffect(() => { + if (overrideScrollToIndex !== undefined) { + setTimeout(() => { + setOverrideScrollToIndex(-1); + }, 200); + } + }, [overrideScrollToIndex, setOverrideScrollToIndex]); + /** * @dev update the row container height when * the window is resized in any way. that way our virtualized table @@ -379,6 +373,7 @@ export default function LibraryList({ ); }; }, [height, width]); + /** * @dev render the row for the virtualized table, reps a single song */ @@ -409,7 +404,7 @@ export default function LibraryList({ }); }} onDoubleClick={async () => { - await playSong(song, filteredLibrary[song]); + await selectSpecificSong(song, filteredLibrary); }} style={style} > @@ -471,8 +466,6 @@ export default function LibraryList({ ); }; - const hasSongs = Object.keys(filteredLibrary || {}).length; - return (
{songMenu && ( diff --git a/src/renderer/components/Main.tsx b/src/renderer/components/Main.tsx index dee6fe0..c02278e 100644 --- a/src/renderer/components/Main.tsx +++ b/src/renderer/components/Main.tsx @@ -1,9 +1,7 @@ /* eslint-disable jsx-a11y/media-has-caption */ import React, { useState, useRef, useEffect } from 'react'; import { useResizeDetector } from 'react-resize-detector'; -import { LightweightAudioMetadata } from '../../common/common'; import useMainStore from '../store/main'; -import usePlayerStore from '../store/player'; import LibraryList from './LibraryList'; import StaticPlayer from './StaticPlayer'; import BackupConfirmationDialog from './Dialog/BackupConfirmationDialog'; @@ -18,44 +16,48 @@ import Browser from './Browser'; import { WindowDimensionsProvider } from '../hooks/useWindowDimensions'; import { Channels, ResponseArgs } from '../../main/preload'; -type AlbumArtMenuState = { mouseX: number; mouseY: number } | undefined; - export default function Main() { + /** + * @dev external hooks + */ const { width, height, ref } = useResizeDetector(); - const audioTagRef = useRef(null); - const importNewSongsButtonRef = useRef(null); - // Main store hooks - const storeLibrary = useMainStore((store) => store.library); + /** + * @dev main store hooks + */ const setLibraryInStore = useMainStore((store) => store.setLibrary); - const setLastPlayedSong = useMainStore((store) => store.setLastPlayedSong); const setInitialized = useMainStore((store) => store.setInitialized); - - // Player store hooks - const paused = usePlayerStore((store) => store.paused); - const setPaused = usePlayerStore((store) => store.setPaused); - const shuffle = usePlayerStore((store) => store.shuffle); - const repeating = usePlayerStore((store) => store.repeating); - const currentSong = usePlayerStore((store) => store.currentSong); - const shuffleHistory = usePlayerStore((store) => store.shuffleHistory); - const setShuffleHistory = usePlayerStore((store) => store.setShuffleHistory); - const currentSongMetadata = usePlayerStore( + const currentSong = useMainStore((store) => store.currentSong); + const player = useMainStore((store) => store.player); + const setPaused = useMainStore((store) => store.setPaused); + const paused = useMainStore((store) => store.paused); + const autoPlayNextSong = useMainStore((store) => store.autoPlayNextSong); + const playPreviousSong = useMainStore((store) => store.playPreviousSong); + const skipToNextSong = useMainStore((store) => store.skipToNextSong); + const setVolume = useMainStore((store) => store.setVolume); + const setFilteredLibrary = useMainStore((store) => store.setFilteredLibrary); + const selectSpecificSong = useMainStore((store) => store.selectSpecificSong); + const setCurrentSongTime = useMainStore((store) => store.setCurrentSongTime); + const currentSongTime = useMainStore((store) => store.currentSongTime); + const currentSongMetadata = useMainStore( (store) => store.currentSongMetadata, ); - const setCurrentSong = usePlayerStore((store) => store.setCurrentSong); - const filteredLibrary = usePlayerStore((store) => store.filteredLibrary); - const setFilteredLibrary = usePlayerStore( - (store) => store.setFilteredLibrary, + const setOverrideScrollToIndex = useMainStore( + (store) => store.setOverrideScrollToIndex, ); - const currentSongTime = usePlayerStore((store) => store.currentSongTime); - const setCurrentSongTime = usePlayerStore( - (store) => store.setCurrentSongTime, + const increasePlayCountOfSong = useMainStore( + (store) => store.increasePlayCountOfSong, ); - const setOverrideScrollToIndex = usePlayerStore( - (store) => store.setOverrideScrollToIndex, + const setHasIncreasedPlayCount = useMainStore( + (store) => store.setHasIncreasedPlayCount, + ); + const hasIncreasedPlayCount = useMainStore( + (store) => store.hasIncreasedPlayCount, ); - // Component state + /** + * @dev component state + */ const [dialogState, setDialogState] = useState({ showImportingProgress: false, showDedupingProgress: false, @@ -66,106 +68,27 @@ export default function Main() { }); const [importState, setImportState] = useState({ songsImported: 0, - totalSongs: 0, + totalSongs: 1, estimatedTimeRemainingString: '', }); - const [showAlbumArtMenu, setShowAlbumArtMenu] = useState(); - - const playSong = async (song: string, meta: LightweightAudioMetadata) => { - const mediaData = { - title: meta.common.title, - artist: meta.common.artist, - album: meta.common.album, - }; - - if (navigator.mediaSession.metadata) { - Object.assign(navigator.mediaSession.metadata, mediaData); - } else { - navigator.mediaSession.metadata = new MediaMetadata(mediaData); - } - - setCurrentSong(song, storeLibrary); - setLastPlayedSong(song); - - window.electron.ipcRenderer.once('set-last-played-song', (args) => { - const newLibrary = { ...storeLibrary, [args.song]: args.songData }; - setLibraryInStore(newLibrary); - setFilteredLibrary({ ...filteredLibrary, [args.song]: args.songData }); - }); - - window.electron.ipcRenderer.sendMessage('set-last-played-song', song); - setPaused(false); - }; - - const startCurrentSongOver = () => - // eslint-disable-next-line consistent-return - new Promise((resolve, reject) => { - // eslint-disable-next-line no-promise-executor-return - if (!currentSong || !currentSongMetadata) return reject(); - setCurrentSong('', filteredLibrary); - setTimeout(() => { - playSong(currentSong, currentSongMetadata); - resolve(null); - }, 10); - }); - - const playNextSong = async () => { - if (!filteredLibrary) return; - - const keys = Object.keys(filteredLibrary); - const currentIndex = keys.indexOf(currentSong || ''); - - if (repeating && currentSong && currentSongMetadata) { - await startCurrentSongOver(); - return; - } - - if (shuffle) { - const randomIndex = Math.floor(Math.random() * keys.length); - const randomSong = keys[randomIndex]; - await playSong(randomSong, filteredLibrary[randomSong]); - setOverrideScrollToIndex(randomIndex); - setShuffleHistory([...shuffleHistory, currentSong]); - return; - } - - const nextIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; - const nextSong = keys[nextIndex]; - await playSong(nextSong, filteredLibrary[nextSong]); - }; - - const playPreviousSong = async () => { - if (!filteredLibrary) return; - - if (!paused && currentSong && currentSongMetadata && currentSongTime > 2) { - await startCurrentSongOver(); - return; - } - - const keys = Object.keys(filteredLibrary); - const currentIndex = keys.indexOf(currentSong || ''); - - if (repeating && currentSong && currentSongMetadata) { - await startCurrentSongOver(); - return; - } - - if (shuffle && shuffleHistory.length > 0) { - const previousSong = shuffleHistory[shuffleHistory.length - 1]; - await playSong(previousSong, filteredLibrary[previousSong]); - setOverrideScrollToIndex(keys.indexOf(previousSong)); - setShuffleHistory(shuffleHistory.slice(0, -1)); - return; - } + const [showAlbumArtMenu, setShowAlbumArtMenu] = useState< + { mouseX: number; mouseY: number } | undefined + >(); - const prevIndex = currentIndex - 1 < 0 ? keys.length - 1 : currentIndex - 1; - const prevSong = keys[prevIndex]; - await playSong(prevSong, filteredLibrary[prevSong]); - }; + /** + * @dev components refs + */ + const importNewSongsButtonRef = useRef(null); const importNewLibrary = async (rescan = false) => { setDialogState((prev) => ({ ...prev, showImportingProgress: true })); + setImportState((prev) => ({ + ...prev, + songsImported: 0, + totalSongs: 1, + })); + window.electron.ipcRenderer.sendMessage('select-library', { rescan, }); @@ -177,23 +100,33 @@ export default function Main() { totalSongs: args.totalSongs, })); - // Calculate estimated time remaining - const timePerSong = 0.1; // seconds per song (approximate) - const remainingSongs = args.totalSongs - args.songsImported; - const estimatedSeconds = remainingSongs * timePerSong; - const minutes = Math.floor(estimatedSeconds / 60); - const seconds = Math.floor(estimatedSeconds % 60); + // Average time per song based on testing: + // - ~3ms for metadata parsing + // - ~6ms for file copy (if needed) + // - ~1ms overhead + const avgTimePerSong = 10; // milliseconds + const estimatedTimeRemaining = Math.floor( + (args.totalSongs - args.songsImported) * avgTimePerSong, + ); + + const minutes = Math.floor(estimatedTimeRemaining / 60000); + const seconds = Math.floor((estimatedTimeRemaining % 60000) / 1000); + + const timeRemainingString = + // eslint-disable-next-line no-nested-ternary + minutes < 1 + ? seconds === 0 + ? 'Processing Metadata...' + : `${seconds}s left` + : `${minutes}m ${seconds}s left`; setImportState((prev) => ({ ...prev, - estimatedTimeRemainingString: `${minutes}:${seconds - .toString() - .padStart(2, '0')}`, + estimatedTimeRemainingString: timeRemainingString, })); }); window.electron.ipcRenderer.once('select-library', (store) => { - setInitialized(true); if (store) { setLibraryInStore(store.library); setFilteredLibrary(store.library); @@ -202,6 +135,11 @@ export default function Main() { }); }; + /** + * Set up event listeners for the main process to communicate with the renderer + * process. Also set up window event listeners for custom events from the + * renderer process. + */ useEffect(() => { const handlers = { initialize: (arg: ResponseArgs['initialize']) => { @@ -209,12 +147,16 @@ export default function Main() { setLibraryInStore(arg.library); setFilteredLibrary(arg.library); if (arg.lastPlayedSong) { - setCurrentSong(arg.lastPlayedSong, arg.library); const songIndex = Object.keys(arg.library).findIndex( (song) => song === arg.lastPlayedSong, ); + selectSpecificSong(arg.lastPlayedSong, arg.library); setOverrideScrollToIndex(songIndex); } + + player.onfinishedtrack = autoPlayNextSong; + + setPaused(true); }, 'update-store': (arg: ResponseArgs['update-store']) => { setInitialized(true); @@ -266,6 +208,15 @@ export default function Main() { 'menu-reset-library': () => { window.electron.ipcRenderer.sendMessage('menu-reset-library'); }, + 'menu-max-volume': () => { + setVolume(100); + }, + 'menu-mute-volume': () => { + setVolume(0); + }, + 'menu-quiet-mode': () => { + setVolume(2); + }, }; Object.entries(handlers).forEach(([event, handler]) => { @@ -276,31 +227,132 @@ export default function Main() { setDialogState((prev) => ({ ...prev, showBrowser: true })); }); - navigator.mediaSession.setActionHandler('previoustrack', playPreviousSong); - navigator.mediaSession.setActionHandler('nexttrack', playNextSong); - return () => { // Cleanup handlers if needed in the future here }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + /** + * @dev Since we are using Gapless5, we must handle the media session's + * position state and actions manually. + * + * We loop a blank audio file in a hidden audio tag to keep the media + * session alive indefinitely. We keep the hidden audio tag in sync with + * the gapless5 player so that the media session's state always matches + * the actual audio player's state. + */ + useEffect(() => { + if (!navigator.mediaSession) { + console.error('Media session not supported'); + return () => {}; + } + + navigator.mediaSession.setActionHandler('previoustrack', playPreviousSong); + navigator.mediaSession.setActionHandler('nexttrack', skipToNextSong); + navigator.mediaSession.setActionHandler('play', () => { + setPaused(false); + document.querySelector('audio')?.play(); + }); + navigator.mediaSession.setActionHandler('pause', () => { + setPaused(true); + document.querySelector('audio')?.pause(); + }); + navigator.mediaSession.setActionHandler('seekto', (seekDetails) => { + setCurrentSongTime(seekDetails.seekTime || 0); + player.setPosition((seekDetails.seekTime || 0) * 1000); + }); + + try { + const duration = currentSongMetadata?.format?.duration || 0; + const safePosition = Math.min(currentSongTime, duration); + navigator.mediaSession.setPositionState({ + duration, + playbackRate: 1, + position: safePosition, + }); + } catch (error) { + console.error('Failed to update media session position state:', error); + } + /** + * @important text and album artwork metadata for the media session + * are handled by the main store when songs change etc. NOT HERE. + * @see updateMediaSessionMetadata + * @see store/main.ts + */ + return () => { + navigator.mediaSession.setActionHandler('previoustrack', null); + navigator.mediaSession.setActionHandler('nexttrack', null); + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + navigator.mediaSession.setActionHandler('seekto', null); + }; + }, [ + paused, + skipToNextSong, + playPreviousSong, + setPaused, + currentSongTime, + currentSongMetadata, + player, + setCurrentSongTime, + ]); + + /** + * @important this handles requesting the play count to be incremented. + * if the song has ever actively played passed the 10 second mark, + * we increment the play count both internally and in the user config. + * @important we manually unset the hasIncreasedPlayCount flag + * from the store when the user hits next, selects a new song, etc. + * so that we don't increment the play count multiple times for the same song. + * @caveat this means that if the user skips to the 20s mark and then + * plays the song for 1s, it will increment the play count. + * @note this is based on an avg of spotiy vs apple music vs itunes. + * itunes did it instantly on start, spotify requires 30 seconds of play, + * and apple music is like my algorithm but with a 20s threshold not 10s + */ + useEffect(() => { + let lastUpdate = 0; + + player.ontimeupdate = ( + currentTrackTime: number, + _currentTrackIndex: number, + ) => { + const now = Date.now(); + // throttle updates to once every 500ms instead of every 1-10ms + if (now - lastUpdate >= 500) { + setCurrentSongTime(currentTrackTime / 1000); + if ( + !hasIncreasedPlayCount && + currentTrackTime >= 10000 && + currentSong && + !paused + ) { + increasePlayCountOfSong(currentSong); + setHasIncreasedPlayCount(true); + } + + lastUpdate = now; + } + }; + }, [ + currentSong, + increasePlayCountOfSong, + player, + setCurrentSongTime, + setHasIncreasedPlayCount, + hasIncreasedPlayCount, + paused, + ]); + return (
+
diff --git a/src/renderer/components/SearchBar.tsx b/src/renderer/components/SearchBar.tsx index b3287c1..ee5144b 100644 --- a/src/renderer/components/SearchBar.tsx +++ b/src/renderer/components/SearchBar.tsx @@ -3,7 +3,6 @@ import Box from '@mui/material/Box'; import SearchIcon from '@mui/icons-material/Search'; import { StoreStructure } from '../../common/common'; import useMainStore from '../store/main'; -import usePlayerStore from '../store/player'; import { Search, SearchIconWrapper, @@ -17,9 +16,7 @@ type SearchBarProps = { export default function SearchBar({ className }: SearchBarProps) { const storeLibrary = useMainStore((store) => store.library); - const setFilteredLibrary = usePlayerStore( - (store) => store.setFilteredLibrary, - ); + const setFilteredLibrary = useMainStore((store) => store.setFilteredLibrary); const handleSearch = (event: React.ChangeEvent) => { const query = event.target.value; diff --git a/src/renderer/components/SongProgressBar.tsx b/src/renderer/components/SongProgressAndSongDisplay.tsx similarity index 75% rename from src/renderer/components/SongProgressBar.tsx rename to src/renderer/components/SongProgressAndSongDisplay.tsx index a121600..baacbee 100644 --- a/src/renderer/components/SongProgressBar.tsx +++ b/src/renderer/components/SongProgressAndSongDisplay.tsx @@ -1,52 +1,79 @@ -import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Slider from '@mui/material/Slider'; import { Tooltip } from '@mui/material'; import Marquee from 'react-fast-marquee'; import { LessOpaqueTinyText } from './SimpleStyledMaterialUIComponents'; -import usePlayerStore from '../store/player'; +import useMainStore from '../store/main'; import { useWindowDimensions } from '../hooks/useWindowDimensions'; -export default function SongProgressBar({ - value, - onManualChange, - max, -}: { - value: number; - onManualChange: (value: number) => void; - max: number; -}) { - const [position, setPosition] = React.useState(32); - const [isScrolling, setIsScrolling] = React.useState(false); - const [isArtistScrolling, setIsArtistScrolling] = React.useState(false); +export default function SongProgressAndSongDisplay() { + /** + * @dev component state + */ + const [isScrolling, setIsScrolling] = useState(false); + const [isArtistScrolling, setIsArtistScrolling] = useState(false); + + /** + * @dev window provider hook + */ const { width, height } = useWindowDimensions(); - const filteredLibrary = usePlayerStore((state) => state.filteredLibrary); - const currentSongMetadata = usePlayerStore( + /** + * @dev main store hooks + */ + const filteredLibrary = useMainStore((state) => state.filteredLibrary); + const setCurrentSongTime = useMainStore((store) => store.setCurrentSongTime); + const currentSongTime = useMainStore((store) => store.currentSongTime); + const player = useMainStore((store) => store.player); + const currentSongMetadata = useMainStore( (state) => state.currentSongMetadata, ); - const setOverrideScrollToIndex = usePlayerStore( + const setOverrideScrollToIndex = useMainStore( (store) => store.setOverrideScrollToIndex, ); - const titleRef = React.useRef(null); - const titleRef2 = React.useRef(null); - const artistRef = React.useRef(null); - const artistRef2 = React.useRef(null); - function convertToMMSS(timeInSeconds: number) { + const max = currentSongMetadata?.format?.duration || 0; + + /** + * @dev component refs + */ + const titleRef = useRef(null); + const titleRef2 = useRef(null); + const artistRef = useRef(null); + const artistRef2 = useRef(null); + + const convertToMMSS = (timeInSeconds: number) => { const minutes = Math.floor(timeInSeconds / 60); const seconds = Math.floor(timeInSeconds % 60); // Ensuring the format is two-digits both for minutes and seconds return `${minutes.toString().padStart(2, '0')}:${seconds .toString() .padStart(2, '0')}`; - } + }; - React.useEffect(() => { - setPosition(value); - }, [value]); + /** + * on click, scroll to the song in the library if possible, + * then try to scroll to the artist if the song is not found + */ + const scrollToSong = () => { + const libraryArray = Object.values(filteredLibrary); + let index = libraryArray.findIndex( + (song) => + song.common.title === currentSongMetadata.common?.title && + song.common.artist === currentSongMetadata.common?.artist && + song.common.album === currentSongMetadata.common?.album, + ); - React.useEffect(() => { + if (index === -1) { + index = libraryArray.findIndex( + (song) => song.common.artist === currentSongMetadata.common?.artist, + ); + } + setOverrideScrollToIndex(index); + }; + + useEffect(() => { const checkOverflow1 = () => { if (titleRef.current) { // requires going up three parent elements to get out of the marquee @@ -75,7 +102,7 @@ export default function SongProgressBar({ }; }, [currentSongMetadata.common?.title, width, height]); - React.useEffect(() => { + useEffect(() => { const checkOverflow2 = () => { if (titleRef2.current) { // requires going up three parent elements to get out of the marquee @@ -104,7 +131,7 @@ export default function SongProgressBar({ }; }, [currentSongMetadata.common?.title, width, height]); - React.useEffect(() => { + useEffect(() => { const checkOverflow3 = () => { if (artistRef.current) { // requires going up three parent elements to get out of the marquee @@ -130,7 +157,7 @@ export default function SongProgressBar({ }; }, [currentSongMetadata.common?.artist, width, height]); - React.useEffect(() => { + useEffect(() => { const checkOverflow4 = () => { if (artistRef2.current) { // requires going up three parent elements to get out of the marquee @@ -174,18 +201,7 @@ export default function SongProgressBar({ { - /** - * on click, scroll to the song title in the library - */ - const libraryArray = Object.values(filteredLibrary); - const index = libraryArray.findIndex( - (song) => - song.common.title === currentSongMetadata.common?.title && - song.common.artist === currentSongMetadata.common?.artist && - song.common.album === currentSongMetadata.common?.album, - ); - - setOverrideScrollToIndex(index); + scrollToSong(); }} sx={{ margin: 0, @@ -227,8 +243,11 @@ export default function SongProgressBar({ max={max} min={0} onChange={(_, val) => { - setPosition(val as number); - onManualChange(val as number); + // manually update the player's time BUT ALSO the internal state + // to ensure that the UX feels snappy. otherwise the UX wouldn't update + // until the next ontimeupdate event fired. + setCurrentSongTime(val as number); + player.setPosition((val as number) * 1000); }} size="small" step={1} @@ -240,7 +259,7 @@ export default function SongProgressBar({ width: 8, }, }} - value={position} + value={currentSongTime} /> - {convertToMMSS(position)} + {convertToMMSS(currentSongTime)} { - /** - * on click, scroll to the artist in the library - */ - const libraryArray = Object.values(filteredLibrary); - const index = libraryArray.findIndex( - (song) => - song.common.artist === currentSongMetadata.common?.artist, - ); - setOverrideScrollToIndex(index); + scrollToSong(); }} sx={{ margin: 0, @@ -293,7 +304,7 @@ export default function SongProgressBar({ )} - -{convertToMMSS(max - position)} + -{convertToMMSS(max - currentSongTime)} diff --git a/src/renderer/components/SongRightClickMenu.tsx b/src/renderer/components/SongRightClickMenu.tsx index 5c29b6e..6fb66e9 100644 --- a/src/renderer/components/SongRightClickMenu.tsx +++ b/src/renderer/components/SongRightClickMenu.tsx @@ -14,8 +14,14 @@ type SongRightClickMenuProps = { mouseY: number; }; -export default function SongRightClickMenu(props: SongRightClickMenuProps) { - const { anchorEl, onClose, song, songInfo, mouseX, mouseY } = props; +export default function SongRightClickMenu({ + anchorEl, + onClose, + song, + songInfo, + mouseX, + mouseY, +}: SongRightClickMenuProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showHideDialog, setShowHideDialog] = useState(false); const [showDeleteAlbumDialog, setShowDeleteAlbumDialog] = useState(false); diff --git a/src/renderer/components/StaticPlayer.tsx b/src/renderer/components/StaticPlayer.tsx index f5ad680..b7aabf1 100644 --- a/src/renderer/components/StaticPlayer.tsx +++ b/src/renderer/components/StaticPlayer.tsx @@ -10,41 +10,27 @@ import ShuffleOnIcon from '@mui/icons-material/ShuffleOn'; import RepeatIcon from '@mui/icons-material/Repeat'; import Stack from '@mui/material/Stack'; import RepeatOnIcon from '@mui/icons-material/RepeatOn'; -import SpatialAudioIcon from '@mui/icons-material/SpatialAudio'; import VolumeSliderStack from './VolumeSliderStack'; -import SongProgressBar from './SongProgressBar'; -import usePlayerStore from '../store/player'; +import SongProgressAndSongDisplay from './SongProgressAndSongDisplay'; +import useMainStore from '../store/main'; -type StaticPlayerProps = { - audioTagRef: React.RefObject; - playNextSong: () => void; - playPreviousSong: () => void; -}; - -export default function StaticPlayer({ - audioTagRef, - playNextSong, - playPreviousSong, -}: StaticPlayerProps) { +export default function StaticPlayer() { /** * @dev store */ - const repeating = usePlayerStore((state) => state.repeating); - const setRepeating = usePlayerStore((state) => state.setRepeating); - const shuffle = usePlayerStore((state) => state.shuffle); - const setShuffle = usePlayerStore((state) => state.setShuffle); - const paused = usePlayerStore((state) => state.paused); - const setPaused = usePlayerStore((state) => state.setPaused); - // @note this is used as state to know when the song's info is done loading - const currentSongMetadata = usePlayerStore( + const repeating = useMainStore((state) => state.repeating); + const setRepeating = useMainStore((state) => state.setRepeating); + const shuffle = useMainStore((state) => state.shuffle); + const setShuffle = useMainStore((state) => state.setShuffle); + const paused = useMainStore((state) => state.paused); + const setPaused = useMainStore((state) => state.setPaused); + const volume = useMainStore((state) => state.volume); + const setVolume = useMainStore((state) => state.setVolume); + const currentSongMetadata = useMainStore( (state) => state.currentSongMetadata, ); - const volume = usePlayerStore((state) => state.volume); - const setVolume = usePlayerStore((state) => state.setVolume); - const currentSongTime = usePlayerStore((state) => state.currentSongTime); - const setCurrentSongTime = usePlayerStore( - (state) => state.setCurrentSongTime, - ); + const playPreviousSong = useMainStore((store) => store.playPreviousSong); + const skipToNextSong = useMainStore((store) => store.skipToNextSong); return (
- - { - // set the volume to 0.02 so you can listen to a podcast or hear the person next to you - setVolume(2); - }} - sx={{ - fontSize: '1rem', - color: 'rgb(133,133,133)', - }} - > - - -
{/** @@ -143,19 +115,6 @@ export default function StaticPlayer({ disabled={!currentSongMetadata} onClick={() => { setPaused(!paused); - if (audioTagRef.current) { - /** - * @important we manually trigger play/pause via the tag - * because the player store's state will automatically sync - * itself to the
{ diff --git a/src/renderer/components/VolumeSliderStack.tsx b/src/renderer/components/VolumeSliderStack.tsx index 050cc4a..15a0ffd 100644 --- a/src/renderer/components/VolumeSliderStack.tsx +++ b/src/renderer/components/VolumeSliderStack.tsx @@ -5,7 +5,7 @@ import Slider from '@mui/material/Slider'; import VolumeDown from '@mui/icons-material/VolumeDown'; import VolumeUp from '@mui/icons-material/VolumeUp'; import IconButton from '@mui/material/IconButton'; -import usePlayerStore from '../store/player'; +import useMainStore from '../store/main'; export default function VolumeSliderStack({ onChange, @@ -14,13 +14,14 @@ export default function VolumeSliderStack({ onChange: (event: Event, newValue: number | number[]) => void; value: number; }) { - const setVolume = usePlayerStore((store) => store.setVolume); + const setVolume = useMainStore((store) => store.setVolume); + const handleChange = (event: Event, newValue: number | number[]) => { onChange(event, newValue); }; return ( - + ({ /** - * @note these default dimensions come from the window main.ts + * @note these default dimensions come from the window in main/main.ts */ width: 1024, height: 1024, diff --git a/src/renderer/store/main.ts b/src/renderer/store/main.ts index 50f8ff4..faf82ac 100644 --- a/src/renderer/store/main.ts +++ b/src/renderer/store/main.ts @@ -1,47 +1,505 @@ import { create } from 'zustand'; +import { Gapless5 } from '@regosen/gapless-5'; import { LightweightAudioMetadata, StoreStructure } from '../../common/common'; +import { + bufferToDataUrl, + findNextSong, + updateMediaSession, +} from '../utils/utils'; -interface AdditionalActions { +interface StoreActions { + // Main store actions deleteEverything: () => void; setLibrary: (library: { [key: string]: LightweightAudioMetadata }) => void; - setLastPlayedSong: (song: string) => void; setLibraryPath: (path: string) => void; setInitialized: (initialized: boolean) => void; + setLastPlayedSong: (song: string) => void; + + // Player store actions + setVolume: (volume: number) => void; + setPaused: (paused: boolean) => void; + setShuffle: (shuffle: boolean) => void; + setRepeating: (repeating: boolean) => void; + selectSpecificSong: ( + songPath: string, + library?: { [key: string]: LightweightAudioMetadata }, + ) => void; + setCurrentSongTime: (time: number) => void; + setFilteredLibrary: (filteredLibrary: { + [key: string]: LightweightAudioMetadata; + }) => void; + setOverrideScrollToIndex: (index: number | undefined) => void; + setShuffleHistory: (history: string[]) => void; + skipToNextSong: () => void; + autoPlayNextSong: () => Promise; + playPreviousSong: () => Promise; + increasePlayCountOfSong: (songPath: string) => void; + setHasIncreasedPlayCount: (hasIncreasedPlayCount: boolean) => void; } -const useMainStore = create((set) => ({ - /** - * StoreStructure - */ +interface StoreState extends StoreStructure { + // Player specific state + player: Gapless5; + paused: boolean; + currentSong: string; + currentSongArtworkDataURL: string; + currentSongMetadata: LightweightAudioMetadata; + shuffle: boolean; + repeating: boolean; + volume: number; + currentSongTime: number; + filteredLibrary: { [key: string]: LightweightAudioMetadata }; + overrideScrollToIndex: number; + shuffleHistory: string[]; + hasIncreasedPlayCount: boolean; +} + +const useMainStore = create((set) => ({ + // StoreStructure state library: {}, playlists: [], lastPlayedSong: '', libraryPath: '', initialized: false, - /** - * AdditionalActions - */ + // Player state + player: new Gapless5({ + useHTML5Audio: false, + crossfade: 25, + exclusive: true, + loadLimit: 3, + }), + paused: false, + currentSong: '', + currentSongArtworkDataURL: '', + currentSongMetadata: {} as LightweightAudioMetadata, + shuffle: false, + repeating: false, + volume: 100, + currentSongTime: 0, + filteredLibrary: {}, + overrideScrollToIndex: -1, + shuffleHistory: [], + hasIncreasedPlayCount: false, + // Main store actions deleteEverything: () => set({}, true), - setLibrary: (library: { [key: string]: LightweightAudioMetadata }) => { - // @dev: library source of truth is the BE so this only updates the FE store - return set({ - library, + setLibrary: (library) => set({ library }), + setLibraryPath: (libraryPath) => set({ libraryPath }), + setInitialized: (initialized) => set({ initialized }), + setLastPlayedSong: (lastPlayedSong) => set({ lastPlayedSong }), + setHasIncreasedPlayCount: (hasIncreasedPlayCount) => + set({ hasIncreasedPlayCount }), + // Player store actions + setVolume: (volume) => { + return set((state) => { + state.player.setVolume(volume / 100); + return { volume }; + }); + }, + + setPaused: (paused) => { + return set((state) => { + if (paused) { + state.player.pause(); + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.pause(); + } + } else { + state.player.play(); + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.play(); + } + } + return { paused }; + }); + }, + + setShuffle: (shuffle) => { + return set((state) => { + const nextSong = findNextSong( + state.currentSong, + state.filteredLibrary, + shuffle, + ); + + // Get the current track index + const currentIndex = state.player.getIndex(); + + // Replace the last track with the new next song + state.player.replaceTrack( + currentIndex + 1, + `my-magic-protocol://getMediaFile/${nextSong.songPath}`, + ); + + // Seed the shuffle history with the current song that is playing + const shuffleHistory = shuffle ? [state.currentSong] : []; + + return { shuffle, shuffleHistory }; + }); + }, + + setRepeating: (repeating) => { + return set((state) => { + state.player.singleMode = repeating; + return { repeating }; + }); + }, + + increasePlayCountOfSong: (songPath: string) => { + return set((state) => { + const newLibrary = { + ...state.library, + [songPath]: { + ...state.library[songPath], + additionalInfo: { + ...state.library[songPath].additionalInfo, + playCount: state.library[songPath].additionalInfo.playCount + 1, + }, + }, + }; + const newFilteredLibrary = { + ...state.filteredLibrary, + [songPath]: { + ...state.filteredLibrary[songPath], + additionalInfo: { + ...state.filteredLibrary[songPath].additionalInfo, + playCount: + state.filteredLibrary[songPath].additionalInfo.playCount + 1, + }, + }, + }; + + // @note: ensures the userConfig is updated for next boot of app + window.electron.ipcRenderer.sendMessage('increment-play-count', { + song: songPath, + }); + + return { library: newLibrary, filteredLibrary: newFilteredLibrary }; + }); + }, + + selectSpecificSong: (songPath, library) => { + return set((state) => { + if (!library) { + console.error('No library provided to setCurrentSongWithDetails'); + return {}; + } + + const songLibrary = library; + const metadata = songLibrary[songPath]; + + if (!metadata) { + console.warn('No metadata found for requested song:', songPath); + return {}; + } + + // Update shuffle history if needed, never longer than 100 items + let shuffleHistory: string[] = []; + if (state.shuffle) { + shuffleHistory = [...state.shuffleHistory, songPath]; + if (shuffleHistory.length > 100) { + shuffleHistory.shift(); + } + } + + const nextSong = findNextSong(songPath, library, state.shuffle); + + state.player.pause(); + state.player.removeAllTracks(); + state.player.addTrack(`my-magic-protocol://getMediaFile/${songPath}`); + state.player.addTrack( + `my-magic-protocol://getMediaFile/${nextSong.songPath}`, + ); + state.player.play(); + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.play(); + } + + updateMediaSession(metadata); + + window.electron.ipcRenderer.once('get-album-art', async (event) => { + let url = ''; + if (event.data) { + url = await bufferToDataUrl(event.data, event.format); + } + + set({ currentSongArtworkDataURL: url }); + + if (navigator.mediaSession.metadata?.artwork) { + navigator.mediaSession.metadata.artwork = [ + { + src: url, + sizes: '192x192', + type: event.format, + }, + ]; + } + }); + + window.electron.ipcRenderer.sendMessage('get-album-art', songPath); + window.electron.ipcRenderer.sendMessage('set-last-played-song', songPath); + + return { + currentSong: songPath, + currentSongMetadata: metadata, + paused: false, + currentSongTime: 0, + hasIncreasedPlayCount: false, + shuffleHistory, + }; }); }, - setLastPlayedSong: (song: string) => { - return set({ - lastPlayedSong: song, + + setCurrentSongTime: (currentSongTime) => set({ currentSongTime }), + setFilteredLibrary: (filteredLibrary) => set({ filteredLibrary }), + setOverrideScrollToIndex: (overrideScrollToIndex) => + set({ overrideScrollToIndex }), + setShuffleHistory: (shuffleHistory) => set({ shuffleHistory }), + + /** + * Used for when you want to skip to the next song without + * having to wait for the current song to finish. + */ + skipToNextSong: () => { + return set((state) => { + if (state.repeating) { + state.player.setPosition(0); + return { + currentSongTime: 0, + hasIncreasedPlayCount: false, + }; + } + + // If shuffle is on, directly find the next shuffled song + const nextSong = findNextSong( + state.currentSong, + state.filteredLibrary, + state.shuffle, + ); + + // Update shuffle history if needed, never longer than 100 items + let shuffleHistory: string[] = []; + if (state.shuffle) { + shuffleHistory = [...state.shuffleHistory, nextSong.songPath]; + if (shuffleHistory.length > 100) { + shuffleHistory.shift(); + } + } + + // Calculate the song to play after this one + const futureNextSong = findNextSong( + nextSong.songPath, + state.filteredLibrary, + state.shuffle, + ); + + // First pause current playback + state.player.pause(); + state.player.removeAllTracks(); + + // Add the next song and future next song + state.player.addTrack( + `my-magic-protocol://getMediaFile/${nextSong.songPath}`, + ); + state.player.addTrack( + `my-magic-protocol://getMediaFile/${futureNextSong.songPath}`, + ); + + if (!state.paused) { + state.player.play(); + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.play(); + } + } + + updateMediaSession(state.filteredLibrary[nextSong.songPath]); + + // handle album art request + window.electron.ipcRenderer.once('get-album-art', async (event) => { + let url = ''; + if (event.data) { + url = await bufferToDataUrl(event.data, event.format); + } + set({ currentSongArtworkDataURL: url }); + + if (navigator.mediaSession.metadata?.artwork) { + navigator.mediaSession.metadata.artwork = [ + { + src: url, + sizes: '192x192', + type: event.format, + }, + ]; + } + }); + + // request album art + window.electron.ipcRenderer.sendMessage( + 'get-album-art', + nextSong.songPath, + ); + + // update last played song in user config + window.electron.ipcRenderer.sendMessage( + 'set-last-played-song', + nextSong.songPath, + ); + + return { + currentSong: nextSong.songPath, + currentSongMetadata: state.filteredLibrary[nextSong.songPath], + currentSongTime: 0, + shuffleHistory, + lastPlayedSong: nextSong.songPath, + hasIncreasedPlayCount: false, + }; }); }, - setLibraryPath: (path: string) => { - return set({ - libraryPath: path, + /** + * Used for when the current song finishes playing. + * Needs to be debounced to avoid a second call before the removeTrack call + * finishes. + */ + autoPlayNextSong: async () => { + return set((state) => { + if (state.repeating) { + state.player.gotoTrack(0); + state.player.play(); + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.play(); + } + return { + currentSongTime: 0, + hasIncreasedPlayCount: false, + }; + } + + // this is already up by one + const tracks = state.player.getTracks(); + const index = state.player.getIndex(); + + // Get next song info before removing anything + const nextSongPath = tracks[index].replace( + 'my-magic-protocol://getMediaFile/', + '', + ); + const nextSongMetadata = state.filteredLibrary[nextSongPath]; + + // Calculate the song to play after this one + const futureNextSong = findNextSong( + nextSongPath, + state.filteredLibrary, + state.shuffle, + ); + + // Update shuffle history if needed, never longer than 100 items + let shuffleHistory: string[] = []; + if (state.shuffle) { + shuffleHistory = [...state.shuffleHistory, nextSongPath]; + if (shuffleHistory.length > 100) { + shuffleHistory.shift(); + } + } + + // Add the future next song + state.player.addTrack( + `my-magic-protocol://getMediaFile/${futureNextSong.songPath}`, + ); + + // Update media session + updateMediaSession(nextSongMetadata); + + // handle album art request + window.electron.ipcRenderer.once('get-album-art', async (event) => { + let url = ''; + if (event.data) { + url = await bufferToDataUrl(event.data, event.format); + } + set({ currentSongArtworkDataURL: url }); + if (navigator.mediaSession.metadata?.artwork) { + navigator.mediaSession.metadata.artwork = [ + { + src: url, + sizes: '192x192', + type: event.format, + }, + ]; + } + }); + + // request album art, handler is set above + window.electron.ipcRenderer.sendMessage('get-album-art', nextSongPath); + + // update last played song in user config + window.electron.ipcRenderer.sendMessage( + 'set-last-played-song', + nextSongPath, + ); + + return { + currentSong: nextSongPath, + currentSongMetadata: nextSongMetadata, + paused: false, + shuffleHistory, + lastPlayedSong: nextSongPath, + hasIncreasedPlayCount: false, + }; }); }, - setInitialized: (initialized: boolean) => { - return set({ - initialized, + playPreviousSong: async () => { + return set((state) => { + if (!state.filteredLibrary) return {}; + + const keys = Object.keys(state.filteredLibrary); + const currentIndex = keys.indexOf(state.currentSong || ''); + + // repeating case, start the song over and over and over + if (state.repeating) { + state.player.setPosition(0); + return { + currentSongTime: 0, + hasIncreasedPlayCount: false, + }; + } + + /** + * @note if the song is past the 3 second mark, restart it. + * this emulates the behavior of most music players / cd players + */ + if (state.currentSongTime > 3) { + state.player.setPosition(0); + + return { + currentSongTime: 0, + hasIncreasedPlayCount: false, + }; + } + + // shuffle case + if (state.shuffle && state.shuffleHistory.length > 0) { + const previousSong = + state.shuffleHistory[state.shuffleHistory.length - 1]; + state.selectSpecificSong(previousSong, state.filteredLibrary); + + return { + shuffleHistory: state.shuffleHistory.slice(0, -1), + hasIncreasedPlayCount: false, + }; + } + + // normal case - go to previous song in list + const prevIndex = + currentIndex - 1 < 0 ? keys.length - 1 : currentIndex - 1; + const prevSong = keys[prevIndex]; + state.selectSpecificSong(prevSong, state.filteredLibrary); + // @note: selectSpecificSong does all the heavy lifting for us + // so we don't need to return anything here + return {}; }); }, })); diff --git a/src/renderer/store/player.ts b/src/renderer/store/player.ts deleted file mode 100644 index 721f0ea..0000000 --- a/src/renderer/store/player.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { create } from 'zustand'; -import { LightweightAudioMetadata } from '../../common/common'; -import { bufferToDataUrl } from '../utils/utils'; - -interface PlayerStore { - /** - * state - */ - paused: boolean; - currentSong: string; // holds the path of the current song - currentSongArtworkDataURL: string; // holds the artwork of the current song - currentSongMetadata: LightweightAudioMetadata; // holds the metadata of the current song - shuffle: boolean; - repeating: boolean; - volume: number; - currentSongTime: number; - filteredLibrary: { [key: string]: LightweightAudioMetadata }; - overrideScrollToIndex: number; - shuffleHistory: string[]; - - /** - * actions - */ - deleteEverything: () => void; - setVolume: (volume: number) => void; - setPaused: (paused: boolean) => void; - setShuffle: (shuffle: boolean) => void; - setRepeating: (repeating: boolean) => void; - setCurrentSong: ( - songPath: string, - library?: { [key: string]: LightweightAudioMetadata }, - ) => void; - setCurrentSongTime: (time: number) => void; - setFilteredLibrary: (filteredLibrary: { - [key: string]: LightweightAudioMetadata; - }) => void; - setOverrideScrollToIndex: (index: number | undefined) => void; - setShuffleHistory: (history: string[]) => void; -} - -const usePlayerStore = create((set) => ({ - /** - * default state - */ - paused: true, - currentSong: '', - currentSongArtworkDataURL: '', - currentSongMetadata: {} as LightweightAudioMetadata, - shuffle: false, - repeating: false, - volume: 100, - currentSongTime: 0, - filteredLibrary: {}, - overrideScrollToIndex: -1, - shuffleHistory: [], - - /** - * action implementations - */ - deleteEverything: () => set({}, true), - setVolume: (volume) => { - /** - * @dev set the volume of the audio tag to the new volume automatically - * as there is only one audio tag in the entire app - */ - const audioTag = document.querySelector('audio'); - if (audioTag) { - audioTag.volume = volume / 100; - } - return set({ volume }); - }, - setPaused: (paused) => set({ paused }), - // @note: when shuffle is toggled on or off we clear the shuffle history - setShuffle: (shuffle) => set({ shuffle, shuffleHistory: [] }), - setRepeating: (repeating) => set({ repeating }), - setCurrentSong: (songPath: string, library) => { - if (!library) { - // eslint-disable-next-line no-console - console.error('No library provided to setCurrentSongWithDetails'); - return; - } - - const songLibrary = library; - const metadata = songLibrary[songPath]; - - /** - * @important need this feature so we can restart the currently playing - * song by first clearing the current song and then setting it again - */ - if (songPath === '') { - set({ currentSong: songPath }); - return; - } - - if (!metadata) { - // eslint-disable-next-line no-console - console.warn('No metadata found for song:', songPath); - return; - } - - set({ - currentSong: songPath, - currentSongMetadata: metadata, - }); - - // Set album art on response - window.electron.ipcRenderer.once('get-album-art', async (event) => { - let url = ''; - if (event.data) { - url = await bufferToDataUrl(event.data, event.format); - } - - set({ currentSongArtworkDataURL: url }); - - if (navigator.mediaSession.metadata?.artwork) { - navigator.mediaSession.metadata.artwork = [ - { - src: url, - sizes: '192x192', - type: event.format, - }, - ]; - } - }); - - // Request album art, response handler is above - window.electron.ipcRenderer.sendMessage('get-album-art', songPath); - }, - setFilteredLibrary: (filteredLibrary) => set({ filteredLibrary }), - setCurrentSongTime: (currentSongTime) => set({ currentSongTime }), - setOverrideScrollToIndex: (overrideScrollToIndex) => { - return set({ overrideScrollToIndex }); - }, - setShuffleHistory: (shuffleHistory) => set({ shuffleHistory }), -})); - -export default usePlayerStore; diff --git a/src/renderer/utils/utils.ts b/src/renderer/utils/utils.ts index b273aed..8ad3be6 100644 --- a/src/renderer/utils/utils.ts +++ b/src/renderer/utils/utils.ts @@ -1,3 +1,5 @@ +import { LightweightAudioMetadata } from '../../common/common'; + export const bufferToDataUrl = async ( buffer: Buffer, format: string, @@ -25,3 +27,50 @@ export const convertToMMSS = (timeInSeconds: number) => { .toString() .padStart(2, '0')}`; }; + +type NextSongResult = { + songPath: string; + index: number; +}; + +export const findNextSong = ( + currentSong: string, + filteredLibrary: { [key: string]: LightweightAudioMetadata }, + shuffle: boolean, +): NextSongResult => { + const keys = Object.keys(filteredLibrary); + const currentIndex = keys.indexOf(currentSong || ''); + + if (shuffle) { + const randomIndex = Math.floor(Math.random() * keys.length); + return { + songPath: keys[randomIndex], + index: randomIndex, + }; + } + + const nextIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; + return { + songPath: keys[nextIndex], + index: nextIndex, + }; +}; + +export const updateMediaSession = (metadata: LightweightAudioMetadata) => { + const mediaData = { + title: metadata.common.title, + artist: metadata.common.artist, + album: metadata.common.album, + }; + + if (!navigator.mediaSession) { + console.error('Media session not supported'); + return; + } + + if (navigator.mediaSession.metadata) { + Object.assign(navigator.mediaSession.metadata, mediaData); + } else { + navigator.mediaSession.metadata = new MediaMetadata(mediaData); + } +}; diff --git a/tailwind.config.js b/tailwind.config.js index cbd3d7f..2abb76e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -50,8 +50,8 @@ module.exports = { * the StaticPlayer component looks good snapping into a vertical layout. */ screens: { - sm: '500px', - // => @media (min-width: 500px) { ... } + sm: '600px', + // => @media (min-width: 600px) { ... } }, keyframes: { 'sui--accordion-down': {