From 73ce8346851f9cb1a16fcf1607a8211d07e2601d Mon Sep 17 00:00:00 2001 From: Johnny Shankman Date: Thu, 21 Nov 2024 22:03:30 -0500 Subject: [PATCH] Feature: Gapless Playback with Gapless-5 (#41) 1. Moved as much of the player handling into the store as possible 2. Fixed a minor bug with the placeholder album art being the wrong size 3. Updated "previous" behavior to match standard music players with regards to song restarting 4. Updates our time estimation code for importing a new library 5. Move quiet mode to the menu to declutter the static player a bit 6. Shuffle history contains the songs a user manually selects while shuffle is engaged 7. Shuffle history contains the song that was playing when user engaged shuffle 8. Fixes the flash of full progress bar when importing songs or swapping libs 9. Makes playcounts more intelligently tracked, requires 10s of the song to "play" 10. Add volume controls to the hihat menu with accelerators for convenience aka key commands --- .eslintrc.js | 2 + README.md | 5 +- package-lock.json | 6 + package.json | 1 + regosen-gapless-5-1.5.6.tgz | Bin 0 -> 30053 bytes release/app/package-lock.json | 4 +- release/app/package.json | 2 +- src/main/main.ts | 13 +- src/main/menu.ts | 79 ++- src/main/preload.ts | 10 + src/renderer/components/AlbumArt.tsx | 46 +- src/renderer/components/Browser.tsx | 40 +- .../components/ImportNewSongsButtons.tsx | 16 +- src/renderer/components/LibraryList.tsx | 117 ++-- src/renderer/components/Main.tsx | 367 +++++++------ src/renderer/components/SearchBar.tsx | 5 +- ...Bar.tsx => SongProgressAndSongDisplay.tsx} | 123 +++-- .../components/SongRightClickMenu.tsx | 10 +- src/renderer/components/StaticPlayer.tsx | 95 +--- src/renderer/components/VolumeSliderStack.tsx | 7 +- src/renderer/hooks/useWindowDimensions.tsx | 2 +- src/renderer/store/main.ts | 502 +++++++++++++++++- src/renderer/store/player.ts | 137 ----- src/renderer/utils/utils.ts | 49 ++ tailwind.config.js | 4 +- 25 files changed, 1050 insertions(+), 592 deletions(-) create mode 100644 regosen-gapless-5-1.5.6.tgz rename src/renderer/components/{SongProgressBar.tsx => SongProgressAndSongDisplay.tsx} (75%) delete mode 100644 src/renderer/store/player.ts 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 0000000000000000000000000000000000000000..be13548e33a9589641f1acdd8ab164649a8ddd8c GIT binary patch literal 30053 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≥R$sZv{30CPHhTO=jms!=Pz0ik!N};?r^CZx{Rhr5kL4 z!i(>LeY%Og#a0{;PXAf_9$hB5`PrZ;mjEX~_6Muwr&<=ft(R+=B4?yW`e0o5uv-?T zdBV19a^THU+4LMNp5=@~HVqc9g7h3VsT{D-qh`B^Yb+%|>C!{wnMdT2 zWLnm{N0w`b-A9vcLh+&UP3Zh_G7eiXEj>imtwmvlyu zR@CSFRVg2jvDHywmS$dF)q;nSL(BfhTZ1I#L`L7Z4OQ!k2j+)naYhAi-+MWBjY4bzax{rVL-jh_k zu^uCfa#kK;VfY?Uw{6G6x*-cem9w?DDZQoUHLZf4nGb)a1?cQ5LKx^dA-3@2!}OC= z9mY#GDC>0|Z)Zpgj?zfL9YsJ_z|A z$1knNa+tLqT!2-_gykl3@`<<*o`cc?&`+mZCOW8|(JMV|(LdzCRX(v+eh~{9T=hk~ zUDWLCI|U(qSp%ulz|=d{Rj&tWj?w=RD>(Z^hd1Sc|NVB~@gb=$r=`L+bwy4nzlb%b zcsB8?z?svj&|tv+5YTimb6#&CFwF=uSbC?bBOR2VgXsu|kltEj1Phk^s)q9L?xGoy zOad(p#)6c{%d})NAfS?UQqR{pV6nKO(h9Mk0qEBY(|7tQaY8LC&(8WgPd5Ey{B&j? zFv;F#7_CMCQ7ebjV|T+spyI-cvpHeKR;|%7Gfl+!o0AZ(#FlE6lwiVHn9W>tQ$tbo z1Nh<#n{->OoE5AFcum@YRO3}^Bd78=R_>A}ZUIK$AL+GZsHKwQ=c7n0w+G40n_&(Q z7QR>J01=Uyuz^Ot?!;!K^dC60Z7lZYQ`>_n!?E_`BE320?yM!KE0qt1(pRY-d8N*S z+@Fg|FLApzQpx0i<4Jktt`b%PVT4?wrCoHwJn{mU2@bJV{xP#QK$TD)i$#=5or)by z5mvN^aD{s+9(XOf7YV;vjJvO&IF`%$4F9*Oeqv$>E7hUB%v5WF6W4FgjYV8im|=As zVi^x?8m5>=Th)Zy5L}xx{L$40`SR>q1NYf--2uFpEHu&MV83#@m<8fKl3aWcf92vp zZ%|he%s*QYWEWWA%}9XcjIRaSbvw!s`lVzsiAnEsG@Hfa2oH}CHV>ZVMM`NV%ilHg zCm_qls@HkDmN1rzv2G~S-AqI^l*6q3z~`h?u9-NmywGB!{UQ!Nqe6?iJF?N&GIL&} z?XxlTw}N+$(SD7;eddZNvAMgTRBY2Z5nE7LQkN~xUTK^f^kKo)bTyiI(%<#W+0OO? zm()^_3;<(Hr=SIh@0%qD+zN;HZn$CqiorRaFFmEJ8S)PTGCKOju2?Ey$dzmsTkz&@ zFI4apVQPes1)JOg{25_nLJJjKhOIal5S7C(*4} zlou;&D`Iil8KveZ#l8oj=l080ON?*}C%(ArEeeHZ=^juhJROl=DNr35pgSbS$*e;V z3VPd#QxVq5$Fcd9BLRZbJ|4iAGrFjlchFyNlETVFUGJ#jHtkrN3RTU*nwgx2{m5Na z>+1XYgNm82dO*tUu(>0@wKj0j4`#`yvajv7lRqUFeG)J|WtL`S*PE@GVwye3)X$Wc zLeU~vnkM83n4do`&#+oy3c6!3h~}>VYQsnfH2-xdv@X95kH%wff?CJc{P(7blxK-* ztk_7Xqw7iLw#&ilZGZ^hcN6fM;xTawZSg#Epe&v&8huI@lerN@^i!k|#JlVIj!Cj~ zIb4lCUL9znZ6slvQcLEfF(4OC*g37?+%x0Yic?l%3KlpiZ;t3GU&aX6k@l`2*}Lo! zCE}d4z&1L^*LWC%HvTcPO2^0 zu4h7TARh0RctS9{md6i&V`rI)I;lm@Qxe9%K>SZ9+zYycpgI3C|F-$xAp9qv|MkW0 zQ~uX)^0${v?`DvvO^d#5^~3IqmnzGwT&roB@F`AH^a7`NebeQg`Y;`TtlugGJBsZ;nUCSZcTr+jB(1Nm$K zfIPm2|3}!Z>EcrBLN^TviPWSo7m)BVg+h$+!}}DUgJ(Q~3p$c%9HkPCE8I0GZZd(f z0h~Pz5w?4K!wE!8PvB;m#f+g6(A`8qz6;$Vf{!>vG)qbarc>6)DPI+Z7hjoAJ4xmM z)uGVMI3*_NtMa4tW<-bxzEuRTQX5&_nc=-fbN%6C2)fX+o341guGy?Nb^C{b8uSi^ zr|o{A!ht&O_uhs3!M@sR5A^S?hB^(0hrN@b(lz?+?(n_p9jJEqz4}+!-EXMi-^cx6 zFi^d|3XhIEVW7*x?q26)KkU9$Z}fZJ-cWVIqj0E+4tv-jlL~_Y5IYL`dx!dG`%Tyh zhwmHeARKmq=7A>KR>$rBFx)%owEOD#q<`ES1iJZsO|~0$5Bj>5;3()0o4Qq9rh<3+ zhZ-EVI~}E)sts>5DqKhTA50?k?bO(&qHbf@+@?eM6f_S;A8 zw*kJ@(^UFcjk!BL46sPI*Vg~-K}Iy)joMzXJM8P94c*KB(7bvY4uXbi_rn1U$U(oS z$-{8!j`xs=ey { 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': {