mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +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 i18n from '/@/i18n/i18n';
|
||||||
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
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 { PlayerProvider } from '/@/renderer/features/player/context/player-context';
|
||||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||||
@@ -87,6 +88,7 @@ export const App = () => {
|
|||||||
<WebAudioContext.Provider value={webAudioProvider}>
|
<WebAudioContext.Provider value={webAudioProvider}>
|
||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
|
<AudioPlayers />
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
</WebAudioContext.Provider>
|
</WebAudioContext.Provider>
|
||||||
<IsUpdatedDialog />
|
<IsUpdatedDialog />
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export const ItemGridList = ({
|
|||||||
|
|
||||||
const internalState = useItemListState(getDataFn, extractRowId);
|
const internalState = useItemListState(getDataFn, extractRowId);
|
||||||
|
|
||||||
const [initialize] = useOverlayScrollbars({
|
const [initialize, osInstance] = useOverlayScrollbars({
|
||||||
defer: false,
|
defer: false,
|
||||||
events: {
|
events: {
|
||||||
initialized(osInstance) {
|
initialized(osInstance) {
|
||||||
@@ -323,15 +323,39 @@ export const ItemGridList = ({
|
|||||||
const { current: root } = rootRef;
|
const { current: root } = rootRef;
|
||||||
const { current: outer } = outerRef;
|
const { current: outer } = outerRef;
|
||||||
|
|
||||||
if (root && outer) {
|
if (!tableMeta || !root || !outer) {
|
||||||
initialize({
|
return;
|
||||||
elements: {
|
|
||||||
viewport: outer,
|
|
||||||
},
|
|
||||||
target: root,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
const throttledSetTableMeta = useMemo(() => {
|
||||||
return createThrottledSetTableMeta(itemsPerRow, rows?.length);
|
return createThrottledSetTableMeta(itemsPerRow, rows?.length);
|
||||||
|
|||||||
@@ -688,7 +688,6 @@ interface ItemTableListProps {
|
|||||||
type: 'index' | 'offset';
|
type: 'index' | 'offset';
|
||||||
};
|
};
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
startRowIndex?: number;
|
|
||||||
onColumnReordered?: (
|
onColumnReordered?: (
|
||||||
columnIdFrom: TableColumn,
|
columnIdFrom: TableColumn,
|
||||||
columnIdTo: TableColumn,
|
columnIdTo: TableColumn,
|
||||||
@@ -700,6 +699,7 @@ interface ItemTableListProps {
|
|||||||
ref?: Ref<ItemListHandle>;
|
ref?: Ref<ItemListHandle>;
|
||||||
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
|
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
|
||||||
size?: 'compact' | 'default' | 'large';
|
size?: 'compact' | 'default' | 'large';
|
||||||
|
startRowIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemTableList = ({
|
export const ItemTableList = ({
|
||||||
@@ -1111,7 +1111,7 @@ export const ItemTableList = ({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [initialize] = useOverlayScrollbars({
|
const [initialize, osInstance] = useOverlayScrollbars({
|
||||||
defer: false,
|
defer: false,
|
||||||
events: {
|
events: {
|
||||||
initialized(osInstance) {
|
initialized(osInstance) {
|
||||||
@@ -1135,24 +1135,49 @@ export const ItemTableList = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { current: root } = scrollContainerRef;
|
const { current: root } = scrollContainerRef;
|
||||||
|
|
||||||
if (root) {
|
if (!root || !root.firstElementChild) {
|
||||||
initialize({
|
return;
|
||||||
elements: { viewport: root.firstElementChild as HTMLElement },
|
|
||||||
target: root,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enableDrag) {
|
|
||||||
autoScrollForElements({
|
|
||||||
canScroll: () => true,
|
|
||||||
element: root.firstElementChild as HTMLElement,
|
|
||||||
getAllowedAxis: () => 'vertical',
|
|
||||||
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
const viewport = root.firstElementChild as HTMLElement;
|
||||||
}, [enableDrag, initialize]);
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement;
|
const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement;
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export const SongContextMenu = ({ items }: SongContextMenuProps) => {
|
|||||||
return { ids };
|
return { ids };
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
console.log(items, ids);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<PlayAction ids={ids} itemType={LibraryItem.SONG} songs={items} />
|
<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 styles from './playerbar-slider.module.css';
|
||||||
import { PlayerbarWaveform } from './playerbar-waveform';
|
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 { useRemote } from '/@/renderer/features/remote/hooks/use-remote';
|
||||||
import {
|
import {
|
||||||
useAppStore,
|
useAppStore,
|
||||||
useAppStoreActions,
|
useAppStoreActions,
|
||||||
usePlaybackType,
|
|
||||||
usePlayerSong,
|
usePlayerSong,
|
||||||
usePlayerTimestamp,
|
usePlayerTimestamp,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
@@ -18,10 +15,8 @@ import { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/setti
|
|||||||
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
|
||||||
|
|
||||||
export const PlayerbarSlider = () => {
|
export const PlayerbarSlider = () => {
|
||||||
const playbackType = usePlaybackType();
|
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
const playerbarSlider = usePlayerbarSlider();
|
const playerbarSlider = usePlayerbarSlider();
|
||||||
|
|
||||||
@@ -76,8 +71,6 @@ export const PlayerbarSlider = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</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 { CenterControls } from '/@/renderer/features/player/components/center-controls';
|
||||||
import { LeftControls } from '/@/renderer/features/player/components/left-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 { RightControls } from '/@/renderer/features/player/components/right-controls';
|
||||||
import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
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 { useFullScreenPlayerStore, useSetFullScreenPlayerStore } from '/@/renderer/store';
|
||||||
import { useGeneralSettings } from '/@/renderer/store/settings.store';
|
import { useGeneralSettings } from '/@/renderer/store/settings.store';
|
||||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||||
@@ -15,6 +17,7 @@ export const Playerbar = () => {
|
|||||||
const { playerbarOpenDrawer } = useGeneralSettings();
|
const { playerbarOpenDrawer } = useGeneralSettings();
|
||||||
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
const { expanded: isFullScreenPlayerExpanded } = useFullScreenPlayerStore();
|
||||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
usePowerSaveBlocker();
|
usePowerSaveBlocker();
|
||||||
|
|
||||||
@@ -23,6 +26,10 @@ export const Playerbar = () => {
|
|||||||
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
setFullScreenPlayerStore({ expanded: !isFullScreenPlayerExpanded });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <MobilePlayerbar />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(styles.container, PlaybackSelectors.mediaPlayer)}
|
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 (
|
return (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
{item.items.map((subItem) => {
|
{item.items.map((subItem) => {
|
||||||
console.log(subItem.id);
|
|
||||||
return <Fragment key={subItem.id}>{renderMenuItem(subItem)}</Fragment>;
|
return <Fragment key={subItem.id}>{renderMenuItem(subItem)}</Fragment>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { RouterErrorBoundary } from '/@/renderer/components/error-boundary/router-error-boundary';
|
||||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
|
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists/components/add-to-playlist-context-modal';
|
||||||
import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-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 { AppOutlet } from '/@/renderer/router/app-outlet';
|
||||||
import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
|
import { TitlebarOutlet } from '/@/renderer/router/titlebar-outlet';
|
||||||
import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';
|
import { BaseContextModal, ModalsProvider } from '/@/shared/components/modal/modal';
|
||||||
@@ -85,7 +85,7 @@ export const AppRouter = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<TitlebarOutlet />}>
|
<Route element={<TitlebarOutlet />}>
|
||||||
<Route element={<AppOutlet />} errorElement={<RouteErrorBoundary />}>
|
<Route element={<AppOutlet />} errorElement={<RouteErrorBoundary />}>
|
||||||
<Route element={<DefaultLayout />}>
|
<Route element={<ResponsiveLayout />}>
|
||||||
<Route
|
<Route
|
||||||
element={<HomeRoute />}
|
element={<HomeRoute />}
|
||||||
errorElement={<RouteErrorBoundary />}
|
errorElement={<RouteErrorBoundary />}
|
||||||
@@ -206,7 +206,7 @@ export const AppRouter = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<TitlebarOutlet />}>
|
<Route element={<TitlebarOutlet />}>
|
||||||
<Route element={<DefaultLayout shell />}>
|
<Route element={<ResponsiveLayout shell />}>
|
||||||
<Route
|
<Route
|
||||||
element={<ActionRequiredRoute />}
|
element={<ActionRequiredRoute />}
|
||||||
path={AppRoute.ACTION_REQUIRED}
|
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