diff --git a/client/public/images/festival/christmas/banner-center.jpg b/client/public/images/festival/christmas/banner-center.jpg new file mode 100644 index 0000000..660d535 Binary files /dev/null and b/client/public/images/festival/christmas/banner-center.jpg differ diff --git a/client/public/images/festival/christmas/banner-left.jpg b/client/public/images/festival/christmas/banner-left.jpg new file mode 100644 index 0000000..f2b1956 Binary files /dev/null and b/client/public/images/festival/christmas/banner-left.jpg differ diff --git a/client/public/images/festival/christmas/banner-right.jpg b/client/public/images/festival/christmas/banner-right.jpg new file mode 100644 index 0000000..0b700a5 Binary files /dev/null and b/client/public/images/festival/christmas/banner-right.jpg differ diff --git a/client/public/images/festival/christmas/bell.png b/client/public/images/festival/christmas/bell.png new file mode 100644 index 0000000..a60fefe Binary files /dev/null and b/client/public/images/festival/christmas/bell.png differ diff --git a/client/public/images/festival/christmas/gift.png b/client/public/images/festival/christmas/gift.png new file mode 100644 index 0000000..bc18c45 Binary files /dev/null and b/client/public/images/festival/christmas/gift.png differ diff --git a/client/public/images/festival/christmas/reindeer.png b/client/public/images/festival/christmas/reindeer.png new file mode 100644 index 0000000..748d6cf Binary files /dev/null and b/client/public/images/festival/christmas/reindeer.png differ diff --git a/client/public/images/festival/christmas/santa.png b/client/public/images/festival/christmas/santa.png new file mode 100644 index 0000000..607f17c Binary files /dev/null and b/client/public/images/festival/christmas/santa.png differ diff --git a/client/public/images/festival/christmas/tree.png b/client/public/images/festival/christmas/tree.png new file mode 100644 index 0000000..99928c7 Binary files /dev/null and b/client/public/images/festival/christmas/tree.png differ diff --git a/client/public/images/festival/halloween/banner-center.jpg b/client/public/images/festival/halloween/banner-center.jpg new file mode 100644 index 0000000..c257bef Binary files /dev/null and b/client/public/images/festival/halloween/banner-center.jpg differ diff --git a/client/public/images/festival/halloween/banner-left.jpg b/client/public/images/festival/halloween/banner-left.jpg new file mode 100644 index 0000000..11e817b Binary files /dev/null and b/client/public/images/festival/halloween/banner-left.jpg differ diff --git a/client/public/images/festival/halloween/banner-right.jpg b/client/public/images/festival/halloween/banner-right.jpg new file mode 100644 index 0000000..0dc5aa5 Binary files /dev/null and b/client/public/images/festival/halloween/banner-right.jpg differ diff --git a/client/public/images/festival/halloween/ghost.png b/client/public/images/festival/halloween/ghost.png new file mode 100644 index 0000000..4462704 Binary files /dev/null and b/client/public/images/festival/halloween/ghost.png differ diff --git a/client/public/images/festival/halloween/pumpkin.png b/client/public/images/festival/halloween/pumpkin.png new file mode 100644 index 0000000..ee995d7 Binary files /dev/null and b/client/public/images/festival/halloween/pumpkin.png differ diff --git a/client/public/images/festival/halloween/skeleton.png b/client/public/images/festival/halloween/skeleton.png new file mode 100644 index 0000000..a7fa94c Binary files /dev/null and b/client/public/images/festival/halloween/skeleton.png differ diff --git a/client/public/images/festival/halloween/vampire.png b/client/public/images/festival/halloween/vampire.png new file mode 100644 index 0000000..ed859f1 Binary files /dev/null and b/client/public/images/festival/halloween/vampire.png differ diff --git a/client/public/images/festival/halloween/witch.png b/client/public/images/festival/halloween/witch.png new file mode 100644 index 0000000..d6ff503 Binary files /dev/null and b/client/public/images/festival/halloween/witch.png differ diff --git a/client/src/components/BottomNav.tsx b/client/src/components/BottomNav.tsx index e72dfda..f4aee69 100644 --- a/client/src/components/BottomNav.tsx +++ b/client/src/components/BottomNav.tsx @@ -23,6 +23,8 @@ import { import { Link, useLocation } from "wouter"; import { useMemo } from "react"; import { bottomNavInnerClass, bottomNavOuterClass } from "@/lib/appShell"; +import FestivalNavIcon from "@/components/festival/FestivalNavIcon"; +import { useFestival, type FestivalId } from "@/lib/festival"; type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>; @@ -30,16 +32,61 @@ interface NavItem { path: string; icon: React.ComponentType<{ size?: number; className?: string }>; labelKey: keyof BottomNavLocale; + /** 各节日替换 lucide 图标的贴纸素材,键为节日 ID。 */ + festivalIcons?: Partial>; } const FORUM_URL = "https://forum.zidoo.tv/index.php"; +const festivalIcon = (festival: FestivalId, name: string) => + `${import.meta.env.BASE_URL}images/festival/${festival}/${name}.png`; + const NAV_ITEMS: NavItem[] = [ - { path: "/community", icon: MessageSquare, labelKey: "community" }, - { path: "/eq", icon: Sliders, labelKey: "hpEq" }, - { path: "/", icon: Home, labelKey: "home" }, - { path: "/effects", icon: Activity, labelKey: "effects" }, - { path: "/ai", icon: Bot, labelKey: "myAi" }, + { + path: "/community", + icon: MessageSquare, + labelKey: "community", + festivalIcons: { + christmas: festivalIcon("christmas", "bell"), + halloween: festivalIcon("halloween", "ghost"), + }, + }, + { + path: "/eq", + icon: Sliders, + labelKey: "hpEq", + festivalIcons: { + christmas: festivalIcon("christmas", "reindeer"), + halloween: festivalIcon("halloween", "skeleton"), + }, + }, + { + path: "/", + icon: Home, + labelKey: "home", + festivalIcons: { + christmas: festivalIcon("christmas", "santa"), + halloween: festivalIcon("halloween", "pumpkin"), + }, + }, + { + path: "/effects", + icon: Activity, + labelKey: "effects", + festivalIcons: { + christmas: festivalIcon("christmas", "tree"), + halloween: festivalIcon("halloween", "witch"), + }, + }, + { + path: "/ai", + icon: Bot, + labelKey: "myAi", + festivalIcons: { + christmas: festivalIcon("christmas", "gift"), + halloween: festivalIcon("halloween", "vampire"), + }, + }, ]; export default function BottomNav() { @@ -47,6 +94,7 @@ export default function BottomNav() { const isLgUp = useIsLgUp(); const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer(); const { deviceState } = useDevice(); + const festival = useFestival(); const navText = useMemo((): BottomNavLocale => { const pack = resolveLocalePack(deviceState?.language); @@ -75,6 +123,7 @@ export default function BottomNav() { (item.path === "/" && location === "/") || (item.path !== "/" && location.startsWith(item.path)); const Icon = item.icon; + const festivalSrc = festival ? item.festivalIcons?.[festival] : undefined; const inner = (
@@ -100,13 +149,17 @@ export default function BottomNav() { background: isActive ? "rgba(0,255,246,0.1)" : "transparent", }} > - + {festivalSrc ? ( + + ) : ( + + )}
{/* Label */} diff --git a/client/src/components/ThumbOnlyRangeSlider.tsx b/client/src/components/ThumbOnlyRangeSlider.tsx index 6297251..f45ffe8 100644 --- a/client/src/components/ThumbOnlyRangeSlider.tsx +++ b/client/src/components/ThumbOnlyRangeSlider.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { useRef, type CSSProperties } from "react"; +import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; /** Generous hit area around the thumb for touch. */ const THUMB_HIT_RADIUS_PX = 24; @@ -42,6 +42,12 @@ export interface ThumbOnlyRangeSliderProps { onRelease?: (value: number) => void; onDragStart?: () => void; onDragEnd?: () => void; + /** + * Optional custom thumb (e.g. festival skin). When provided the native round + * thumb is hidden and this node is rendered as an overlay centered on the + * thumb position; the slider still owns all drag/hit-test logic. + */ + renderThumb?: () => ReactNode; } /** @@ -66,12 +72,28 @@ export default function ThumbOnlyRangeSlider({ onRelease, onDragStart, onDragEnd, + renderThumb, }: ThumbOnlyRangeSliderProps) { const inputRef = useRef(null); const dragRef = useRef<{ pointerId: number; grabOffsetPx: number } | null>(null); const valueRef = useRef(value); valueRef.current = value; + // Track width (px) so a custom overlay thumb can be centered exactly like the + // native thumb (which insets its center by THUMB_HALF_WIDTH_PX at both ends). + const [trackW, setTrackW] = useState(0); + useLayoutEffect(() => { + const el = inputRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const en of entries) setTrackW(en.contentRect.width); + }); + ro.observe(el); + setTrackW(el.getBoundingClientRect().width); + return () => ro.disconnect(); + }, []); + const thumbCenterPx = trackW > 0 ? thumbCenterXPx(trackW, min, max, value) : null; + const finishDrag = (pointerId: number | null) => { const drag = dragRef.current; if (!drag) return; @@ -133,7 +155,7 @@ export default function ThumbOnlyRangeSlider({ max={max} value={value} disabled={disabled} - className="cyan-slider block w-full" + className={cn("cyan-slider block w-full", renderThumb && "cyan-slider--no-thumb")} style={{ pointerEvents: "none", touchAction: "none" }} onChange={(e) => { // Only reachable via keyboard — the input no longer receives pointer input. @@ -157,6 +179,19 @@ export default function ThumbOnlyRangeSlider({ } }} /> + {renderThumb && ( +
+ {renderThumb()} +
+ )} ); } diff --git a/client/src/components/festival/ChristmasBellIcon.tsx b/client/src/components/festival/ChristmasBellIcon.tsx new file mode 100644 index 0000000..428a511 --- /dev/null +++ b/client/src/components/festival/ChristmasBellIcon.tsx @@ -0,0 +1,30 @@ +import { cn } from "@/lib/utils"; + +const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/bell.png`; + +/** + * Christmas skin: jingle-bell icon that replaces the min/max volume speakers. + * `dim` renders the smaller, muted-looking variant used for the min button. + */ +export default function ChristmasBellIcon({ + dim = false, + className, +}: { + dim?: boolean; + className?: string; +}) { + return ( + + ); +} diff --git a/client/src/components/festival/ChristmasCardDecoration.tsx b/client/src/components/festival/ChristmasCardDecoration.tsx new file mode 100644 index 0000000..f207a97 --- /dev/null +++ b/client/src/components/festival/ChristmasCardDecoration.tsx @@ -0,0 +1,94 @@ +import { memo } from "react"; + +const LIGHT_POSITIONS = [4, 17, 30, 43, 56, 69, 82, 95]; + +/** 设备卡片的静态圣诞装饰:只占上沿,不接收点击或参与布局。 */ +export default memo(function ChristmasCardDecoration() { + return ( + + ); +}); diff --git a/client/src/components/festival/ChristmasVolumeThumb.tsx b/client/src/components/festival/ChristmasVolumeThumb.tsx new file mode 100644 index 0000000..1f5045b --- /dev/null +++ b/client/src/components/festival/ChristmasVolumeThumb.tsx @@ -0,0 +1,19 @@ +import { memo } from "react"; + +const SRC = `${import.meta.env.BASE_URL}images/festival/christmas/santa.png`; + +/** + * Christmas skin: running-Santa overlay that replaces the volume slider thumb. + * Positioning is handled by ThumbOnlyRangeSlider (renderThumb); this component + * only supplies the artwork + running animation. + */ +export default memo(function ChristmasVolumeThumb() { + return ( + + ); +}); diff --git a/client/src/components/festival/FestivalGreeting.tsx b/client/src/components/festival/FestivalGreeting.tsx new file mode 100644 index 0000000..92699e8 --- /dev/null +++ b/client/src/components/festival/FestivalGreeting.tsx @@ -0,0 +1,100 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { FESTIVAL_GREETINGS, type FestivalId } from "@/lib/festival"; +import "./festival.css"; + +/** + * VU 区的静态插画横幅:两端角色保持比例,中央背景平铺,文字独立缩放。 + * 按容器宽度切换横排/双行,保留顶部设备信息;不参与布局、不拦截点击。 + */ +export default function FestivalGreeting({ + festival, +}: { + festival: FestivalId; +}) { + const boxRef = useRef(null); + const textBoxRef = useRef(null); + const innerRef = useRef(null); + const [scale, setScale] = useState(1); + const artworkRoot = `${import.meta.env.BASE_URL}images/festival/${festival}/`; + + useLayoutEffect(() => { + const box = boxRef.current; + const textBox = textBoxRef.current; + const inner = innerRef.current; + if (!box || !textBox || !inner) return; + const status = box.parentElement?.querySelector( + ".festival-vu-status" + ); + let disposed = false; + const fit = () => { + if (disposed) return; + // 先应用排版再测量;使用容器宽度,兼容横屏、分屏及 legacy。 + box.dataset.layout = box.clientWidth >= 420 ? "wide" : "compact"; + // 极窄屏的格式和音量分成两行,标题跟随实际状态栏高度下移。 + textBox.style.top = `${Math.max(17, status?.offsetHeight ?? 0) + 2}px`; + const bw = textBox.clientWidth - 8; + const bh = textBox.clientHeight - 4; + const tw = inner.offsetWidth; + const th = inner.offsetHeight; + if (bw <= 0 || bh <= 0 || tw <= 0 || th <= 0) return; + setScale(Math.min(bw / tw, bh / th)); + }; + fit(); + const ro = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fit); + ro?.observe(box); + ro?.observe(inner); + if (status) ro?.observe(status); + window.addEventListener("resize", fit); + // 字体就绪后重新测量,避免初次回退字体导致文字越界。 + void document.fonts?.ready.then(fit); + return () => { + disposed = true; + ro?.disconnect(); + window.removeEventListener("resize", fit); + }; + }, [festival]); + + return ( +
+