feat: Added the OverflowMarquee, implemented auto-scrolling text.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type OverflowMarqueeProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
title?: string;
|
||||
speedPxPerSec?: number;
|
||||
minDurationSec?: number;
|
||||
};
|
||||
|
||||
type MarqueeMetrics = {
|
||||
isOverflowing: boolean;
|
||||
overflowPx: number;
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SPEED_PX_PER_SEC = 24;
|
||||
const DEFAULT_MIN_DURATION_SEC = 4;
|
||||
|
||||
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
|
||||
children,
|
||||
className,
|
||||
textClassName,
|
||||
title,
|
||||
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
|
||||
minDurationSec = DEFAULT_MIN_DURATION_SEC,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [metrics, setMetrics] = useState<MarqueeMetrics>({
|
||||
isOverflowing: false,
|
||||
overflowPx: 0,
|
||||
durationSec: minDurationSec,
|
||||
});
|
||||
|
||||
const fallbackTitle = useMemo(() => {
|
||||
if (title) return title;
|
||||
return typeof children === 'string' ? children : undefined;
|
||||
}, [children, title]);
|
||||
|
||||
const recompute = () => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
// Ensure we measure with current layout.
|
||||
const available = container.clientWidth;
|
||||
const content = text.scrollWidth;
|
||||
const overflowPx = Math.ceil(content - available);
|
||||
|
||||
if (overflowPx > 1) {
|
||||
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
|
||||
setMetrics({ isOverflowing: true, overflowPx, durationSec });
|
||||
} else {
|
||||
// Avoid re-render loops if already not overflowing.
|
||||
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recompute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [children]);
|
||||
|
||||
useEffect(() => {
|
||||
recompute();
|
||||
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
const ro = new ResizeObserver(() => recompute());
|
||||
ro.observe(container);
|
||||
ro.observe(text);
|
||||
|
||||
const onResize = () => recompute();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
|
||||
? ({
|
||||
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
|
||||
['--marquee-duration' as any]: `${metrics.durationSec}s`,
|
||||
} satisfies React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={['overflow-marquee', className].filter(Boolean).join(' ')}
|
||||
title={fallbackTitle}
|
||||
>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={[
|
||||
'overflow-marquee__text',
|
||||
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
|
||||
textClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={textStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverflowMarquee;
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Playlist } from '../types';
|
||||
import { Disc3, Clock } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
@@ -12,8 +13,10 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
|
||||
return (
|
||||
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
|
||||
{playlist.title}
|
||||
<h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
|
||||
<OverflowMarquee>
|
||||
{playlist.title}
|
||||
</OverflowMarquee>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Playlist, ServerType, PlexServerConnection } from '../types';
|
||||
import PlaylistCard from './PlaylistCard';
|
||||
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface ServerPanelProps {
|
||||
type: ServerType;
|
||||
@@ -43,7 +44,12 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
||||
displayTitle = serverInfo.name || t('server.cloud');
|
||||
displaySubtitle = (
|
||||
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
|
||||
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
|
||||
<span className="text-plex-orange font-semibold min-w-0 max-w-full">
|
||||
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
|
||||
<OverflowMarquee className="hidden md:inline-block">
|
||||
{serverInfo.libraryName}
|
||||
</OverflowMarquee>
|
||||
</span>
|
||||
<span className="text-gray-600 hidden md:inline">•</span>
|
||||
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user