release: bump version to 0.26.1

This commit is contained in:
rayd1o
2026-04-12 05:20:23 +08:00
parent 812c825dc6
commit b1d0624061
10 changed files with 547 additions and 77 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.26.0",
"version": "0.26.1",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -371,6 +371,10 @@ function findSourceById(sourceId) {
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
}
function getCurrentSource() {
return findSourceById(currentSourceId);
}
function setPanelMessage(message) {
const { status } = getElements();
if (status) {
@@ -378,6 +382,50 @@ function setPanelMessage(message) {
}
}
function resetIframe(iframe) {
if (!(iframe instanceof HTMLIFrameElement)) return;
iframe.removeAttribute("src");
iframe.hidden = true;
}
function resetVideo(video) {
if (!(video instanceof HTMLVideoElement)) return;
video.removeAttribute("src");
video.hidden = true;
video.load();
}
function showEmptyState(empty, message) {
if (!(empty instanceof HTMLElement)) return;
empty.hidden = false;
empty.textContent = message;
}
function hideEmptyState(empty) {
if (empty instanceof HTMLElement) {
empty.hidden = true;
}
}
function renderVideoSource(video, iframe, source) {
resetIframe(iframe);
if (video instanceof HTMLVideoElement) {
video.hidden = false;
attachVideoSource(video, source);
}
}
function renderEmbeddedSource(iframe, video, embeddedUrl) {
destroyHlsPlayer();
resetVideo(video);
if (iframe instanceof HTMLIFrameElement) {
iframe.hidden = false;
if (iframe.src !== embeddedUrl) {
iframe.src = embeddedUrl;
}
}
}
function renderSourceOptions() {
const { select } = getElements();
if (!select) return;
@@ -429,53 +477,26 @@ function renderSource(source) {
if (!source || (!embeddedUrl && !videoUrl)) {
destroyHlsPlayer();
if (iframe) {
iframe.removeAttribute("src");
iframe.hidden = true;
}
if (video) {
video.removeAttribute("src");
video.hidden = true;
video.load();
}
if (empty) {
empty.hidden = false;
empty.textContent = isExternalOnly
resetIframe(iframe);
resetVideo(video);
showEmptyState(
empty,
isExternalOnly
? "当前频道仅支持跳转官网或外部播放器打开。"
: "暂无可播放直播源,请先在系统配置中添加频道。";
}
: "暂无可播放直播源,请先在系统配置中添加频道。",
);
setPanelMessage(isExternalOnly ? TV_STATUS_MESSAGE.externalOnly : TV_STATUS_MESSAGE.empty);
updateOpenButton(source);
return;
}
if (isVideo && videoUrl) {
if (iframe) {
iframe.removeAttribute("src");
iframe.hidden = true;
}
if (video) {
video.hidden = false;
attachVideoSource(video, source);
}
renderVideoSource(video, iframe, source);
} else {
destroyHlsPlayer();
if (video) {
video.removeAttribute("src");
video.hidden = true;
video.load();
}
if (iframe) {
iframe.hidden = false;
if (iframe.src !== embeddedUrl) {
iframe.src = embeddedUrl;
}
}
renderEmbeddedSource(iframe, video, embeddedUrl);
}
if (empty) {
empty.hidden = true;
}
hideEmptyState(empty);
setPanelMessage(
source.id === tvPayload?.default_source_id ? "当前正在播放默认源" : "当前正在播放已选频道",
@@ -586,7 +607,7 @@ export function initTVPanel() {
});
video?.addEventListener("error", () => {
const currentSource = findSourceById(currentSourceId);
const currentSource = getCurrentSource();
if (!showEmbeddedFallback(currentSource)) {
setPanelMessage(TV_STATUS_MESSAGE.videoError);
}

View File

@@ -20,6 +20,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
import { useAuthStore } from '../../stores/auth'
import packageJson from '../../../package.json'
import Scrollbar from '../Scrollbar/Scrollbar'
const { Sider, Content } = Layout
const { Text } = Typography
@@ -117,7 +118,7 @@ function AppLayout({ children }: AppLayoutProps) {
className="dashboard-sider"
>
<div className="dashboard-sider-inner">
<div>
<div className="dashboard-sider-main">
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
<Button
type="text"
@@ -132,31 +133,33 @@ function AppLayout({ children }: AppLayoutProps) {
<Text strong style={{ color: 'white', fontSize: 18 }}></Text>
) : null}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? [] : openKeys}
items={menuItems}
onOpenChange={(keys) => {
updateOpenKeys(keys as string[])
}}
onClick={({ key }) => {
if (key !== selectedKey) {
navigate(key)
}
}}
/>
<Scrollbar className="dashboard-sider-menu-scroll" axis="y">
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? [] : openKeys}
items={menuItems}
onOpenChange={(keys) => {
updateOpenKeys(keys as string[])
}}
onClick={({ key }) => {
if (key !== selectedKey) {
navigate(key)
}
}}
/>
</Scrollbar>
</div>
{showBanner && !collapsed ? (
<div className="dashboard-sider-banner">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
<div>
<div className="dashboard-sider-banner-row">
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
</div>
<div>
<div className="dashboard-sider-banner-row">
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{appVersion}</Text>
</div>

View File

@@ -0,0 +1,301 @@
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type Axis = 'x' | 'y'
type ScrollbarAxis = 'x' | 'y' | 'both'
interface AxisScrollbarState {
visible: boolean
dragging: boolean
thumbSize: number
thumbOffset: number
}
interface ScrollbarState {
x: AxisScrollbarState
y: AxisScrollbarState
}
interface ScrollbarProps {
children: ReactNode
className?: string
minThumbSize?: number
axis?: ScrollbarAxis
suppressScrollX?: boolean
suppressScrollY?: boolean
}
interface DragState {
axis: Axis
pointerId: number
pointerOffset: number
}
const INITIAL_AXIS_STATE: AxisScrollbarState = {
visible: false,
dragging: false,
thumbSize: 0,
thumbOffset: 0,
}
const INITIAL_STATE: ScrollbarState = {
x: INITIAL_AXIS_STATE,
y: INITIAL_AXIS_STATE,
}
function Scrollbar({
children,
className = '',
minThumbSize = 28,
axis = 'both',
suppressScrollX = false,
suppressScrollY = false,
}: ScrollbarProps) {
const viewportRef = useRef<HTMLDivElement | null>(null)
const trackXRef = useRef<HTMLDivElement | null>(null)
const trackYRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<DragState | null>(null)
const [scrollbar, setScrollbar] = useState<ScrollbarState>(INITIAL_STATE)
const updateScrollbar = useCallback(() => {
const viewport = viewportRef.current
const trackX = trackXRef.current
const trackY = trackYRef.current
if (!viewport || !trackX || !trackY) return
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollLeft,
scrollTop,
} = viewport
const enableX = axis === 'x' || axis === 'both'
const enableY = axis === 'y' || axis === 'both'
const hasOverflowX = enableX && !suppressScrollX && scrollWidth - clientWidth > 1
const hasOverflowY = enableY && !suppressScrollY && scrollHeight - clientHeight > 1
const nextX: AxisScrollbarState = hasOverflowX && clientWidth > 0
? (() => {
const trackSize = trackX.clientWidth
const thumbSize = Math.max(minThumbSize, (clientWidth / scrollWidth) * trackSize)
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
const scrollRange = Math.max(1, scrollWidth - clientWidth)
return {
visible: true,
dragging: scrollbar.x.dragging && dragStateRef.current?.axis === 'x',
thumbSize,
thumbOffset: (scrollLeft / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
const nextY: AxisScrollbarState = hasOverflowY && clientHeight > 0
? (() => {
const trackSize = trackY.clientHeight
const thumbSize = Math.max(minThumbSize, (clientHeight / scrollHeight) * trackSize)
const maxThumbOffset = Math.max(0, trackSize - thumbSize)
const scrollRange = Math.max(1, scrollHeight - clientHeight)
return {
visible: true,
dragging: scrollbar.y.dragging && dragStateRef.current?.axis === 'y',
thumbSize,
thumbOffset: (scrollTop / scrollRange) * maxThumbOffset,
}
})()
: { ...INITIAL_AXIS_STATE }
setScrollbar({ x: nextX, y: nextY })
}, [minThumbSize, scrollbar.x.dragging, scrollbar.y.dragging, suppressScrollX, suppressScrollY])
useEffect(() => {
const viewport = viewportRef.current
const trackX = trackXRef.current
const trackY = trackYRef.current
if (!viewport || !trackX || !trackY) return
updateScrollbar()
const resizeObserver = new ResizeObserver(() => {
updateScrollbar()
})
resizeObserver.observe(viewport)
resizeObserver.observe(trackX)
resizeObserver.observe(trackY)
Array.from(viewport.children).forEach((child) => resizeObserver.observe(child))
viewport.addEventListener('scroll', updateScrollbar, { passive: true })
window.addEventListener('resize', updateScrollbar)
return () => {
resizeObserver.disconnect()
viewport.removeEventListener('scroll', updateScrollbar)
window.removeEventListener('resize', updateScrollbar)
}
}, [children, updateScrollbar])
useEffect(() => {
const stopDragging = () => {
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
}
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
return () => {
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
}, [])
const syncScrollFromThumbOffset = (axis: Axis, nextThumbOffset: number) => {
const viewport = viewportRef.current
const track = axis === 'x' ? trackXRef.current : trackYRef.current
const axisState = scrollbar[axis]
if (!viewport || !track) return
const trackSize = axis === 'x' ? track.clientWidth : track.clientHeight
const maxThumbOffset = Math.max(0, trackSize - axisState.thumbSize)
if (maxThumbOffset <= 0) {
if (axis === 'x') viewport.scrollLeft = 0
else viewport.scrollTop = 0
return
}
const progress = Math.max(0, Math.min(nextThumbOffset, maxThumbOffset)) / maxThumbOffset
if (axis === 'x') {
const scrollRange = Math.max(0, viewport.scrollWidth - viewport.clientWidth)
viewport.scrollLeft = progress * scrollRange
return
}
const scrollRange = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
viewport.scrollTop = progress * scrollRange
}
const handleThumbPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const axisState = scrollbar[axis]
if (!axisState.visible) return
dragStateRef.current = {
axis,
pointerId: event.pointerId,
pointerOffset: axis === 'x' ? event.clientX - rect.left : event.clientY - rect.top,
}
setScrollbar((previous) => ({
x: { ...previous.x, dragging: axis === 'x' },
y: { ...previous.y, dragging: axis === 'y' },
}))
document.body.style.userSelect = 'none'
event.currentTarget.setPointerCapture(event.pointerId)
event.stopPropagation()
event.preventDefault()
}
const handleThumbPointerMove = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const track = axis === 'x' ? trackXRef.current : trackYRef.current
const dragState = dragStateRef.current
if (!track || !dragState || dragState.pointerId !== event.pointerId || dragState.axis !== axis) return
const rect = track.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - dragState.pointerOffset
: event.clientY - rect.top - dragState.pointerOffset
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const handleThumbPointerUp = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current.axis !== axis) return
dragStateRef.current = null
document.body.style.userSelect = ''
setScrollbar((previous) => ({
x: { ...previous.x, dragging: false },
y: { ...previous.y, dragging: false },
}))
event.currentTarget.releasePointerCapture?.(event.pointerId)
}
const handleTrackPointerDown = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const axisState = scrollbar[axis]
if (!axisState.visible || event.target !== event.currentTarget) return
const rect = event.currentTarget.getBoundingClientRect()
const nextThumbOffset = axis === 'x'
? event.clientX - rect.left - axisState.thumbSize / 2
: event.clientY - rect.top - axisState.thumbSize / 2
syncScrollFromThumbOffset(axis, nextThumbOffset)
}
const showCorner = scrollbar.x.visible && scrollbar.y.visible
const scrollbarClassName = [
'scrollbar',
`scrollbar--${axis}`,
className,
].filter(Boolean).join(' ')
return (
<div className={scrollbarClassName}>
<div ref={viewportRef} className="scrollbar__viewport">
{children}
</div>
{(axis === 'y' || axis === 'both') ? (
<div
ref={trackYRef}
className={`scrollbar__track scrollbar__track--y${scrollbar.y.visible ? ' scrollbar__track--visible' : ''}${scrollbar.y.dragging ? ' scrollbar__track--dragging' : ''}${showCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('y')}
>
{scrollbar.y.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--y"
style={{
height: `${scrollbar.y.thumbSize}px`,
transform: `translateY(${scrollbar.y.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('y')}
onPointerMove={handleThumbPointerMove('y')}
onPointerUp={handleThumbPointerUp('y')}
/>
) : null}
</div>
) : null}
{(axis === 'x' || axis === 'both') ? (
<div
ref={trackXRef}
className={`scrollbar__track scrollbar__track--x${scrollbar.x.visible ? ' scrollbar__track--visible' : ''}${scrollbar.x.dragging ? ' scrollbar__track--dragging' : ''}${showCorner ? ' scrollbar__track--has-corner' : ''}`}
onPointerDown={handleTrackPointerDown('x')}
>
{scrollbar.x.visible ? (
<div
className="scrollbar__thumb scrollbar__thumb--x"
style={{
width: `${scrollbar.x.thumbSize}px`,
transform: `translateX(${scrollbar.x.thumbOffset}px)`,
}}
onPointerDown={handleThumbPointerDown('x')}
onPointerMove={handleThumbPointerMove('x')}
onPointerUp={handleThumbPointerUp('x')}
/>
) : null}
</div>
) : null}
</div>
)
}
export default Scrollbar

View File

@@ -55,11 +55,132 @@ body {
background: #001529 !important;
}
.dashboard-sider.ant-layout-sider-has-trigger {
padding-bottom: 0 !important;
}
.dashboard-sider-inner {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
.dashboard-sider-main {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dashboard-sider-menu-scroll {
flex: 1 1 auto;
min-height: 0;
}
.dashboard-sider-menu-scroll .scrollbar__viewport > .ant-menu {
width: 100%;
min-width: 0;
}
.scrollbar {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.scrollbar--y .scrollbar__track--x,
.scrollbar--x .scrollbar__track--y {
display: none;
}
.scrollbar__viewport {
height: 100%;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar__viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
.scrollbar__track {
position: absolute;
z-index: 2;
pointer-events: none;
overflow: visible;
}
.scrollbar__track--y {
top: 0;
right: 8px;
width: 0;
height: 100%;
}
.scrollbar__track--x {
left: 0;
bottom: 8px;
width: 100%;
height: 0;
}
.scrollbar__thumb {
position: absolute;
min-height: 28px;
min-width: 28px;
border-radius: 999px;
background: rgba(196, 208, 224, 0.46);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
opacity: 0;
cursor: pointer;
pointer-events: auto;
transition: opacity 0.18s ease, background 0.18s ease, width 0.18s ease;
}
.scrollbar__thumb::after {
content: "";
position: absolute;
}
.scrollbar__thumb--y {
top: 8px;
right: 0;
width: 6px;
min-width: 6px;
}
.scrollbar__thumb--y::after {
inset: 0 -6px;
}
.scrollbar__thumb--x {
left: 8px;
bottom: 0;
height: 6px;
min-height: 6px;
}
.scrollbar__thumb--x::after {
inset: -6px 0;
}
.scrollbar:hover .scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--visible .scrollbar__thumb,
.scrollbar__track--dragging .scrollbar__thumb {
opacity: 1;
}
.scrollbar__thumb:hover {
background: rgba(216, 226, 240, 0.68);
}
.scrollbar__track--dragging .scrollbar__thumb {
background: rgba(226, 235, 246, 0.82);
}
.dashboard-brand {
@@ -106,24 +227,31 @@ body {
}
.dashboard-sider-banner {
margin: 12px;
flex: 0 0 auto;
margin: 12px 12px 0;
padding: 14px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.dashboard-sider-banner-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dashboard-sider-banner-label {
display: block;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
white-space: nowrap;
}
.dashboard-sider-banner-value {
display: block;
color: white !important;
font-size: 14px;
text-align: right;
}
.dashboard-sider-logout {