mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add basic mobile responsive layout
This commit is contained in:
@@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
||||
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
|
||||
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||
@@ -87,6 +88,7 @@ export const App = () => {
|
||||
<WebAudioContext.Provider value={webAudioProvider}>
|
||||
<PlayerProvider>
|
||||
<AppRouter />
|
||||
<AudioPlayers />
|
||||
</PlayerProvider>
|
||||
</WebAudioContext.Provider>
|
||||
<IsUpdatedDialog />
|
||||
|
||||
@@ -290,7 +290,7 @@ export const ItemGridList = ({
|
||||
|
||||
const internalState = useItemListState(getDataFn, extractRowId);
|
||||
|
||||
const [initialize] = useOverlayScrollbars({
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: false,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
@@ -323,15 +323,39 @@ export const ItemGridList = ({
|
||||
const { current: root } = rootRef;
|
||||
const { current: outer } = outerRef;
|
||||
|
||||
if (root && outer) {
|
||||
initialize({
|
||||
elements: {
|
||||
viewport: outer,
|
||||
},
|
||||
target: root,
|
||||
});
|
||||
if (!tableMeta || !root || !outer) {
|
||||
return;
|
||||
}
|
||||
}, [initialize, tableMeta]);
|
||||
|
||||
initialize({
|
||||
elements: {
|
||||
viewport: outer,
|
||||
},
|
||||
target: root,
|
||||
});
|
||||
|
||||
return () => {
|
||||
try {
|
||||
const instance = osInstance();
|
||||
const { current: root } = rootRef;
|
||||
const { current: outer } = outerRef;
|
||||
|
||||
// Check if instance exists and elements are still connected to the DOM
|
||||
if (instance) {
|
||||
// Check if elements are still in the document
|
||||
const rootInDocument = root && document.contains(root);
|
||||
const outerInDocument = outer && document.contains(outer);
|
||||
|
||||
// Only destroy if elements are still in the document
|
||||
if (rootInDocument && outerInDocument) {
|
||||
instance.destroy();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
};
|
||||
}, [initialize, osInstance, tableMeta]);
|
||||
|
||||
const throttledSetTableMeta = useMemo(() => {
|
||||
return createThrottledSetTableMeta(itemsPerRow, rows?.length);
|
||||
|
||||
@@ -688,7 +688,6 @@ interface ItemTableListProps {
|
||||
type: 'index' | 'offset';
|
||||
};
|
||||
itemType: LibraryItem;
|
||||
startRowIndex?: number;
|
||||
onColumnReordered?: (
|
||||
columnIdFrom: TableColumn,
|
||||
columnIdTo: TableColumn,
|
||||
@@ -700,6 +699,7 @@ interface ItemTableListProps {
|
||||
ref?: Ref<ItemListHandle>;
|
||||
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
|
||||
size?: 'compact' | 'default' | 'large';
|
||||
startRowIndex?: number;
|
||||
}
|
||||
|
||||
export const ItemTableList = ({
|
||||
@@ -1111,7 +1111,7 @@ export const ItemTableList = ({
|
||||
],
|
||||
);
|
||||
|
||||
const [initialize] = useOverlayScrollbars({
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: false,
|
||||
events: {
|
||||
initialized(osInstance) {
|
||||
@@ -1135,24 +1135,49 @@ export const ItemTableList = ({
|
||||
useEffect(() => {
|
||||
const { current: root } = scrollContainerRef;
|
||||
|
||||
if (root) {
|
||||
initialize({
|
||||
elements: { viewport: root.firstElementChild as HTMLElement },
|
||||
target: root,
|
||||
});
|
||||
|
||||
if (enableDrag) {
|
||||
autoScrollForElements({
|
||||
canScroll: () => true,
|
||||
element: root.firstElementChild as HTMLElement,
|
||||
getAllowedAxis: () => 'vertical',
|
||||
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
|
||||
});
|
||||
}
|
||||
if (!root || !root.firstElementChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [enableDrag, initialize]);
|
||||
const viewport = root.firstElementChild as HTMLElement;
|
||||
|
||||
initialize({
|
||||
elements: { viewport },
|
||||
target: root,
|
||||
});
|
||||
|
||||
if (enableDrag) {
|
||||
autoScrollForElements({
|
||||
canScroll: () => true,
|
||||
element: viewport,
|
||||
getAllowedAxis: () => 'vertical',
|
||||
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
const instance = osInstance();
|
||||
const { current: root } = scrollContainerRef;
|
||||
|
||||
// Check if instance exists and elements are still connected to the DOM
|
||||
if (instance && root) {
|
||||
const viewport = root.firstElementChild as HTMLElement;
|
||||
|
||||
// Check if elements are still in the document
|
||||
const rootInDocument = document.contains(root);
|
||||
const viewportInDocument = viewport && document.contains(viewport);
|
||||
|
||||
// Only destroy if elements are still in the document
|
||||
if (rootInDocument && viewportInDocument) {
|
||||
instance.destroy();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
};
|
||||
}, [enableDrag, initialize, osInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement;
|
||||
|
||||
@@ -21,8 +21,6 @@ export const SongContextMenu = ({ items }: SongContextMenuProps) => {
|
||||
return { ids };
|
||||
}, [items]);
|
||||
|
||||
console.log(items, ids);
|
||||
|
||||
return (
|
||||
<ContextMenu.Content>
|
||||
<PlayAction ids={ids} itemType={LibraryItem.SONG} songs={items} />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||
import { usePlaybackType } from '/@/renderer/store';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
export const AudioPlayers = () => {
|
||||
const playbackType = usePlaybackType();
|
||||
|
||||
return (
|
||||
<>
|
||||
{playbackType === PlayerType.WEB && <WebPlayer />}
|
||||
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { useFullScreenPlayerStore, useGeneralSettings } from '/@/renderer/store';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
|
||||
const scaleImageUrl = (imageSize: number, url?: null | string) => {
|
||||
return url
|
||||
?.replace(/&size=\d+/, `&size=${imageSize}`)
|
||||
.replace(/\?width=\d+/, `?width=${imageSize}`)
|
||||
.replace(/&height=\d+/, `&height=${imageSize}`);
|
||||
};
|
||||
|
||||
interface MobileFullscreenPlayerAlbumArtProps {
|
||||
currentSong?: QueueSong;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerAlbumArt = memo(
|
||||
({ currentSong }: MobileFullscreenPlayerAlbumArtProps) => {
|
||||
const { albumArtRes } = useGeneralSettings();
|
||||
const { useImageAspectRatio } = useFullScreenPlayerStore();
|
||||
const imageSize = albumArtRes || 1000;
|
||||
const imageUrl = scaleImageUrl(imageSize, currentSong?.imageUrl);
|
||||
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={clsx(styles.image, {
|
||||
[styles.imageNativeAspectRatio]: useImageAspectRatio,
|
||||
})}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Image
|
||||
className={clsx(styles.albumImage, PlaybackSelectors.playerCoverArt)}
|
||||
loading="eager"
|
||||
src={imageUrl}
|
||||
style={{
|
||||
objectFit: useImageAspectRatio ? 'contain' : 'cover',
|
||||
width: useImageAspectRatio ? 'auto' : '100%',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerAlbumArt.displayName = 'MobileFullscreenPlayerAlbumArt';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { memo, MouseEvent } from 'react';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerRepeat, usePlayerShuffle } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
|
||||
|
||||
interface MobileFullscreenPlayerBottomControlsProps {
|
||||
isLyricsActive: boolean;
|
||||
isQueueActive: boolean;
|
||||
onToggleContextMenu: (e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => void;
|
||||
onToggleLyrics: () => void;
|
||||
onToggleQueue: () => void;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerBottomControls = memo(
|
||||
({
|
||||
isLyricsActive,
|
||||
isQueueActive,
|
||||
onToggleContextMenu,
|
||||
onToggleLyrics,
|
||||
onToggleQueue,
|
||||
}: MobileFullscreenPlayerBottomControlsProps) => {
|
||||
const repeat = usePlayerRepeat();
|
||||
const shuffle = usePlayerShuffle();
|
||||
const { toggleRepeat, toggleShuffle } = usePlayer();
|
||||
|
||||
return (
|
||||
<div className={styles.bottomControlsBar}>
|
||||
<Group className={styles.bottomControlsGroup} gap={0}>
|
||||
<ActionIcon
|
||||
className={styles.bottomControlIcon}
|
||||
icon="mediaShuffle"
|
||||
iconProps={{
|
||||
fill: shuffle === PlayerShuffle.NONE ? 'default' : 'primary',
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={toggleShuffle}
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.bottomControlIcon}
|
||||
icon={repeat === PlayerRepeat.ONE ? 'mediaRepeatOne' : 'mediaRepeat'}
|
||||
iconProps={{
|
||||
fill: repeat === PlayerRepeat.NONE ? 'default' : 'primary',
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={toggleRepeat}
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.bottomControlIcon}
|
||||
icon="queue"
|
||||
iconProps={{
|
||||
fill: isQueueActive ? 'primary' : undefined,
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={onToggleQueue}
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.bottomControlIcon}
|
||||
icon="metadata"
|
||||
iconProps={{
|
||||
fill: isLyricsActive ? 'primary' : undefined,
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={onToggleLyrics}
|
||||
variant="transparent"
|
||||
/>
|
||||
<ActionIcon
|
||||
className={styles.bottomControlIcon}
|
||||
icon="ellipsisVertical"
|
||||
iconProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
onClick={onToggleContextMenu}
|
||||
variant="transparent"
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerBottomControls.displayName = 'MobileFullscreenPlayerBottomControls';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerStatus } from '/@/renderer/store';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
interface MobileFullscreenPlayerControlsProps {
|
||||
currentSong?: QueueSong;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerControls = memo(
|
||||
({ currentSong }: MobileFullscreenPlayerControlsProps) => {
|
||||
const currentSongId = currentSong?.id;
|
||||
const { t } = useTranslation();
|
||||
const status = usePlayerStatus();
|
||||
const {
|
||||
mediaNext,
|
||||
mediaPrevious,
|
||||
mediaSkipBackward,
|
||||
mediaSkipForward,
|
||||
mediaTogglePlayPause,
|
||||
} = usePlayer();
|
||||
|
||||
return (
|
||||
<div className={styles.controlsContainer}>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaPrevious" size="xl" />}
|
||||
onClick={mediaPrevious}
|
||||
tooltip={{
|
||||
label: t('player.previous', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="secondary"
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaStepBackward" size="xl" />}
|
||||
onClick={mediaSkipBackward}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
context: 'back',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<PlayButton
|
||||
disabled={currentSongId === undefined}
|
||||
isPaused={status === PlayerStatus.PAUSED}
|
||||
onClick={mediaTogglePlayPause}
|
||||
style={{
|
||||
height: '50px',
|
||||
width: '50px',
|
||||
}}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaStepForward" size="xl" />}
|
||||
onClick={mediaSkipForward}
|
||||
tooltip={{
|
||||
label: t('player.skip', {
|
||||
context: 'forward',
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaNext" size="xl" />}
|
||||
onClick={mediaNext}
|
||||
tooltip={{
|
||||
label: t('player.next', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerControls.displayName = 'MobileFullscreenPlayerControls';
|
||||
@@ -0,0 +1,361 @@
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { useLyricsSettings, useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { useFullScreenPlayerStore, useFullScreenPlayerStoreActions } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Option } from '/@/shared/components/option/option';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Slider } from '/@/shared/components/slider/slider';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
interface MobileFullscreenPlayerHeaderProps {
|
||||
currentSong?: QueueSong;
|
||||
isPageHovered: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerHeader = memo(
|
||||
({ isPageHovered, onClose }: MobileFullscreenPlayerHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
dynamicBackground,
|
||||
dynamicImageBlur,
|
||||
dynamicIsImage,
|
||||
opacity,
|
||||
useImageAspectRatio,
|
||||
} = useFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const lyricConfig = useLyricsSettings();
|
||||
|
||||
const handleLyricsSettings = (property: string, value: any) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
[property]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
style={{
|
||||
background: `rgb(var(--theme-colors-background-transparent), ${opacity}%)`,
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
icon="arrowDownS"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={onClose}
|
||||
tooltip={{ label: t('common.minimize', { postProcess: 'titleCase' }) }}
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
<Popover position="bottom-end">
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
icon="settings2"
|
||||
iconProps={{ size: 'lg' }}
|
||||
tooltip={{ label: t('common.configure', { postProcess: 'titleCase' }) }}
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.dynamicBackground', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicBackground}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicBackground: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
{dynamicBackground && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.dynamicIsImage', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={dynamicIsImage}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
dynamicIsImage: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
)}
|
||||
{dynamicBackground && dynamicIsImage && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.dynamicImageBlur', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={dynamicImageBlur}
|
||||
label={(e) => `${e} rem`}
|
||||
max={6}
|
||||
min={0}
|
||||
onChangeEnd={(e) =>
|
||||
setStore({ dynamicImageBlur: Number(e) })
|
||||
}
|
||||
step={0.5}
|
||||
w="100%"
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
)}
|
||||
{dynamicBackground && (
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.opacity', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
defaultValue={opacity}
|
||||
label={(e) => `${e} %`}
|
||||
max={100}
|
||||
min={0}
|
||||
onChangeEnd={(e) => setStore({ opacity: Number(e) })}
|
||||
w="100%"
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
)}
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.useImageAspectRatio', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={useImageAspectRatio}
|
||||
onChange={(e) =>
|
||||
setStore({
|
||||
useImageAspectRatio: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Divider my="sm" />
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.followCurrentLyric', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.follow}
|
||||
onChange={(e) =>
|
||||
handleLyricsSettings('follow', e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.showLyricProvider', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.showProvider}
|
||||
onChange={(e) =>
|
||||
handleLyricsSettings(
|
||||
'showProvider',
|
||||
e.currentTarget.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.showLyricMatch', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
checked={lyricConfig.showMatch}
|
||||
onChange={(e) =>
|
||||
handleLyricsSettings('showMatch', e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyricSize', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group w="100%" wrap="nowrap">
|
||||
<Slider
|
||||
defaultValue={lyricConfig.fontSize}
|
||||
label={(e) =>
|
||||
`${t('page.fullscreenPlayer.config.synchronized', {
|
||||
postProcess: 'titleCase',
|
||||
})}: ${e}px`
|
||||
}
|
||||
max={72}
|
||||
min={8}
|
||||
onChangeEnd={(e) =>
|
||||
handleLyricsSettings('fontSize', Number(e))
|
||||
}
|
||||
w="100%"
|
||||
/>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.fontSizeUnsync}
|
||||
label={(e) =>
|
||||
`${t('page.fullscreenPlayer.config.unsynchronized', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}: ${e}px`
|
||||
}
|
||||
max={72}
|
||||
min={8}
|
||||
onChangeEnd={(e) =>
|
||||
handleLyricsSettings('fontSizeUnsync', Number(e))
|
||||
}
|
||||
w="100%"
|
||||
/>
|
||||
</Group>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyricGap', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Group w="100%" wrap="nowrap">
|
||||
<Slider
|
||||
defaultValue={lyricConfig.gap}
|
||||
label={(e) => `Synchronized: ${e}px`}
|
||||
max={50}
|
||||
min={0}
|
||||
onChangeEnd={(e) => handleLyricsSettings('gap', Number(e))}
|
||||
w="100%"
|
||||
/>
|
||||
<Slider
|
||||
defaultValue={lyricConfig.gapUnsync}
|
||||
label={(e) => `Unsynchronized: ${e}px`}
|
||||
max={50}
|
||||
min={0}
|
||||
onChangeEnd={(e) =>
|
||||
handleLyricsSettings('gapUnsync', Number(e))
|
||||
}
|
||||
w="100%"
|
||||
/>
|
||||
</Group>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyricAlignment', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<Select
|
||||
data={[
|
||||
{
|
||||
label: t('common.left', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
label: t('common.center', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'center',
|
||||
},
|
||||
{
|
||||
label: t('common.right', {
|
||||
postProcess: 'titleCase',
|
||||
}),
|
||||
value: 'right',
|
||||
},
|
||||
]}
|
||||
onChange={(e) => handleLyricsSettings('alignment', e)}
|
||||
value={lyricConfig.alignment}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>
|
||||
{t('page.fullscreenPlayer.config.lyricOffset', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
</Option.Label>
|
||||
<Option.Control>
|
||||
<NumberInput
|
||||
defaultValue={lyricConfig.delayMs}
|
||||
hideControls={false}
|
||||
onBlur={(e) =>
|
||||
handleLyricsSettings(
|
||||
'delayMs',
|
||||
Number(e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
step={10}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Divider my="sm" />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<ListConfigMenu
|
||||
buttonProps={{
|
||||
variant: isPageHovered ? 'default' : 'subtle',
|
||||
}}
|
||||
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||
listKey={ItemListKey.FULL_SCREEN}
|
||||
optionsConfig={{
|
||||
table: {
|
||||
itemsPerPage: { hidden: true },
|
||||
pagination: { hidden: true },
|
||||
},
|
||||
}}
|
||||
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerHeader.displayName = 'MobileFullscreenPlayerHeader';
|
||||
@@ -0,0 +1,83 @@
|
||||
import clsx from 'clsx';
|
||||
import { memo, MouseEvent } from 'react';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Rating } from '/@/shared/components/rating/rating';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { TextTitle } from '/@/shared/components/text-title/text-title';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
|
||||
interface MobileFullscreenPlayerMetadataProps {
|
||||
currentSong?: QueueSong;
|
||||
onToggleFavorite: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onUpdateRating: (rating: number) => void;
|
||||
showRating?: boolean;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerMetadata = memo(
|
||||
({
|
||||
currentSong,
|
||||
onToggleFavorite,
|
||||
onUpdateRating,
|
||||
showRating,
|
||||
}: MobileFullscreenPlayerMetadataProps) => {
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
const album = currentSong?.album;
|
||||
const container = currentSong?.container;
|
||||
const year = currentSong?.releaseYear;
|
||||
const isFavorite = currentSong?.userFavorite;
|
||||
const rating = currentSong?.userRating;
|
||||
|
||||
const hasMetadata = container || year;
|
||||
|
||||
return (
|
||||
<div className={styles.metadataContainer}>
|
||||
<div className={styles.titleRow}>
|
||||
<TextTitle className={PlaybackSelectors.songTitle} fw={700} order={2}>
|
||||
{title || '—'}
|
||||
</TextTitle>
|
||||
</div>
|
||||
<Text className={clsx(PlaybackSelectors.songArtist)} size="md" truncate>
|
||||
{artists?.map((a) => a.name).join(', ') || '—'}
|
||||
</Text>
|
||||
<Text className={clsx(PlaybackSelectors.songAlbum)} size="md" truncate>
|
||||
{album || '—'}
|
||||
</Text>
|
||||
{hasMetadata && (
|
||||
<Group align="center" className={styles.metadataRow} gap="xs" wrap="nowrap">
|
||||
{container && <Text size="xs">{container}</Text>}
|
||||
{year && (
|
||||
<>
|
||||
{container && <Separator />}
|
||||
<Text size="xs">{year}</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group align="center" className={styles.actionsRow} gap="xs">
|
||||
<ActionIcon
|
||||
icon="favorite"
|
||||
iconProps={{
|
||||
fill: isFavorite ? 'primary' : undefined,
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={onToggleFavorite}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
/>
|
||||
{showRating && (
|
||||
<Rating onChange={onUpdateRating} size="sm" value={rating || 0} />
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerMetadata.displayName = 'MobileFullscreenPlayerMetadata';
|
||||
@@ -0,0 +1,60 @@
|
||||
import formatDuration from 'format-duration';
|
||||
import { memo } from 'react';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { PlayerbarSeekSlider } from '/@/renderer/features/player/components/playerbar-seek-slider';
|
||||
import { PlayerbarWaveform } from '/@/renderer/features/player/components/playerbar-waveform';
|
||||
import { usePlayerTimestamp } from '/@/renderer/store';
|
||||
import { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/settings.store';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
|
||||
interface MobileFullscreenPlayerProgressProps {
|
||||
currentSong?: QueueSong;
|
||||
}
|
||||
|
||||
export const MobileFullscreenPlayerProgress = memo(
|
||||
({ currentSong }: MobileFullscreenPlayerProgressProps) => {
|
||||
const currentTime = usePlayerTimestamp();
|
||||
const playerbarSlider = usePlayerbarSlider();
|
||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
||||
const formattedDuration = formatDuration(songDuration * 1000 || 0);
|
||||
const formattedTime = formatDuration(currentTime * 1000 || 0);
|
||||
|
||||
const isWaveform = playerbarSlider?.type === PlayerbarSliderType.WAVEFORM;
|
||||
|
||||
return (
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.timeContainer}>
|
||||
<Text
|
||||
className={PlaybackSelectors.elapsedTime}
|
||||
size="xs"
|
||||
style={{ textAlign: 'right' }}
|
||||
>
|
||||
{formattedTime}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.sliderWrapper}>
|
||||
{isWaveform ? (
|
||||
<PlayerbarWaveform />
|
||||
) : (
|
||||
<PlayerbarSeekSlider max={songDuration} min={0} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.timeContainer}>
|
||||
<Text
|
||||
className={PlaybackSelectors.totalDuration}
|
||||
size="xs"
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
{formattedDuration}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MobileFullscreenPlayerProgress.displayName = 'MobileFullscreenPlayerProgress';
|
||||
@@ -0,0 +1,198 @@
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
|
||||
.background-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-overlay-header);
|
||||
backdrop-filter: blur(var(--image-blur));
|
||||
}
|
||||
|
||||
.player-state,
|
||||
.queue-state,
|
||||
.lyrics-state {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.player-state[style*='z-index: 2'],
|
||||
.queue-state[style*='z-index: 2'],
|
||||
.lyrics-state[style*='z-index: 2'] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.imageNativeAspectRatio {
|
||||
aspect-ratio: auto;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.album-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadata-row {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.time-container {
|
||||
flex-shrink: 0;
|
||||
min-width: 3.5ch;
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.controls-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.bottom-controls-bar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.bottom-controls-group {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-control-icon {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.queue-header,
|
||||
.lyrics-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.queue-content,
|
||||
.lyrics-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { CSSProperties, MouseEvent, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './mobile-fullscreen-player.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { Lyrics } from '/@/renderer/features/lyrics/lyrics';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { MobileFullscreenPlayerAlbumArt } from '/@/renderer/features/player/components/mobile-fullscreen-player-album-art';
|
||||
import { MobileFullscreenPlayerBottomControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-bottom-controls';
|
||||
import { MobileFullscreenPlayerControls } from '/@/renderer/features/player/components/mobile-fullscreen-player-controls';
|
||||
import { MobileFullscreenPlayerHeader } from '/@/renderer/features/player/components/mobile-fullscreen-player-header';
|
||||
import { MobileFullscreenPlayerMetadata } from '/@/renderer/features/player/components/mobile-fullscreen-player-metadata';
|
||||
import { MobileFullscreenPlayerProgress } from '/@/renderer/features/player/components/mobile-fullscreen-player-progress';
|
||||
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
usePlayerData,
|
||||
usePlayerSong,
|
||||
useSetFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const mainBackground = 'var(--theme-colors-background)';
|
||||
|
||||
export const MobileFullscreenPlayer = () => {
|
||||
const { t } = useTranslation();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const { activeTab, dynamicBackground, dynamicImageBlur, dynamicIsImage } =
|
||||
useFullScreenPlayerStore();
|
||||
const currentSong = usePlayerSong();
|
||||
const { currentSong: currentSongData } = usePlayerData();
|
||||
const server = useCurrentServer();
|
||||
const addToFavoritesMutation = useCreateFavorite({});
|
||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const [isPageHovered, setIsPageHovered] = useState(false);
|
||||
|
||||
const { background } = useFastAverageColor({
|
||||
algorithm: 'dominant',
|
||||
src: currentSong?.imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const imageUrl = currentSong?.imageUrl && currentSong.imageUrl.replace(/size=\d+/g, 'size=500');
|
||||
const backgroundImage =
|
||||
imageUrl && dynamicIsImage
|
||||
? `url("${imageUrl.replace(currentSong.id, currentSong.albumId)}"), url("${imageUrl}")`
|
||||
: mainBackground;
|
||||
|
||||
const handleToggleFullScreenPlayer = useCallback(() => {
|
||||
setFullScreenPlayerStore({ expanded: false });
|
||||
}, [setFullScreenPlayerStore]);
|
||||
|
||||
const handleToggleContextMenu = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenuController.call({
|
||||
cmd: { items: [currentSong], type: LibraryItem.SONG },
|
||||
event: e as unknown as MouseEvent<HTMLDivElement>,
|
||||
});
|
||||
},
|
||||
[currentSong],
|
||||
);
|
||||
|
||||
const handleToggleQueue = useCallback(() => {
|
||||
setStore({ activeTab: activeTab === 'queue' ? 'player' : 'queue' });
|
||||
}, [activeTab, setStore]);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
const song = currentSongData;
|
||||
if (!song?.id) return;
|
||||
|
||||
if (song.userFavorite) {
|
||||
removeFromFavoritesMutation.mutate({
|
||||
apiClientProps: { serverId: song?._serverId || '' },
|
||||
query: {
|
||||
id: [song.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
addToFavoritesMutation.mutate({
|
||||
apiClientProps: { serverId: song?._serverId || '' },
|
||||
query: {
|
||||
id: [song.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentSongData, addToFavoritesMutation, removeFromFavoritesMutation],
|
||||
);
|
||||
|
||||
const handleToggleLyrics = useCallback(() => {
|
||||
setStore({ activeTab: activeTab === 'lyrics' ? 'player' : 'lyrics' });
|
||||
}, [activeTab, setStore]);
|
||||
|
||||
const handleUpdateRating = useCallback(
|
||||
(rating: number) => {
|
||||
if (!currentSong?.id) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
apiClientProps: { serverId: currentSong?._serverId || '' },
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
rating,
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
},
|
||||
[currentSong, updateRatingMutation],
|
||||
);
|
||||
|
||||
const isPlayerState = activeTab !== 'queue' && activeTab !== 'lyrics';
|
||||
const isQueueState = activeTab === 'queue';
|
||||
const isLyricsState = activeTab === 'lyrics';
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
const showRating =
|
||||
isSongDefined &&
|
||||
(server?.type === ServerType.NAVIDROME || server?.type === ServerType.SUBSONIC);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{
|
||||
background: dynamicBackground ? backgroundImage : mainBackground,
|
||||
backgroundColor: dynamicBackground ? background : mainBackground,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
{dynamicBackground && (
|
||||
<div
|
||||
className={styles.backgroundImageOverlay}
|
||||
style={
|
||||
{
|
||||
'--image-blur': `${dynamicImageBlur}rem`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: isPlayerState ? 1 : 0,
|
||||
zIndex: isPlayerState ? 2 : 1,
|
||||
}}
|
||||
className={styles.playerState}
|
||||
onMouseEnter={() => setIsPageHovered(true)}
|
||||
onMouseLeave={() => setIsPageHovered(false)}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<MobileFullscreenPlayerHeader
|
||||
currentSong={currentSong}
|
||||
isPageHovered={isPageHovered}
|
||||
onClose={handleToggleFullScreenPlayer}
|
||||
/>
|
||||
<MobileFullscreenPlayerAlbumArt currentSong={currentSong} />
|
||||
<MobileFullscreenPlayerMetadata
|
||||
currentSong={currentSong}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onUpdateRating={handleUpdateRating}
|
||||
showRating={showRating}
|
||||
/>
|
||||
<MobileFullscreenPlayerProgress currentSong={currentSong} />
|
||||
<MobileFullscreenPlayerControls currentSong={currentSong} />
|
||||
<MobileFullscreenPlayerBottomControls
|
||||
isLyricsActive={isLyricsState}
|
||||
isQueueActive={isQueueState}
|
||||
onToggleContextMenu={handleToggleContextMenu}
|
||||
onToggleLyrics={handleToggleLyrics}
|
||||
onToggleQueue={handleToggleQueue}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: isQueueState ? 1 : 0,
|
||||
zIndex: isQueueState ? 2 : 1,
|
||||
}}
|
||||
className={styles.queueState}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className={styles.queueHeader}>
|
||||
<ActionIcon
|
||||
icon="arrowDownS"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
size="sm"
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
iconProps={{ size: 'xl' }}
|
||||
onClick={handleToggleQueue}
|
||||
size="sm"
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.queueContent}>
|
||||
<PlayQueue listKey={ItemListKey.FULL_SCREEN} searchTerm={undefined} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: isLyricsState ? 1 : 0,
|
||||
zIndex: isLyricsState ? 2 : 1,
|
||||
}}
|
||||
className={styles.lyricsState}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<div className={styles.lyricsHeader}>
|
||||
<ActionIcon
|
||||
icon="arrowDownS"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
size="sm"
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
<Text fw={600} size="lg">
|
||||
{t('page.fullscreenPlayer.lyrics', { postProcess: 'sentenceCase' })}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
iconProps={{ size: 'xl' }}
|
||||
onClick={handleToggleLyrics}
|
||||
size="sm"
|
||||
variant={isPageHovered ? 'default' : 'subtle'}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.lyricsContent}>
|
||||
<Lyrics />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-sm);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
padding: 0.5rem 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.playerbar-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.metadata-stack {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-item {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.line-item.secondary {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
}
|
||||
|
||||
.controls-wrapper {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'motion/react';
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generatePath, Link } from 'react-router';
|
||||
|
||||
import styles from './mobile-playerbar.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { PlayButton, PlayerButton } from '/@/renderer/features/player/components/player-button';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useFullScreenPlayerStore,
|
||||
useFullScreenPlayerStoreActions,
|
||||
usePlayerSong,
|
||||
usePlayerStatus,
|
||||
useSetFullScreenPlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { Image } from '/@/shared/components/image/image';
|
||||
import { Separator } from '/@/shared/components/separator/separator';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export const MobilePlayerbar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const { setStore } = useFullScreenPlayerStoreActions();
|
||||
const currentSong = usePlayerSong();
|
||||
const status = usePlayerStatus();
|
||||
const { mediaTogglePlayPause, mediaNext, mediaPrevious } = usePlayer();
|
||||
const title = currentSong?.name;
|
||||
const artists = currentSong?.artists;
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
|
||||
const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent<HTMLDivElement>) => {
|
||||
e?.stopPropagation();
|
||||
// Set active tab to player when opening fullscreen player
|
||||
setStore({ activeTab: 'player' });
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
const handleToggleContextMenu = (e: MouseEvent<HTMLButtonElement | HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenuController.call({
|
||||
cmd: { items: [currentSong], type: LibraryItem.SONG },
|
||||
event: e as MouseEvent<HTMLDivElement>,
|
||||
});
|
||||
};
|
||||
|
||||
const stopPropagation = (e?: MouseEvent) => e?.stopPropagation();
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}>
|
||||
<LayoutGroup>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{currentSong?.imageUrl && (
|
||||
<div className={styles.imageWrapper}>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={styles.image}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="mobile-playerbar-image"
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
onContextMenu={handleToggleContextMenu}
|
||||
role="button"
|
||||
transition={{ duration: 0.2, ease: 'easeIn' }}
|
||||
>
|
||||
<Tooltip
|
||||
label={t('player.toggleFullscreenPlayer', {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
openDelay={500}
|
||||
>
|
||||
<Image
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
PlaybackSelectors.playerCoverArt,
|
||||
)}
|
||||
loading="eager"
|
||||
src={currentSong.imageUrl}
|
||||
/>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div className={styles.metadataStack} layout="position">
|
||||
<div className={styles.lineItem} onClick={stopPropagation}>
|
||||
<Group align="center" gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
className={PlaybackSelectors.songTitle}
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
onContextMenu={handleToggleContextMenu}
|
||||
overflow="hidden"
|
||||
size="sm"
|
||||
to={AppRoute.NOW_PLAYING}
|
||||
truncate
|
||||
>
|
||||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<ActionIcon
|
||||
icon="ellipsisVertical"
|
||||
onClick={handleToggleContextMenu}
|
||||
size="xs"
|
||||
styles={{
|
||||
root: {
|
||||
'--ai-size-xs': '1.15rem',
|
||||
},
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.lineItem,
|
||||
styles.secondary,
|
||||
PlaybackSelectors.songArtist,
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{artists?.map((artist, index) => (
|
||||
<React.Fragment key={`bar-${artist.id}`}>
|
||||
{index > 0 && <Separator />}
|
||||
<Text
|
||||
component={artist.id ? Link : undefined}
|
||||
fw={500}
|
||||
isLink={artist.id !== ''}
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
overflow="hidden"
|
||||
size="xs"
|
||||
to={
|
||||
artist.id
|
||||
? generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{artist.name || '—'}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.lineItem,
|
||||
styles.secondary,
|
||||
PlaybackSelectors.songAlbum,
|
||||
)}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<Text
|
||||
component={Link}
|
||||
fw={500}
|
||||
isLink
|
||||
onClick={handleToggleFullScreenPlayer}
|
||||
overflow="hidden"
|
||||
size="xs"
|
||||
to={
|
||||
currentSong?.albumId
|
||||
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||
albumId: currentSong.albumId,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{currentSong?.album || '—'}
|
||||
</Text>
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
<div className={styles.controlsWrapper}>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaPrevious" size="md" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mediaPrevious();
|
||||
}}
|
||||
tooltip={{
|
||||
label: t('player.previous', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<PlayButton
|
||||
disabled={currentSong?.id === undefined}
|
||||
isPaused={status === PlayerStatus.PAUSED}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mediaTogglePlayPause();
|
||||
}}
|
||||
/>
|
||||
<PlayerButton
|
||||
icon={<Icon fill="default" icon="mediaNext" size="md" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mediaNext();
|
||||
}}
|
||||
tooltip={{
|
||||
label: t('player.next', { postProcess: 'sentenceCase' }),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,13 +4,10 @@ import { PlayerbarSeekSlider } from './playerbar-seek-slider';
|
||||
import styles from './playerbar-slider.module.css';
|
||||
import { PlayerbarWaveform } from './playerbar-waveform';
|
||||
|
||||
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||
import { useRemote } from '/@/renderer/features/remote/hooks/use-remote';
|
||||
import {
|
||||
useAppStore,
|
||||
useAppStoreActions,
|
||||
usePlaybackType,
|
||||
usePlayerSong,
|
||||
usePlayerTimestamp,
|
||||
} from '/@/renderer/store';
|
||||
@@ -18,10 +15,8 @@ import { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/setti
|
||||
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
import { PlayerType } from '/@/shared/types/types';
|
||||
|
||||
export const PlayerbarSlider = () => {
|
||||
const playbackType = usePlaybackType();
|
||||
const currentSong = usePlayerSong();
|
||||
const playerbarSlider = usePlayerbarSlider();
|
||||
|
||||
@@ -76,8 +71,6 @@ export const PlayerbarSlider = () => {
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{playbackType === PlayerType.WEB && <WebPlayer />}
|
||||
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,10 @@ import styles from './playerbar.module.css';
|
||||
|
||||
import { CenterControls } from '/@/renderer/features/player/components/center-controls';
|
||||
import { LeftControls } from '/@/renderer/features/player/components/left-controls';
|
||||
import { MobilePlayerbar } from '/@/renderer/features/player/components/mobile-playerbar';
|
||||
import { RightControls } from '/@/renderer/features/player/components/right-controls';
|
||||
import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
||||
import { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
|
||||
import { useFullScreenPlayerStore, useSetFullScreenPlayerStore } from '/@/renderer/store';
|
||||
import { useGeneralSettings } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||
@@ -15,6 +17,7 @@ export const Playerbar = () => {
|
||||
const { playerbarOpenDrawer } = useGeneralSettings();
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
usePowerSaveBlocker();
|
||||
|
||||
@@ -23,6 +26,10 @@ export const Playerbar = () => {
|
||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return <MobilePlayerbar />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-colors-background-alternate);
|
||||
}
|
||||
|
||||
.scroll-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.accordion-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-control {
|
||||
height: 2.5rem;
|
||||
border-radius: var(--theme-radius-md);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0;
|
||||
background: var(--theme-colors-background-alternate);
|
||||
}
|
||||
|
||||
.accordion-content:last-child {
|
||||
padding-bottom: var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.server-selector-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import styles from './mobile-sidebar.module.css';
|
||||
|
||||
import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar';
|
||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
||||
import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon';
|
||||
import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item';
|
||||
import {
|
||||
SidebarPlaylistList,
|
||||
SidebarSharedPlaylistList,
|
||||
} from '/@/renderer/features/sidebar/components/sidebar-playlist-list';
|
||||
import { SidebarItemType, useGeneralSettings } from '/@/renderer/store/settings.store';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
export const MobileSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { sidebarPlaylistList } = useGeneralSettings();
|
||||
|
||||
const translatedSidebarItemMap = useMemo(
|
||||
() => ({
|
||||
Albums: t('page.sidebar.albums', { postProcess: 'titleCase' }),
|
||||
Artists: t('page.sidebar.albumArtists', { postProcess: 'titleCase' }),
|
||||
'Artists-all': t('page.sidebar.artists', { postProcess: 'titleCase' }),
|
||||
Genres: t('page.sidebar.genres', { postProcess: 'titleCase' }),
|
||||
Home: t('page.sidebar.home', { postProcess: 'titleCase' }),
|
||||
'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }),
|
||||
Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }),
|
||||
Search: t('page.sidebar.search', { postProcess: 'titleCase' }),
|
||||
Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }),
|
||||
Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const { sidebarItems } = useGeneralSettings();
|
||||
|
||||
const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => {
|
||||
if (!sidebarItems) return [];
|
||||
|
||||
const items = sidebarItems
|
||||
.filter((item) => !item.disabled)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label:
|
||||
translatedSidebarItemMap[item.id as keyof typeof translatedSidebarItemMap] ??
|
||||
item.label,
|
||||
}));
|
||||
|
||||
return items;
|
||||
}, [sidebarItems, translatedSidebarItemMap]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} id="mobile-sidebar">
|
||||
<Group grow id="global-search-container" style={{ flexShrink: 0 }}>
|
||||
<ActionBar />
|
||||
</Group>
|
||||
<ScrollArea allowDragScroll className={styles.scrollArea}>
|
||||
<Accordion
|
||||
classNames={{
|
||||
content: styles.accordionContent,
|
||||
control: styles.accordionControl,
|
||||
item: styles.accordionItem,
|
||||
root: styles.accordionRoot,
|
||||
}}
|
||||
defaultValue={['library', 'playlists']}
|
||||
multiple
|
||||
>
|
||||
<Accordion.Item value="library">
|
||||
<Accordion.Control>
|
||||
<Text fw={600} variant="secondary">
|
||||
{t('page.sidebar.myLibrary', {
|
||||
postProcess: 'titleCase',
|
||||
})}
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{sidebarItemsWithRoute.map((item) => {
|
||||
return (
|
||||
<SidebarItem key={`sidebar-${item.route}`} to={item.route}>
|
||||
<Group gap="sm">
|
||||
<SidebarIcon
|
||||
active={location.pathname === item.route}
|
||||
route={item.route}
|
||||
/>
|
||||
{item.label}
|
||||
</Group>
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{sidebarPlaylistList && (
|
||||
<>
|
||||
<SidebarPlaylistList />
|
||||
<SidebarSharedPlaylistList />
|
||||
</>
|
||||
)}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
<div className={styles.serverSelectorWrapper}>
|
||||
<ServerSelector showImage={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -248,7 +248,6 @@ export const AppMenu = () => {
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{item.items.map((subItem) => {
|
||||
console.log(subItem.id);
|
||||
return <Fragment key={subItem.id}>{renderMenuItem(subItem)}</Fragment>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
export const useIsMobile = () => {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
return isMobile;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
.layout {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'window-bar'
|
||||
'main-content'
|
||||
'player';
|
||||
grid-template-rows: 0 calc(100vh - 90px) 90px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
position: absolute;
|
||||
bottom: calc(90px + 0.75rem);
|
||||
left: 0.75rem;
|
||||
z-index: 100;
|
||||
background: color-mix(in srgb, var(--theme-colors-background) 90%, transparent);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
grid-area: main-content;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.full-screen-player-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
background: var(--theme-colors-background);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.full-screen-player-visible {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import clsx from 'clsx';
|
||||
import { lazy } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
|
||||
import styles from './mobile-layout.module.css';
|
||||
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
|
||||
import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
|
||||
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
|
||||
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useFullScreenPlayerStore } from '/@/renderer/store';
|
||||
import { useCommandPalette } from '/@/renderer/store';
|
||||
import { useHotkeySettings } from '/@/renderer/store/settings.store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Drawer } from '/@/shared/components/drawer/drawer';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { useHotkeys } from '/@/shared/hooks/use-hotkeys';
|
||||
|
||||
const WindowBar = lazy(() =>
|
||||
import('/@/renderer/layouts/window-bar').then((module) => ({
|
||||
default: module.WindowBar,
|
||||
})),
|
||||
);
|
||||
|
||||
interface MobileLayoutProps {
|
||||
shell?: boolean;
|
||||
}
|
||||
|
||||
export const MobileLayout = ({ shell }: MobileLayoutProps) => {
|
||||
const { opened, ...handlers } = useCommandPalette();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const navigate = useNavigate();
|
||||
const [sidebarOpened, { close: closeSidebar, open: openSidebar }] = useDisclosure(false);
|
||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||
|
||||
useHotkeys([
|
||||
[bindings.globalSearch.hotkey, () => handlers.open()],
|
||||
[bindings.browserBack.hotkey, () => navigate(-1)],
|
||||
[bindings.browserForward.hotkey, () => navigate(1)],
|
||||
[bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)],
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(styles.layout)} id="mobile-layout">
|
||||
{!shell && <WindowBar />}
|
||||
<ActionIcon
|
||||
className={styles.drawerButton}
|
||||
icon="menu"
|
||||
onClick={openSidebar}
|
||||
size="lg"
|
||||
tooltip={{ label: 'Menu' }}
|
||||
variant="subtle"
|
||||
/>
|
||||
<main className={styles.mainContent}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<PlayerBar />
|
||||
</div>
|
||||
<Drawer
|
||||
onClose={closeSidebar}
|
||||
opened={sidebarOpened}
|
||||
position="left"
|
||||
size="320px"
|
||||
styles={{
|
||||
body: {
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
},
|
||||
content: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<MobileSidebar />
|
||||
</Drawer>
|
||||
<div
|
||||
className={clsx(styles.fullScreenPlayerOverlay, {
|
||||
[styles.fullScreenPlayerVisible]: isFullScreenPlayerExpanded,
|
||||
})}
|
||||
>
|
||||
<MobileFullscreenPlayer />
|
||||
</div>
|
||||
<CommandPalette modalProps={{ handlers, opened }} />
|
||||
<ContextMenuController.Root />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
|
||||
import { DefaultLayout } from '/@/renderer/layouts/default-layout';
|
||||
import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout';
|
||||
|
||||
interface ResponsiveLayoutProps {
|
||||
shell?: boolean;
|
||||
}
|
||||
|
||||
export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileLayout shell={shell} />;
|
||||
}
|
||||
|
||||
return <DefaultLayout shell={shell} />;
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { AppRoute } from './routes';
|
||||
import { RouterErrorBoundary } from '/@/renderer/components/error-boundary/router-error-boundary';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
|
||||
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal';
|
||||
import { DefaultLayout } from '/@/renderer/layouts/default-layout';
|
||||
import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';
|
||||
import { AppOutlet } from '/@/renderer/router/app-outlet';
|
||||
import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
|
||||
import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';
|
||||
@@ -85,7 +85,7 @@ export const AppRouter = () => {
|
||||
<Routes>
|
||||
<Route element={<TitlebarOutlet />}>
|
||||
<Route element={<AppOutlet />} errorElement={<RouteErrorBoundary />}>
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route element={<ResponsiveLayout />}>
|
||||
<Route
|
||||
element={<HomeRoute />}
|
||||
errorElement={<RouteErrorBoundary />}
|
||||
@@ -206,7 +206,7 @@ export const AppRouter = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<TitlebarOutlet />}>
|
||||
<Route element={<DefaultLayout shell />}>
|
||||
<Route element={<ResponsiveLayout shell />}>
|
||||
<Route
|
||||
element={<ActionRequiredRoute />}
|
||||
path={AppRoute.ACTION_REQUIRED}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Drawer as MantineDrawer, DrawerProps as MantineDrawerProps } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface DrawerProps extends MantineDrawerProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Drawer = ({ children, ...props }: DrawerProps) => {
|
||||
return <MantineDrawer {...props}>{children}</MantineDrawer>;
|
||||
};
|
||||
Reference in New Issue
Block a user