add basic mobile responsive layout

This commit is contained in:
jeffvli
2025-11-19 19:23:44 -08:00
parent 485fe8085c
commit c763824803
26 changed files with 1930 additions and 40 deletions
+2
View File
@@ -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>
+6
View File
@@ -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} />;
};
+3 -3
View File
@@ -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}
+10
View File
@@ -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>;
};