mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-14 20:40:21 +02:00
add basic mobile responsive layout
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user