From db88a6bc22e4b489ee2a7ca7cd4a81dcab1bbbef Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 17 Mar 2026 19:00:55 -0700 Subject: [PATCH] support vertical play queue layout --- docs/ENV_SETTINGS.md | 1 + settings.js.template | 1 + src/i18n/locales/en.json | 4 + .../general/application-settings.tsx | 41 ++++++++ .../components/resize-handle.module.css | 8 ++ .../features/titlebar/components/app-menu.tsx | 76 ++++++++++++++- .../default-layout/main-content.module.css | 17 ++++ .../layouts/default-layout/main-content.tsx | 95 ++++++++++++++----- .../default-layout/right-sidebar.module.css | 5 + .../layouts/default-layout/right-sidebar.tsx | 15 ++- src/renderer/store/app.store.ts | 11 ++- src/renderer/store/env-settings-overrides.ts | 7 ++ src/renderer/store/settings.store.ts | 15 ++- src/shared/components/icon/icon.tsx | 4 + 14 files changed, 268 insertions(+), 32 deletions(-) diff --git a/docs/ENV_SETTINGS.md b/docs/ENV_SETTINGS.md index 7901e23a5..4c1087593 100644 --- a/docs/ENV_SETTINGS.md +++ b/docs/ENV_SETTINGS.md @@ -44,6 +44,7 @@ These variables override app settings **on first run** when no persisted setting | `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. | | `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. | | `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. | +| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. | | `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. | | `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. | | `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). | diff --git a/settings.js.template b/settings.js.template index f84b57f81..50157056b 100644 --- a/settings.js.template +++ b/settings.js.template @@ -39,6 +39,7 @@ window.FS_GENERAL_SIDEBAR_COLLAPSE_SHARED = "${FS_GENERAL_SIDEBAR_COLLAPSE_SHARE window.FS_GENERAL_SIDEBAR_PLAYLIST_LIST = "${FS_GENERAL_SIDEBAR_PLAYLIST_LIST}"; window.FS_GENERAL_SIDEBAR_PLAYLIST_SORTING = "${FS_GENERAL_SIDEBAR_PLAYLIST_SORTING}"; window.FS_GENERAL_SIDE_QUEUE_TYPE = "${FS_GENERAL_SIDE_QUEUE_TYPE}"; +window.FS_GENERAL_SIDE_QUEUE_LAYOUT = "${FS_GENERAL_SIDE_QUEUE_LAYOUT}"; window.FS_GENERAL_THEME = "${FS_GENERAL_THEME}"; window.FS_GENERAL_THEME_DARK = "${FS_GENERAL_THEME_DARK}"; window.FS_GENERAL_THEME_LIGHT = "${FS_GENERAL_THEME_LIGHT}"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f7162f557..0ee7b4ca9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1036,6 +1036,10 @@ "sidePlayQueueStyle_description": "sets the style of the side play queue", "sidePlayQueueStyle_optionAttached": "attached", "sidePlayQueueStyle_optionDetached": "detached", + "sidePlayQueueLayout": "side play queue layout", + "sidePlayQueueLayout_description": "sets the layout of the attached side play queue", + "sidePlayQueueLayout_optionHorizontal": "horizontal", + "sidePlayQueueLayout_optionVertical": "vertical", "mediaSession_description": "enables Media Session integration, displaying media controls and metadata in the system volume overlay and lock screen", "mediaSession": "enable media session", "sidePlayQueueStyle": "side play queue style", diff --git a/src/renderer/features/settings/components/general/application-settings.tsx b/src/renderer/features/settings/components/general/application-settings.tsx index c7172b4db..3f4116b31 100644 --- a/src/renderer/features/settings/components/general/application-settings.tsx +++ b/src/renderer/features/settings/components/general/application-settings.tsx @@ -20,6 +20,7 @@ import { } from '/@/renderer/features/settings/components/settings-section'; import { HomeFeatureStyle, + SideQueueLayout, SideQueueType, useFontSettings, useGeneralSettings, @@ -74,6 +75,23 @@ const SIDE_QUEUE_OPTIONS = [ }, ]; +const SIDE_QUEUE_LAYOUT_OPTIONS = [ + { + label: t('setting.sidePlayQueueLayout', { + context: 'optionHorizontal', + postProcess: 'sentenceCase', + }), + value: 'horizontal', + }, + { + label: t('setting.sidePlayQueueLayout', { + context: 'optionVertical', + postProcess: 'sentenceCase', + }), + value: 'vertical', + }, +]; + const FONT_TYPES: Font[] = [ { label: i18n.t('setting.fontType', { @@ -539,6 +557,29 @@ export const ApplicationSettings = memo(() => { isHidden: false, title: t('setting.sidePlayQueueStyle', { postProcess: 'sentenceCase' }), }, + { + control: ( + + setSettings({ + general: { + ...settings, + sideQueueLayout: e as SideQueueLayout, + }, + }) + } + /> + ), + description: t('setting.sidePlayQueueLayout', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: settings.sideQueueType !== 'sideQueue', + title: t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' }), + }, { control: ( { const collapsed = useAppStore((state) => state.sidebar.collapsed); const privateMode = useAppStore((state) => state.privateMode); const { setPrivateMode, setSideBar } = useAppStoreActions(); + const { setSettings } = useSettingsStoreActions(); + const settings = useGeneralSettings(); const { open: openCommandPalette } = useCommandPalette(); const handleBrowserDevTools = () => { @@ -115,6 +125,15 @@ export const AppMenu = () => { browser?.quit(); }; + const handleSetSideQueueLayout = (sideQueueLayout: 'horizontal' | 'vertical') => { + setSettings({ + general: { + ...settings, + sideQueueLayout, + }, + }); + }; + const menuConfig: MenuItem[] = [ { icon: 'search', @@ -265,6 +284,61 @@ export const AppMenu = () => { }, type: 'conditional-item', }, + { + id: 'divider-5', + type: 'divider', + }, + { + condition: settings.sideQueueType === 'sideQueue', + id: 'layout-toggle-group', + items: [ + { + component: ( + + handleSetSideQueueLayout('horizontal')} + tooltip={{ + label: t('setting.sidePlayQueueLayout', { + context: 'optionHorizontal', + postProcess: 'sentenceCase', + }), + openDelay: 0, + position: 'bottom', + }} + variant={ + settings.sideQueueLayout === 'horizontal' ? 'light' : 'default' + } + /> + handleSetSideQueueLayout('vertical')} + tooltip={{ + label: t('setting.sidePlayQueueLayout', { + context: 'optionVertical', + postProcess: 'sentenceCase', + }), + openDelay: 0, + position: 'bottom', + }} + variant={ + settings.sideQueueLayout === 'vertical' ? 'light' : 'default' + } + /> + + ), + id: 'layout-toggle', + type: 'custom', + }, + ], + type: 'conditional-group', + }, ]; const renderMenuItem = (item: MenuItem): ReactNode => { diff --git a/src/renderer/layouts/default-layout/main-content.module.css b/src/renderer/layouts/default-layout/main-content.module.css index d5754ea73..2012522a3 100644 --- a/src/renderer/layouts/default-layout/main-content.module.css +++ b/src/renderer/layouts/default-layout/main-content.module.css @@ -28,6 +28,23 @@ grid-template-columns: 80px 1fr var(--right-sidebar-width); } +.main-content-container.vertical-layout { + grid-template-areas: + 'sidebar .' + 'sidebar right-sidebar'; + grid-template-rows: minmax(0, 1fr) var(--right-sidebar-height); + grid-template-columns: var(--sidebar-width) 1fr; +} + +.main-content-container.sidebar-collapsed.vertical-layout { + grid-template-columns: 80px 1fr; +} + +.main-content-container.vertical-layout #sidebar-queue { + border-top: 1px solid alpha(var(--theme-colors-border), 0.5); + border-left: 0; +} + .main-content-body { display: flex; flex: 1; diff --git a/src/renderer/layouts/default-layout/main-content.tsx b/src/renderer/layouts/default-layout/main-content.tsx index 65c51f07d..18ef1aa9d 100644 --- a/src/renderer/layouts/default-layout/main-content.tsx +++ b/src/renderer/layouts/default-layout/main-content.tsx @@ -16,6 +16,7 @@ import { useAppStore, useAppStoreActions, useGlobalExpanded, + useSideQueueLayout, useSideQueueType, } from '/@/renderer/store'; import { constrainRightSidebarWidth, constrainSidebarWidth } from '/@/renderer/utils'; @@ -24,56 +25,77 @@ import { Spinner } from '/@/shared/components/spinner/spinner'; const MINIMUM_SIDEBAR_WIDTH = 260; export const MainContent = ({ shell }: { shell?: boolean }) => { - const { collapsed, leftWidth, rightExpanded, rightWidth } = useAppStore( + const { collapsed, leftWidth, rightExpanded, rightHeight, rightWidth } = useAppStore( (state) => ({ collapsed: state.sidebar.collapsed, leftWidth: state.sidebar.leftWidth, rightExpanded: state.sidebar.rightExpanded, + rightHeight: state.sidebar.rightHeight, rightWidth: state.sidebar.rightWidth, }), shallow, ); const { setSideBar } = useAppStoreActions(); const sideQueueType = useSideQueueType(); + const sideQueueLayout = useSideQueueLayout(); const [isResizing, setIsResizing] = useState(false); const [isResizingRight, setIsResizingRight] = useState(false); const rightSidebarRef = useRef(null); const mainContentRef = useRef(null); const initialRightWidthRef = useRef(rightWidth); + const initialRightHeightRef = useRef(rightHeight); const initialMouseXRef = useRef(0); + const initialMouseYRef = useRef(0); const wasCollapsedDuringDragRef = useRef(false); useEffect(() => { if (mainContentRef.current && !isResizing && !isResizingRight) { mainContentRef.current.style.setProperty('--sidebar-width', leftWidth); mainContentRef.current.style.setProperty('--right-sidebar-width', rightWidth); + mainContentRef.current.style.setProperty('--right-sidebar-height', rightHeight); initialRightWidthRef.current = rightWidth; + initialRightHeightRef.current = rightHeight; } - }, [leftWidth, rightWidth, isResizing, isResizingRight]); + }, [leftWidth, rightWidth, rightHeight, isResizing, isResizingRight]); const startResizing = useCallback( - (position: 'left' | 'right', mouseEvent?: MouseEvent) => { + (position: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => { if (position === 'left') { setIsResizing(true); wasCollapsedDuringDragRef.current = false; } else { setIsResizingRight(true); if (mainContentRef.current && rightSidebarRef.current && mouseEvent) { - const currentWidth = - mainContentRef.current.style.getPropertyValue('--right-sidebar-width'); - if (currentWidth) { - initialRightWidthRef.current = currentWidth; + if (position === 'top') { + const currentHeight = + mainContentRef.current.style.getPropertyValue('--right-sidebar-height'); + if (currentHeight) { + initialRightHeightRef.current = currentHeight; + } else { + initialRightHeightRef.current = rightHeight; + } + initialMouseYRef.current = mouseEvent.clientY; + } else { + const currentWidth = + mainContentRef.current.style.getPropertyValue('--right-sidebar-width'); + if (currentWidth) { + initialRightWidthRef.current = currentWidth; + } else { + initialRightWidthRef.current = rightWidth; + } + initialMouseXRef.current = mouseEvent.clientX; + } + } else { + if (position === 'top') { + initialRightHeightRef.current = rightHeight; } else { initialRightWidthRef.current = rightWidth; } - initialMouseXRef.current = mouseEvent.clientX; - } else { - initialRightWidthRef.current = rightWidth; } } }, - [rightWidth], + [rightHeight, rightWidth], ); const stopResizing = useCallback(() => { @@ -87,14 +109,22 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { setIsResizing(false); wasCollapsedDuringDragRef.current = false; } else if (isResizingRight && mainContentRef.current) { - const finalWidth = - mainContentRef.current.style.getPropertyValue('--right-sidebar-width'); - if (finalWidth) { - setSideBar({ rightWidth: finalWidth }); + if (sideQueueLayout === 'vertical') { + const finalHeight = + mainContentRef.current.style.getPropertyValue('--right-sidebar-height'); + if (finalHeight) { + setSideBar({ rightHeight: finalHeight }); + } + } else { + const finalWidth = + mainContentRef.current.style.getPropertyValue('--right-sidebar-width'); + if (finalWidth) { + setSideBar({ rightWidth: finalWidth }); + } } setIsResizingRight(false); } - }, [isResizing, isResizingRight, setSideBar]); + }, [isResizing, isResizingRight, setSideBar, sideQueueLayout]); const resize = useCallback( (mouseMoveEvent: any) => { @@ -118,15 +148,30 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { mainContentRef.current.style.setProperty('--sidebar-width', constrainedWidth); } } else if (isResizingRight) { - const initialWidth = Number(initialRightWidthRef.current.split('px')[0]); - const initialMouseX = initialMouseXRef.current; - const deltaX = mouseMoveEvent.clientX - initialMouseX; - const newWidth = initialWidth - deltaX; - const width = `${constrainRightSidebarWidth(newWidth)}px`; - mainContentRef.current.style.setProperty('--right-sidebar-width', width); + if (sideQueueLayout === 'vertical') { + const initialHeight = Number(initialRightHeightRef.current.split('px')[0]); + const initialMouseY = initialMouseYRef.current; + const deltaY = mouseMoveEvent.clientY - initialMouseY; + const containerHeight = mainContentRef.current.clientHeight; + const minHeight = 220; + const maxHeight = Math.max(minHeight, containerHeight - 200); + const newHeight = initialHeight - deltaY; + const clampedHeight = Math.min(Math.max(newHeight, minHeight), maxHeight); + mainContentRef.current.style.setProperty( + '--right-sidebar-height', + `${clampedHeight}px`, + ); + } else { + const initialWidth = Number(initialRightWidthRef.current.split('px')[0]); + const initialMouseX = initialMouseXRef.current; + const deltaX = mouseMoveEvent.clientX - initialMouseX; + const newWidth = initialWidth - deltaX; + const width = `${constrainRightSidebarWidth(newWidth)}px`; + mainContentRef.current.style.setProperty('--right-sidebar-width', width); + } } }, - [isResizing, isResizingRight, setSideBar], + [isResizing, isResizingRight, setSideBar, sideQueueLayout], ); useEffect(() => { @@ -145,6 +190,10 @@ export const MainContent = ({ shell }: { shell?: boolean }) => { [styles.shell]: shell, [styles.sidebarCollapsed]: collapsed, [styles.sidebarExpanded]: !collapsed, + [styles.verticalLayout]: + rightExpanded && + sideQueueType === 'sideQueue' && + sideQueueLayout === 'vertical', })} id="main-content" ref={mainContentRef} diff --git a/src/renderer/layouts/default-layout/right-sidebar.module.css b/src/renderer/layouts/default-layout/right-sidebar.module.css index 9eaa7c3d5..581418304 100644 --- a/src/renderer/layouts/default-layout/right-sidebar.module.css +++ b/src/renderer/layouts/default-layout/right-sidebar.module.css @@ -14,6 +14,11 @@ } } +.right-sidebar-container.vertical-layout { + border-top: 1px solid alpha(var(--theme-colors-border), 0.5); + border-left: 0; +} + .queue-drawer { border-radius: var(--theme-radius-lg); } diff --git a/src/renderer/layouts/default-layout/right-sidebar.tsx b/src/renderer/layouts/default-layout/right-sidebar.tsx index f5f20c5dd..9975e05fe 100644 --- a/src/renderer/layouts/default-layout/right-sidebar.tsx +++ b/src/renderer/layouts/default-layout/right-sidebar.tsx @@ -1,10 +1,11 @@ +import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; import styles from './right-sidebar.module.css'; import { SidebarPlayQueue } from '/@/renderer/features/now-playing/components/sidebar-play-queue'; import { ResizeHandle } from '/@/renderer/features/shared/components/resize-handle'; -import { useAppStore, useSideQueueType } from '/@/renderer/store'; +import { useAppStore, useSideQueueLayout, useSideQueueType } from '/@/renderer/store'; // const queueDrawerVariants: Variants = { // closed: (windowBarStyle) => ({ @@ -46,7 +47,7 @@ import { useAppStore, useSideQueueType } from '/@/renderer/store'; interface RightSidebarProps { isResizing: boolean; - startResizing: (direction: 'left' | 'right', mouseEvent?: MouseEvent) => void; + startResizing: (direction: 'left' | 'right' | 'top', mouseEvent?: MouseEvent) => void; } export const RightSidebar = forwardRef( @@ -56,12 +57,16 @@ export const RightSidebar = forwardRef( ) => { const rightExpanded = useAppStore((state) => state.sidebar.rightExpanded); const sideQueueType = useSideQueueType(); + const sideQueueLayout = useSideQueueLayout(); + const isVerticalLayout = sideQueueLayout === 'vertical'; return ( <> {rightExpanded && sideQueueType === 'sideQueue' && (