diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 8b4ae2cb..84260681 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,13 +1,11 @@ -import { star, unstar, setRating } from '../api/subsonicStarRating'; +import { star, unstar } from '../api/subsonicStarRating'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import type { SubsonicAlbum } from '../api/subsonicTypes'; -import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { - Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast, - PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis, + SlidersVertical, X, + PictureInPicture2, Ellipsis, } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; @@ -30,44 +28,15 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge'; import { usePlaybackScheduleRemaining } from '../utils/playbackScheduleFormat'; import { usePreviewStore } from '../store/previewStore'; import { usePerfProbeFlags } from '../utils/perfFlags'; - -function formatTime(seconds: number): string { - if (!seconds || isNaN(seconds)) return '0:00'; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -// Renders the playback clock without ever causing PlayerBar to re-render. -// Updates the DOM directly via an imperative store subscription. -const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) { - const spanRef = useRef(null); - useEffect(() => { - if (spanRef.current) { - spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime); - } - return subscribePlaybackProgress(state => { - if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); - }); - }, []); - return ; -}); - -// Renders the remaining time (duration - currentTime) without causing PlayerBar to re-render. -const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) { - const spanRef = useRef(null); - useEffect(() => { - const updateRemaining = () => { - if (spanRef.current) { - const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime); - spanRef.current.textContent = `-${formatTime(remaining)}`; - } - }; - updateRemaining(); - return subscribePlaybackProgress(updateRemaining); - }, [duration]); - return ; -}); +import { formatTime } from '../utils/playerBarHelpers'; +import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock'; +import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo'; +import { PlayerTransportControls } from './playerBar/PlayerTransportControls'; +import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection'; +import { PlayerVolume } from './playerBar/PlayerVolume'; +import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu'; +import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar'; +import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu'; export default function PlayerBar() { const { t } = useTranslation(); @@ -113,102 +82,24 @@ export default function PlayerBar() { }))); const { lastfmSessionKey } = useAuthStore(); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); - const [floatingStyle, setFloatingStyle] = useState({}); const playerBarRef = useRef(null); - const [utilityOverflow, setUtilityOverflow] = useState(false); - const [utilityMenuOpen, setUtilityMenuOpen] = useState(false); - const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full'); - const utilityMenuRef = useRef(null); - const utilityBtnRef = useRef(null); - const [utilityMenuStyle, setUtilityMenuStyle] = useState({}); - const volumeWheelMenuTimerRef = useRef(null); - const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false); const perfFlags = usePerfProbeFlags(); - useEffect(() => { - if (!floatingPlayerBar) return; + const floatingStyle = useFloatingPlayerBar(playerBarRef, floatingPlayerBar); - const updatePosition = () => { - const sidebar = document.querySelector('.sidebar') as HTMLElement; - const queue = document.querySelector('.queue-panel') as HTMLElement; - - const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0; - const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0; - - setFloatingStyle({ - left: leftOffset + 24, - right: rightOffset + 24, - width: 'auto', - }); - }; - - updatePosition(); - - const observer = new ResizeObserver(updatePosition); - const sidebar = document.querySelector('.sidebar'); - const queue = document.querySelector('.queue-panel'); - if (sidebar) observer.observe(sidebar); - if (queue) observer.observe(queue); - window.addEventListener('resize', updatePosition); - - return () => { - observer.disconnect(); - window.removeEventListener('resize', updatePosition); - }; - }, [floatingPlayerBar]); - - useEffect(() => { - const updateOverflow = () => { - const width = playerBarRef.current?.clientWidth ?? window.innerWidth; - const threshold = floatingPlayerBar ? 980 : 1140; - setUtilityOverflow(width < threshold); - }; - - updateOverflow(); - const ro = typeof ResizeObserver !== 'undefined' - ? new ResizeObserver(updateOverflow) - : null; - const el = playerBarRef.current; - if (ro && el) ro.observe(el); - window.addEventListener('resize', updateOverflow); - return () => { - ro?.disconnect(); - window.removeEventListener('resize', updateOverflow); - }; - }, [floatingPlayerBar]); - - useEffect(() => { - if (!utilityOverflow) setUtilityMenuOpen(false); - if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) { - window.clearTimeout(volumeWheelMenuTimerRef.current); - volumeWheelMenuTimerRef.current = null; - } - }, [utilityOverflow]); - - useEffect(() => { - if (!utilityMenuOpen) return; - const onDown = (e: MouseEvent) => { - const target = e.target as Node; - if (utilityBtnRef.current?.contains(target)) return; - if (utilityMenuRef.current?.contains(target)) return; - setUtilityMenuOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setUtilityMenuOpen(false); - }; - document.addEventListener('mousedown', onDown); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - document.removeEventListener('keydown', onKey); - }; - }, [utilityMenuOpen]); - - useEffect(() => () => { - if (volumeWheelMenuTimerRef.current != null) { - window.clearTimeout(volumeWheelMenuTimerRef.current); - } - }, []); + const { + utilityOverflow, + utilityMenuOpen, + setUtilityMenuOpen, + utilityMenuMode, + setUtilityMenuMode, + utilityMenuStyle, + utilityMenuRef, + utilityBtnRef, + volumeWheelMenuTimerRef, + suppressOverflowTooltip, + setSuppressOverflowTooltip, + } = useUtilityOverflowMenu(playerBarRef, floatingPlayerBar); useEffect(() => { const onToggleEqualizer = () => setEqOpen(v => !v); @@ -216,35 +107,6 @@ export default function PlayerBar() { return () => window.removeEventListener('psy:toggle-equalizer', onToggleEqualizer); }, []); - useEffect(() => { - if (!utilityMenuOpen) return; - const MENU_WIDTH = 238; - const MARGIN = 8; - const updateMenuPos = () => { - const btn = utilityBtnRef.current; - if (!btn) return; - const r = btn.getBoundingClientRect(); - const left = Math.min( - Math.max(r.right - MENU_WIDTH, MARGIN), - window.innerWidth - MENU_WIDTH - MARGIN, - ); - setUtilityMenuStyle({ - position: 'fixed', - left, - width: MENU_WIDTH, - bottom: window.innerHeight - r.top + 8, - zIndex: 10050, - }); - }; - updateMenuPos(); - window.addEventListener('resize', updateMenuPos); - window.addEventListener('scroll', updateMenuPos, true); - return () => { - window.removeEventListener('resize', updateMenuPos); - window.removeEventListener('scroll', updateMenuPos, true); - }; - }, [utilityMenuOpen]); - const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay); const transportAnchorRef = useRef(null); const playSlotRef = useRef(null); @@ -332,245 +194,59 @@ export default function PlayerBar() { aria-label={t('player.regionLabel')} > - {/* Track Info */} -
-
!isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()} - data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined} - > - {isRadio ? ( - currentRadio?.coverArt ? ( - - ) : ( -
- -
- ) - ) : displayCoverArt ? ( - - ) : ( -
- -
- )} - {currentTrack && !isRadio && !showPreviewMeta && ( - - )} -
-
- {showPreviewMeta && ( - - {t('player.previewLabel')} - - )} - !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} - onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId - ? (e) => { - e.preventDefault(); - const album: SubsonicAlbum = { - id: currentTrack.albumId, - name: currentTrack.album, - artist: currentTrack.artist, - artistId: currentTrack.artistId ?? '', - coverArt: currentTrack.coverArt, - songCount: 0, - duration: 0, - }; - openContextMenu(e.clientX, e.clientY, album, 'album'); - } - : undefined} - /> - !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} - /> - {currentTrack && !isRadio && !showPreviewMeta && ( - { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} - className="player-track-rating" - ariaLabel={t('albumDetail.ratingLabel')} - /> - )} - {isRadio && radioMeta.listeners != null && ( - - {t('radio.listenerCount', { count: radioMeta.listeners })} - - )} -
- {currentTrack && !isRadio && ( - - )} - {currentTrack && !isRadio && lastfmSessionKey && ( - - )} -
+ - {/* Transport Controls */} -
- - - - - {isPreviewing && ( - - )} - - - - -
+ - {/* Waveform Seekbar / Radio live bar */} -
- {isRadio ? ( - <> - {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? ( - <> - {formatTime(radioMeta.elapsed)} -
-
-
-
-
- {formatTime(radioMeta.duration)} - - ) : ( - <> - -
- {t('radio.live')} -
- 0:00 - - )} - - ) : ( - <> - -
- {perfFlags.disableWaveformCanvas - ?
- : } -
- { - const newVal = !localShowRemaining; - setLocalShowRemaining(newVal); - useThemeStore.getState().setShowRemainingTime(newVal); - }} - data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')} - > - {localShowRemaining ? : formatTime(duration)} - - - - )} -
+ {utilityOverflow ? (
@@ -595,7 +271,6 @@ export default function PlayerBar() {
) : ( <> - {/* EQ Button */} - {/* Mini Player */} - {/* Volume */} -
- -
- {showVolPct && ( - - {Math.round(volume * 100)}% - - )} - setShowVolPct(true)} - onMouseLeave={() => setShowVolPct(false)} - /> -
-
+ )} - {/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} - {utilityMenuOpen && createPortal( -
- {utilityMenuMode === 'full' && ( -
- - -
- )} - {utilityMenuMode === 'full' ? ( -
- -
- {showVolPct && ( - - {Math.round(volume * 100)}% - - )} - setShowVolPct(true)} - onMouseLeave={() => setShowVolPct(false)} - /> -
-
- ) : ( -
- -
- {showVolPct && ( - - {Math.round(volume * 100)}% - - )} - setShowVolPct(true)} - onMouseLeave={() => setShowVolPct(false)} - /> -
-
- )} -
, - document.body + {utilityMenuOpen && ( + setUtilityMenuOpen(false)} + volume={volume} + setVolume={setVolume} + premuteVolumeRef={premuteVolumeRef} + showVolPct={showVolPct} + setShowVolPct={setShowVolPct} + handleVolume={handleVolume} + handleVolumeWheel={handleVolumeWheel} + volumeStyle={volumeStyle} + t={t} + /> )} {/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */} diff --git a/src/components/playerBar/PlaybackClock.tsx b/src/components/playerBar/PlaybackClock.tsx new file mode 100644 index 00000000..0793e974 --- /dev/null +++ b/src/components/playerBar/PlaybackClock.tsx @@ -0,0 +1,35 @@ +import { memo, useEffect, useRef } from 'react'; +import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress'; +import { formatTime } from '../../utils/playerBarHelpers'; + +/** Renders the playback clock without ever causing PlayerBar to re-render. + * Updates the DOM directly via an imperative store subscription. */ +export const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) { + const spanRef = useRef(null); + useEffect(() => { + if (spanRef.current) { + spanRef.current.textContent = formatTime(getPlaybackProgressSnapshot().currentTime); + } + return subscribePlaybackProgress(state => { + if (spanRef.current) spanRef.current.textContent = formatTime(state.currentTime); + }); + }, []); + return ; +}); + +/** Renders the remaining time (duration - currentTime) without causing PlayerBar + * to re-render. */ +export const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) { + const spanRef = useRef(null); + useEffect(() => { + const updateRemaining = () => { + if (spanRef.current) { + const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime); + spanRef.current.textContent = `-${formatTime(remaining)}`; + } + }; + updateRemaining(); + return subscribePlaybackProgress(updateRemaining); + }, [duration]); + return ; +}); diff --git a/src/components/playerBar/PlayerOverflowMenu.tsx b/src/components/playerBar/PlayerOverflowMenu.tsx new file mode 100644 index 00000000..a4869fda --- /dev/null +++ b/src/components/playerBar/PlayerOverflowMenu.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { PictureInPicture2, SlidersVertical } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import type { TFunction } from 'i18next'; +import { PlayerVolume } from './PlayerVolume'; + +interface Props { + utilityMenuRef: React.RefObject; + utilityMenuStyle: React.CSSProperties; + utilityMenuMode: 'full' | 'volume'; + eqOpen: boolean; + setEqOpen: (updater: boolean | ((v: boolean) => boolean)) => void; + closeMenu: () => void; + volume: number; + setVolume: (v: number) => void; + premuteVolumeRef: React.MutableRefObject; + showVolPct: boolean; + setShowVolPct: (v: boolean) => void; + handleVolume: (e: React.ChangeEvent) => void; + handleVolumeWheel: (e: React.WheelEvent) => void; + volumeStyle: React.CSSProperties; + t: TFunction; +} + +export function PlayerOverflowMenu({ + utilityMenuRef, utilityMenuStyle, utilityMenuMode, + eqOpen, setEqOpen, closeMenu, + volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct, + handleVolume, handleVolumeWheel, volumeStyle, t, +}: Props) { + return createPortal( +
+ {utilityMenuMode === 'full' && ( +
+ + +
+ )} + +
, + document.body, + ); +} diff --git a/src/components/playerBar/PlayerSeekbarSection.tsx b/src/components/playerBar/PlayerSeekbarSection.tsx new file mode 100644 index 00000000..1856d720 --- /dev/null +++ b/src/components/playerBar/PlayerSeekbarSection.tsx @@ -0,0 +1,75 @@ +import { ArrowLeftRight } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import type { RadioMetadata } from '../../hooks/useRadioMetadata'; +import { useThemeStore } from '../../store/themeStore'; +import { formatTime } from '../../utils/playerBarHelpers'; +import WaveformSeek from '../WaveformSeek'; +import { PlaybackTime, RemainingTime } from './PlaybackClock'; + +interface Props { + isRadio: boolean; + radioMeta: RadioMetadata; + trackId: string | undefined; + duration: number; + localShowRemaining: boolean; + setLocalShowRemaining: (v: boolean) => void; + disableWaveformCanvas: boolean; + t: TFunction; +} + +export function PlayerSeekbarSection({ + isRadio, radioMeta, trackId, duration, localShowRemaining, setLocalShowRemaining, + disableWaveformCanvas, t, +}: Props) { + return ( +
+ {isRadio ? ( + <> + {radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? ( + <> + {formatTime(radioMeta.elapsed)} +
+
+
+
+
+ {formatTime(radioMeta.duration)} + + ) : ( + <> + +
+ {t('radio.live')} +
+ 0:00 + + )} + + ) : ( + <> + +
+ {disableWaveformCanvas + ?
+ : } +
+ { + const newVal = !localShowRemaining; + setLocalShowRemaining(newVal); + useThemeStore.getState().setShowRemainingTime(newVal); + }} + data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')} + > + {localShowRemaining ? : formatTime(duration)} + + + + )} +
+ ); +} diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx new file mode 100644 index 00000000..9891ac92 --- /dev/null +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -0,0 +1,167 @@ +import { Cast, Heart, Maximize2, Music } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { setRating } from '../../api/subsonicStarRating'; +import type { InternetRadioStation, SubsonicAlbum } from '../../api/subsonicTypes'; +import type { PlayerState, Track } from '../../store/playerStoreTypes'; +import type { RadioMetadata } from '../../hooks/useRadioMetadata'; +import type { PreviewingTrack } from '../../store/previewStore'; +import CachedImage from '../CachedImage'; +import LastfmIcon from '../LastfmIcon'; +import MarqueeText from '../MarqueeText'; +import StarRating from '../StarRating'; + +interface Props { + currentTrack: Track | null; + currentRadio: InternetRadioStation | null; + isRadio: boolean; + radioMeta: RadioMetadata; + radioCoverSrc: string; + radioCoverKey: string; + coverSrc: string; + coverKey: string; + displayCoverArt: string | undefined; + displayTitle: string; + displayArtist: string; + showPreviewMeta: boolean; + previewingTrack: PreviewingTrack | null; + isStarred: boolean; + toggleStar: () => void; + lastfmSessionKey: string | null; + lastfmLoved: boolean; + toggleLastfmLove: () => void; + userRatingOverrides: Record; + setUserRatingOverride: (id: string, r: number) => void; + toggleFullscreen: () => void; + navigate: (to: string) => void; + openContextMenu: PlayerState['openContextMenu']; + t: TFunction; +} + +export function PlayerTrackInfo({ + currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey, + coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, + showPreviewMeta, previewingTrack, isStarred, toggleStar, + lastfmSessionKey, lastfmLoved, toggleLastfmLove, + userRatingOverrides, setUserRatingOverride, toggleFullscreen, + navigate, openContextMenu, t, +}: Props) { + return ( +
+
!isRadio && !showPreviewMeta && currentTrack && toggleFullscreen()} + data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined} + > + {isRadio ? ( + currentRadio?.coverArt ? ( + + ) : ( +
+ +
+ ) + ) : displayCoverArt ? ( + + ) : ( +
+ +
+ )} + {currentTrack && !isRadio && !showPreviewMeta && ( + + )} +
+
+ {showPreviewMeta && ( + + {t('player.previewLabel')} + + )} + !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} + onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId + ? (e) => { + e.preventDefault(); + const album: SubsonicAlbum = { + id: currentTrack.albumId!, + name: currentTrack.album, + artist: currentTrack.artist, + artistId: currentTrack.artistId ?? '', + coverArt: currentTrack.coverArt, + songCount: 0, + duration: 0, + }; + openContextMenu(e.clientX, e.clientY, album, 'album'); + } + : undefined} + /> + !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} + /> + {currentTrack && !isRadio && !showPreviewMeta && ( + { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} + className="player-track-rating" + ariaLabel={t('albumDetail.ratingLabel')} + /> + )} + {isRadio && radioMeta.listeners != null && ( + + {t('radio.listenerCount', { count: radioMeta.listeners })} + + )} +
+ {currentTrack && !isRadio && ( + + )} + {currentTrack && !isRadio && lastfmSessionKey && ( + + )} +
+ ); +} diff --git a/src/components/playerBar/PlayerTransportControls.tsx b/src/components/playerBar/PlayerTransportControls.tsx new file mode 100644 index 00000000..208a3fbc --- /dev/null +++ b/src/components/playerBar/PlayerTransportControls.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import type { TFunction } from 'i18next'; +import type { PlayerState } from '../../store/playerStoreTypes'; +import { usePreviewStore } from '../../store/previewStore'; +import PlaybackScheduleBadge from '../PlaybackScheduleBadge'; +import { usePlaybackDelayPress } from '../../hooks/usePlaybackDelayPress'; +import { usePlaybackScheduleRemaining } from '../../utils/playbackScheduleFormat'; + +type RepeatMode = PlayerState['repeatMode']; +type PlayPauseBind = ReturnType['playPauseBind']; +type ScheduleRemaining = ReturnType; + +interface Props { + isPlaying: boolean; + isRadio: boolean; + isPreviewing: boolean; + stop: () => void; + previous: () => void; + next: () => void; + toggleRepeat: () => void; + repeatMode: RepeatMode; + playPauseBind: PlayPauseBind; + scheduleRemaining: ScheduleRemaining; + transportAnchorRef: React.RefObject; + playSlotRef: React.RefObject; + t: TFunction; +} + +export function PlayerTransportControls({ + isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode, + playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t, +}: Props) { + return ( +
+ + + + + {isPreviewing && ( + + )} + + + + +
+ ); +} diff --git a/src/components/playerBar/PlayerVolume.tsx b/src/components/playerBar/PlayerVolume.tsx new file mode 100644 index 00000000..f6aacfc4 --- /dev/null +++ b/src/components/playerBar/PlayerVolume.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Volume2, VolumeX } from 'lucide-react'; +import type { TFunction } from 'i18next'; + +interface Props { + volume: number; + setVolume: (v: number) => void; + premuteVolumeRef: React.MutableRefObject; + showVolPct: boolean; + setShowVolPct: (v: boolean) => void; + handleVolume: (e: React.ChangeEvent) => void; + handleVolumeWheel: (e: React.WheelEvent) => void; + volumeStyle: React.CSSProperties; + inputId: string; + /** 'menu' adds the --menu modifier to the outer section (used inside the + * overflow menu so the layout adapts). Defaults to no modifier. */ + sectionModifier?: 'menu'; + /** 'menu-only' adds the --menu-only modifier to the slider wrap, widening + * it when the menu is in volume-only mode. */ + wrapModifier?: 'menu-only'; + t: TFunction; +} + +export function PlayerVolume({ + volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct, + handleVolume, handleVolumeWheel, volumeStyle, inputId, + sectionModifier, wrapModifier, t, +}: Props) { + const sectionClass = `player-volume-section${sectionModifier ? ` player-volume-section--${sectionModifier}` : ''}`; + const wrapClass = `player-volume-slider-wrap${wrapModifier ? ` player-volume-slider-wrap--${wrapModifier}` : ''}`; + return ( +
+ +
+ {showVolPct && ( + + {Math.round(volume * 100)}% + + )} + setShowVolPct(true)} + onMouseLeave={() => setShowVolPct(false)} + /> +
+
+ ); +} diff --git a/src/hooks/useFloatingPlayerBar.ts b/src/hooks/useFloatingPlayerBar.ts new file mode 100644 index 00000000..3e36d160 --- /dev/null +++ b/src/hooks/useFloatingPlayerBar.ts @@ -0,0 +1,46 @@ +import React, { useEffect, useState } from 'react'; + +/** Computes the floating player-bar position based on the current sidebar + + * queue panel widths. Returns an inline-style object (left/right/width); only + * active when `floatingPlayerBar` is true. Uses a ResizeObserver on both + * containers so the bar slides while the sidebar or queue is resized. */ +export function useFloatingPlayerBar( + _playerBarRef: React.RefObject, + floatingPlayerBar: boolean, +): React.CSSProperties { + const [floatingStyle, setFloatingStyle] = useState({}); + + useEffect(() => { + if (!floatingPlayerBar) return; + + const updatePosition = () => { + const sidebar = document.querySelector('.sidebar') as HTMLElement; + const queue = document.querySelector('.queue-panel') as HTMLElement; + + const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0; + const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0; + + setFloatingStyle({ + left: leftOffset + 24, + right: rightOffset + 24, + width: 'auto', + }); + }; + + updatePosition(); + + const observer = new ResizeObserver(updatePosition); + const sidebar = document.querySelector('.sidebar'); + const queue = document.querySelector('.queue-panel'); + if (sidebar) observer.observe(sidebar); + if (queue) observer.observe(queue); + window.addEventListener('resize', updatePosition); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updatePosition); + }; + }, [floatingPlayerBar]); + + return floatingStyle; +} diff --git a/src/hooks/useUtilityOverflowMenu.ts b/src/hooks/useUtilityOverflowMenu.ts new file mode 100644 index 00000000..d6d3e2bf --- /dev/null +++ b/src/hooks/useUtilityOverflowMenu.ts @@ -0,0 +1,119 @@ +import React, { useEffect, useRef, useState } from 'react'; + +/** Wires the utility-overflow Ellipsis button + its portaled menu: + * - watches the player-bar width via ResizeObserver and flips `utilityOverflow` + * when it crosses the threshold (980 px floating / 1140 px docked) + * - owns the menu open state and the 'full' vs. 'volume' display mode + * - closes the menu on outside click or Escape + * - re-positions the menu (fixed, above the trigger) on resize/scroll + * - exposes a volumeWheelMenuTimerRef so the volume-wheel auto-hide handler + * can reuse the same timer. */ +export function useUtilityOverflowMenu( + playerBarRef: React.RefObject, + floatingPlayerBar: boolean, +) { + const [utilityOverflow, setUtilityOverflow] = useState(false); + const [utilityMenuOpen, setUtilityMenuOpen] = useState(false); + const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full'); + const [utilityMenuStyle, setUtilityMenuStyle] = useState({}); + const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false); + const utilityMenuRef = useRef(null); + const utilityBtnRef = useRef(null); + const volumeWheelMenuTimerRef = useRef(null); + + useEffect(() => { + const updateOverflow = () => { + const width = playerBarRef.current?.clientWidth ?? window.innerWidth; + const threshold = floatingPlayerBar ? 980 : 1140; + setUtilityOverflow(width < threshold); + }; + + updateOverflow(); + const ro = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(updateOverflow) + : null; + const el = playerBarRef.current; + if (ro && el) ro.observe(el); + window.addEventListener('resize', updateOverflow); + return () => { + ro?.disconnect(); + window.removeEventListener('resize', updateOverflow); + }; + }, [floatingPlayerBar, playerBarRef]); + + useEffect(() => { + if (!utilityOverflow) setUtilityMenuOpen(false); + if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) { + window.clearTimeout(volumeWheelMenuTimerRef.current); + volumeWheelMenuTimerRef.current = null; + } + }, [utilityOverflow]); + + useEffect(() => { + if (!utilityMenuOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (utilityBtnRef.current?.contains(target)) return; + if (utilityMenuRef.current?.contains(target)) return; + setUtilityMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setUtilityMenuOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [utilityMenuOpen]); + + useEffect(() => () => { + if (volumeWheelMenuTimerRef.current != null) { + window.clearTimeout(volumeWheelMenuTimerRef.current); + } + }, []); + + useEffect(() => { + if (!utilityMenuOpen) return; + const MENU_WIDTH = 238; + const MARGIN = 8; + const updateMenuPos = () => { + const btn = utilityBtnRef.current; + if (!btn) return; + const r = btn.getBoundingClientRect(); + const left = Math.min( + Math.max(r.right - MENU_WIDTH, MARGIN), + window.innerWidth - MENU_WIDTH - MARGIN, + ); + setUtilityMenuStyle({ + position: 'fixed', + left, + width: MENU_WIDTH, + bottom: window.innerHeight - r.top + 8, + zIndex: 10050, + }); + }; + updateMenuPos(); + window.addEventListener('resize', updateMenuPos); + window.addEventListener('scroll', updateMenuPos, true); + return () => { + window.removeEventListener('resize', updateMenuPos); + window.removeEventListener('scroll', updateMenuPos, true); + }; + }, [utilityMenuOpen]); + + return { + utilityOverflow, + utilityMenuOpen, + setUtilityMenuOpen, + utilityMenuMode, + setUtilityMenuMode, + utilityMenuStyle, + utilityMenuRef, + utilityBtnRef, + volumeWheelMenuTimerRef, + suppressOverflowTooltip, + setSuppressOverflowTooltip, + }; +} diff --git a/src/utils/playerBarHelpers.ts b/src/utils/playerBarHelpers.ts new file mode 100644 index 00000000..a6551fcc --- /dev/null +++ b/src/utils/playerBarHelpers.ts @@ -0,0 +1,6 @@ +export function formatTime(seconds: number): string { + if (!seconds || isNaN(seconds)) return '0:00'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +}