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 (
+
{width && height && (
-
)}
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': {