From c7638248035171c96061034ff4fc6329c28d94c8 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 19 Nov 2025 19:23:44 -0800 Subject: [PATCH] add basic mobile responsive layout --- src/renderer/app.tsx | 2 + .../item-grid-list/item-grid-list.tsx | 42 +- .../item-table-list/item-table-list.tsx | 61 ++- .../context-menu/menus/song-context-menu.tsx | 2 - .../player/components/audio-players.tsx | 15 + .../mobile-fullscreen-player-album-art.tsx | 59 +++ ...bile-fullscreen-player-bottom-controls.tsx | 89 +++++ .../mobile-fullscreen-player-controls.tsx | 88 +++++ .../mobile-fullscreen-player-header.tsx | 361 ++++++++++++++++++ .../mobile-fullscreen-player-metadata.tsx | 83 ++++ .../mobile-fullscreen-player-progress.tsx | 60 +++ .../mobile-fullscreen-player.module.css | 198 ++++++++++ .../components/mobile-fullscreen-player.tsx | 256 +++++++++++++ .../components/mobile-playerbar.module.css | 66 ++++ .../player/components/mobile-playerbar.tsx | 229 +++++++++++ .../player/components/playerbar-slider.tsx | 7 - .../features/player/components/playerbar.tsx | 7 + .../components/mobile-sidebar.module.css | 46 +++ .../sidebar/components/mobile-sidebar.tsx | 112 ++++++ .../features/titlebar/components/app-menu.tsx | 1 - src/renderer/hooks/use-is-mobile.ts | 6 + .../mobile-layout/mobile-layout.module.css | 55 +++ .../layouts/mobile-layout/mobile-layout.tsx | 92 +++++ src/renderer/layouts/responsive-layout.tsx | 17 + src/renderer/router/app-router.tsx | 6 +- src/shared/components/drawer/drawer.tsx | 10 + 26 files changed, 1930 insertions(+), 40 deletions(-) create mode 100644 src/renderer/features/player/components/audio-players.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-bottom-controls.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-header.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-metadata.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player-progress.tsx create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player.module.css create mode 100644 src/renderer/features/player/components/mobile-fullscreen-player.tsx create mode 100644 src/renderer/features/player/components/mobile-playerbar.module.css create mode 100644 src/renderer/features/player/components/mobile-playerbar.tsx create mode 100644 src/renderer/features/sidebar/components/mobile-sidebar.module.css create mode 100644 src/renderer/features/sidebar/components/mobile-sidebar.tsx create mode 100644 src/renderer/hooks/use-is-mobile.ts create mode 100644 src/renderer/layouts/mobile-layout/mobile-layout.module.css create mode 100644 src/renderer/layouts/mobile-layout/mobile-layout.tsx create mode 100644 src/renderer/layouts/responsive-layout.tsx create mode 100644 src/shared/components/drawer/drawer.tsx diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 59b6d917e..3f817c178 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -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 = () => { + diff --git a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx index 1478e6433..1ff672a79 100644 --- a/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx +++ b/src/renderer/components/item-list/item-grid-list/item-grid-list.tsx @@ -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); diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index a45be0a96..12de595d4 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -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; 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; diff --git a/src/renderer/features/context-menu/menus/song-context-menu.tsx b/src/renderer/features/context-menu/menus/song-context-menu.tsx index b5184a22d..b55522045 100644 --- a/src/renderer/features/context-menu/menus/song-context-menu.tsx +++ b/src/renderer/features/context-menu/menus/song-context-menu.tsx @@ -21,8 +21,6 @@ export const SongContextMenu = ({ items }: SongContextMenuProps) => { return { ids }; }, [items]); - console.log(items, ids); - return ( diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx new file mode 100644 index 000000000..5858b3a07 --- /dev/null +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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 && } + {playbackType === PlayerType.LOCAL && } + + ); +}; diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx new file mode 100644 index 000000000..64cd60ca4 --- /dev/null +++ b/src/renderer/features/player/components/mobile-fullscreen-player-album-art.tsx @@ -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 ( +
+ + + +
+ ); + }, +); + +MobileFullscreenPlayerAlbumArt.displayName = 'MobileFullscreenPlayerAlbumArt'; diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-bottom-controls.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-bottom-controls.tsx new file mode 100644 index 000000000..f132c11c9 --- /dev/null +++ b/src/renderer/features/player/components/mobile-fullscreen-player-bottom-controls.tsx @@ -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) => 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 ( +
+ + + + + + + +
+ ); + }, +); + +MobileFullscreenPlayerBottomControls.displayName = 'MobileFullscreenPlayerBottomControls'; diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx new file mode 100644 index 000000000..449377fb2 --- /dev/null +++ b/src/renderer/features/player/components/mobile-fullscreen-player-controls.tsx @@ -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 ( +
+ } + onClick={mediaPrevious} + tooltip={{ + label: t('player.previous', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="secondary" + /> + } + onClick={mediaSkipBackward} + tooltip={{ + label: t('player.skip', { + context: 'back', + postProcess: 'sentenceCase', + }), + openDelay: 0, + }} + variant="tertiary" + /> + + } + onClick={mediaSkipForward} + tooltip={{ + label: t('player.skip', { + context: 'forward', + postProcess: 'sentenceCase', + }), + openDelay: 0, + }} + variant="tertiary" + /> + } + onClick={mediaNext} + tooltip={{ + label: t('player.next', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="secondary" + /> +
+ ); + }, +); + +MobileFullscreenPlayerControls.displayName = 'MobileFullscreenPlayerControls'; diff --git a/src/renderer/features/player/components/mobile-fullscreen-player-header.tsx b/src/renderer/features/player/components/mobile-fullscreen-player-header.tsx new file mode 100644 index 000000000..a5d025338 --- /dev/null +++ b/src/renderer/features/player/components/mobile-fullscreen-player-header.tsx @@ -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 ( +
+ + + + + + + + {dynamicBackground && ( + + )} + {dynamicBackground && dynamicIsImage && ( + + )} + {dynamicBackground && ( + + )} + + + + + + + +