- {FESTIVAL_GREETINGS[festival].split(" ").map(word => (
+ {isNewYear ? (
+ <>
+
+
+ Happy
+ New Year
+
+
{newYear}
+
+
+ NEW YEAR · NEW CHAPTER · MORE POSSIBILITIES
+
+ >
+ ) : FESTIVAL_GREETINGS[festival].split(" ").map(word => (
{word}
diff --git a/client/src/components/festival/NewYearDecoration.tsx b/client/src/components/festival/NewYearDecoration.tsx
new file mode 100644
index 0000000..4c1675d
--- /dev/null
+++ b/client/src/components/festival/NewYearDecoration.tsx
@@ -0,0 +1,58 @@
+import { memo } from "react";
+import { cn } from "@/lib/utils";
+import "./newyear.css";
+
+const ROOT = `${import.meta.env.BASE_URL}images/festival/newyear/`;
+
+/** 新年礼帽与仙女棒只替换视觉层,拖拽命中和音量提交仍由滑块管理。 */
+export const NewYearVolumeThumb = memo(function NewYearVolumeThumb() {
+ return (
+

+ );
+});
+
+/** 两端占位仍为 24px:最小音量是小仙女棒,最大音量是绽放的烟花。 */
+export function NewYearVolumeIcon({ dim = false }: { dim?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+/** 卡片顶部的金色彩带与星屑,不覆盖端口、不拦截指针事件。 */
+export const NewYearCardDecoration = memo(function NewYearCardDecoration() {
+ return (
+
+
+ {[7, 20, 35, 51, 67, 82, 95].map((left, index) => (
+
+ {index % 2 ? "·" : "✦"}
+
+ ))}
+
+ );
+});
\ No newline at end of file
diff --git a/client/src/components/festival/newyear.css b/client/src/components/festival/newyear.css
new file mode 100644
index 0000000..ec30ed9
--- /dev/null
+++ b/client/src/components/festival/newyear.css
@@ -0,0 +1,152 @@
+/* 元旦视觉层:保留滑轨尺寸、青色音量反馈与所有交互热区。 */
+.newyear-volume-thumb {
+ display: block;
+ width: 44px;
+ height: 44px;
+ max-width: none;
+ object-fit: contain;
+ pointer-events: none;
+ user-select: none;
+ filter: drop-shadow(0 2px 4px #000) drop-shadow(0 0 3px rgba(242, 196, 98, 0.35));
+}
+
+.newyear-volume-icon-slot {
+ display: block;
+ position: relative;
+ width: 24px;
+ height: 24px;
+ pointer-events: none;
+}
+
+.newyear-volume-icon {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 32px;
+ height: 32px;
+ max-width: none;
+ transform: translate(-50%, -50%);
+ filter: drop-shadow(0 0 3px rgba(242, 196, 98, 0.3));
+ pointer-events: none;
+ user-select: none;
+}
+
+.newyear-volume-icon--dim {
+ width: 27px;
+ height: 27px;
+ opacity: 0.75;
+}
+
+.newyear-card-decoration {
+ position: absolute;
+ inset: 0 0 auto;
+ height: 20px;
+ z-index: 1;
+ overflow: hidden;
+ pointer-events: none;
+ user-select: none;
+}
+
+.newyear-card-decoration * {
+ pointer-events: none;
+}
+
+.newyear-card-decoration > svg {
+ width: 100%;
+ height: 20px;
+}
+
+.newyear-card-star {
+ position: absolute;
+ color: #e7bd6b;
+ opacity: 0.75;
+ font-size: 9px;
+ line-height: 1;
+}
+
+.festival-greeting--newyear {
+ background: #071221;
+}
+
+.festival-greeting--newyear .festival-greeting-art {
+ opacity: 0.75;
+}
+
+.festival-greeting--newyear .festival-greeting-shade {
+ background: linear-gradient(90deg, transparent, rgba(3, 9, 20, 0.3) 40%, rgba(3, 9, 20, 0.3) 60%, transparent);
+}
+
+/* 标题整体仍由共享组件测量缩放,窄屏按三行排列,宽屏增加副标题。 */
+.festival-greeting.festival-greeting--newyear[data-layout] .newyear-greeting-text {
+ flex-direction: column;
+ color: #ffe8ae;
+ text-shadow: 0 1px 1px #71390d, 0 0 8px rgba(236, 145, 52, 0.6);
+}
+
+.newyear-greeting-heading,
+.newyear-greeting-words {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.newyear-greeting-words {
+ font-family: "Snell Roundhand", "Apple Chancery", "URW Chancery L", Georgia, cursive;
+ font-size: 36px;
+ font-style: italic;
+ line-height: 1.05;
+}
+
+.newyear-greeting-year {
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 43px;
+ font-style: italic;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ line-height: 1;
+ color: #ffd57a;
+}
+
+.newyear-greeting-tagline {
+ display: none;
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 8px;
+ line-height: 1.6;
+ letter-spacing: 2px;
+ color: #f5da9b;
+ text-shadow: 0 1px 3px #000;
+}
+
+.newyear-greeting-calendar {
+ display: none;
+ position: absolute;
+ width: 48px;
+ height: 48px;
+ left: 22px;
+ bottom: 4px;
+ transform: rotate(-9deg);
+ filter: drop-shadow(0 2px 4px #000);
+}
+
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-calendar {
+ display: block;
+}
+
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-heading,
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-words {
+ flex-direction: row;
+}
+
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-words > span + span {
+ margin-left: 0.22em;
+}
+
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-year {
+ margin-left: 18px;
+ font-size: 54px;
+}
+
+.festival-greeting--newyear[data-layout="wide"] .newyear-greeting-tagline {
+ display: block;
+ margin-top: 4px;
+}
diff --git a/client/src/contexts/DeviceContext.tsx b/client/src/contexts/DeviceContext.tsx
index 619cd8d..634d93d 100644
--- a/client/src/contexts/DeviceContext.tsx
+++ b/client/src/contexts/DeviceContext.tsx
@@ -58,6 +58,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
const [api, setApi] = useState
(null);
const lastMsgCountRef = useRef(-1);
const pollIntervalRef = useRef | null>(null);
+ /** 轮询重入保护:上一轮未 settle(如断网挂起至超时)则跳过本轮,避免挂起请求堆叠。 */
+ const pollInFlightRef = useRef(false);
const autoConnectStartedRef = useRef(false);
const setDeviceIp = useCallback((newIp: string) => {
@@ -260,6 +262,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
if (!isConnected || isDemoMode || !api) return;
const poll = async () => {
+ if (pollInFlightRef.current) return;
+ pollInFlightRef.current = true;
try {
const count = await api.getMsgCount();
if (count !== lastMsgCountRef.current) {
@@ -270,6 +274,8 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
}
} catch {
// Silently fail polling
+ } finally {
+ pollInFlightRef.current = false;
}
};
diff --git a/client/src/lib/festival.ts b/client/src/lib/festival.ts
index 8bcd2f6..8f1f5e2 100644
--- a/client/src/lib/festival.ts
+++ b/client/src/lib/festival.ts
@@ -6,7 +6,7 @@
============================================================ */
import { useMemo } from "react";
-export type FestivalId = "christmas" | "halloween";
+export type FestivalId = "christmas" | "halloween" | "newyear";
/** Inclusive [month, day] windows. Christmas: Dec 20 – Dec 26; Halloween: Oct 26 – Nov 1. */
const FESTIVAL_WINDOWS: Record<
@@ -15,6 +15,8 @@ const FESTIVAL_WINDOWS: Record<
> = {
christmas: { from: [12, 20], to: [12, 26] },
halloween: { from: [10, 26], to: [11, 1] },
+ // 元旦接续圣诞节,跨年窗口包含首尾日期。
+ newyear: { from: [12, 27], to: [1, 3] },
};
function inWindow(
@@ -55,8 +57,14 @@ export function useFestival(): FestivalId | null {
return useMemo(() => getFestival(), []);
}
+/** 跨年夜展示即将到来的年份,其余月份预览展示当年。 */
+export function getNewYearDisplayYear(now: Date = new Date()): number {
+ return now.getFullYear() + (now.getMonth() === 11 ? 1 : 0);
+}
+
/** 首页 VU 区的节日问候语,键与 FestivalId 对应。 */
export const FESTIVAL_GREETINGS: Record = {
christmas: "Merry Christmas",
halloween: "Happy Halloween",
+ newyear: "Happy New Year",
};
diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts
index 7b18690..8e9c87c 100644
--- a/client/src/lib/luxsinApi.ts
+++ b/client/src/lib/luxsinApi.ts
@@ -322,9 +322,37 @@ function normalizeDeviceHost(input: string): string {
// ============================================================
// API Class
// ============================================================
+
+/** 设备请求统一超时:超时即 abort 并抛 DeviceTimeoutError,避免断网时请求无限挂起堆叠。 */
+export const DEVICE_FETCH_TIMEOUT_MS = 4000;
+
+/** 设备请求超时错误;文案含 "timed out" 以便 networkError 识别为网络类错误。 */
+export class DeviceTimeoutError extends Error {
+ constructor(url: string) {
+ super(`Device request timed out: ${url}`);
+ this.name = "DeviceTimeoutError";
+ }
+}
+
+/**
+ * 动作型设置键:语义是“触发一次”(关机/蓝牙播控),失败即丢弃,
+ * 重连后绝不补发(否则设备会在用户无预期时关机或切歌)。
+ */
+const ACTION_ONLY_SETTING_KEYS = new Set(["power", "bt_play", "bt_next"]);
+
+interface WriteSlot {
+ controller: AbortController;
+ timedOut: boolean;
+}
+
export class LuxsinAPI {
private baseUrl: string;
private peqRequestQueue: Promise = Promise.resolve();
+ /** 状态型设置按通道(参数键组合)合并:超时/断网后保留每通道最新意图,重连后补发一次。 */
+ private pendingIntents = new Map>();
+ /** 每通道至多一个在途写请求;新命令 abort 旧在途请求(last-write-wins)。 */
+ private inflightWrites = new Map();
+ private flushScheduled = false;
/** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */
private enqueuePeqRequest(request: () => Promise): Promise {
@@ -353,7 +381,7 @@ export class LuxsinAPI {
async getDeviceState(): Promise {
try {
- const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
const text = await response.text();
const decoded = decodeCustomBase64(text.trim());
const state = JSON.parse(decoded) as DeviceState;
@@ -366,30 +394,122 @@ export class LuxsinAPI {
}
async getMsgCount(): Promise {
- const response = await fetch(`${this.baseUrl}/msgCount`);
+ const response = await this.timedFetch(`${this.baseUrl}/msgCount`);
const text = await response.text();
return parseInt(text.trim(), 10);
}
async refreshState(): Promise {
- await fetch(`${this.baseUrl}/msgAdd`);
+ await this.timedFetch(`${this.baseUrl}/msgAdd`);
}
async setSetting(params: Record): Promise {
- const request = async () => {
+ // EQ 选择与 PEQ 读写共用串行队列:只加超时(保证队列排空),不合并不补发。
+ if (params.peqSelect !== undefined) {
+ return this.enqueuePeqRequest(() => this.sendSettingRequest(params));
+ }
+ // 动作型命令:发一次,超时/断网即丢弃,重连后不补发。
+ if (Object.keys(params).some((k) => ACTION_ONLY_SETTING_KEYS.has(k))) {
+ return this.sendSettingRequest(params);
+ }
+ const channel = Object.keys(params).sort().join(",");
+ return this.sendCoalescedSetting(channel, params);
+ }
+
+ private async sendSettingRequest(params: Record): Promise {
+ const query = Object.entries(params)
+ .map(([k, v]) => `${k}=${v}`)
+ .join("&");
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
+ if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
+ }
+
+ /**
+ * 状态型设置按通道合并发送:新命令 abort 同通道旧在途请求(旧传输直接丢弃),
+ * 每通道至多保留一个在途的“最新意图”;超时或网络错误时把意图挂入 pendingIntents,
+ * 待任意设备请求成功(即重连)后由 onDeviceResponseOk 补发一次。
+ * 被新命令取代的旧 Promise 静默 resolve——其值已被更新意图覆盖,无需向调用方报错。
+ */
+ private async sendCoalescedSetting(
+ channel: string,
+ params: Record,
+ ): Promise {
+ const previous = this.inflightWrites.get(channel);
+ if (previous) previous.controller.abort();
+ this.pendingIntents.delete(channel);
+ const slot: WriteSlot = { controller: new AbortController(), timedOut: false };
+ this.inflightWrites.set(channel, slot);
+ try {
const query = Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join("&");
- const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
+ const response = await this.timedFetch(
+ `${this.baseUrl}/dev/info.cgi?action=setting&${query}`,
+ undefined,
+ slot,
+ );
if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
- };
- if (params.peqSelect !== undefined) return this.enqueuePeqRequest(request);
- return request();
+ this.pendingIntents.delete(channel);
+ } catch (error) {
+ const superseded = !slot.timedOut && slot.controller.signal.aborted;
+ if (!superseded) {
+ // 超时或真实网络错误:保留最新意图,等重连后补发;仍向调用方报错。
+ this.pendingIntents.set(channel, params);
+ throw error;
+ }
+ // 被更新命令取代:本次传输丢弃,不补发、不报错。
+ } finally {
+ if (this.inflightWrites.get(channel) === slot) this.inflightWrites.delete(channel);
+ }
+ }
+
+ /** 任意设备请求成功即视为链路恢复:把挂起的每通道最新意图依次补发一次。 */
+ private onDeviceResponseOk() {
+ if (this.flushScheduled || this.pendingIntents.size === 0) return;
+ const entries: Array<[string, Record]> = [];
+ this.pendingIntents.forEach((params, channel) => entries.push([channel, params]));
+ this.pendingIntents.clear();
+ this.flushScheduled = true;
+ void (async () => {
+ for (const [channel, params] of entries) {
+ // 同通道已有用户在途命令时它比挂起意图更新,跳过补发。
+ if (this.inflightWrites.has(channel)) continue;
+ try {
+ await this.sendCoalescedSetting(channel, params);
+ } catch {
+ // 仍不可达:sendCoalescedSetting 已把意图重新挂起,等下一次成功事件。
+ }
+ }
+ this.flushScheduled = false;
+ })();
+ }
+
+ /** 设备请求统一超时包装:到点 abort 并抛 DeviceTimeoutError;成功则触发挂起意图补发。 */
+ private async timedFetch(
+ url: string,
+ init?: RequestInit,
+ slot?: WriteSlot,
+ ): Promise {
+ const own: WriteSlot = slot ?? { controller: new AbortController(), timedOut: false };
+ const timer = setTimeout(() => {
+ own.timedOut = true;
+ own.controller.abort();
+ }, DEVICE_FETCH_TIMEOUT_MS);
+ try {
+ const response = await fetch(url, { ...init, signal: own.controller.signal });
+ this.onDeviceResponseOk();
+ return response;
+ } catch (error) {
+ if (own.timedOut) throw new DeviceTimeoutError(url);
+ throw error;
+ } finally {
+ clearTimeout(timer);
+ }
}
async getPeqState(): Promise {
return this.enqueuePeqRequest(async () => {
- const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
if (!response.ok) throw new Error(`PEQ read failed: ${response.status}`);
const text = await response.text();
const decoded = decodeCustomBase64(text.trim());
@@ -417,7 +537,7 @@ export class LuxsinAPI {
const encoded = encodeCustomBase64(JSON.stringify(body));
const form = new URLSearchParams();
form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
@@ -432,7 +552,7 @@ export class LuxsinAPI {
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
const form = new URLSearchParams();
form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
@@ -448,7 +568,7 @@ export class LuxsinAPI {
const encoded = encodeCustomBase64(JSON.stringify(payload));
const form = new URLSearchParams();
form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
@@ -464,7 +584,7 @@ export class LuxsinAPI {
const encoded = encodeCustomBase64(JSON.stringify(payload));
const form = new URLSearchParams();
form.set("json", encoded);
- const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
+ const response = await this.timedFetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
diff --git a/client/src/lib/networkError.ts b/client/src/lib/networkError.ts
index e0b6de2..16778a2 100644
--- a/client/src/lib/networkError.ts
+++ b/client/src/lib/networkError.ts
@@ -8,6 +8,8 @@ const NETWORK_FETCH_HINTS = [
"network request failed",
"load failed",
"internet connection appears to be offline",
+ // DeviceTimeoutError:设备请求超时基本等价于设备不可达。
+ "timed out",
];
export function isLikelyNetworkFetchError(error: unknown): boolean {
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx
index 0b30f9e..3965b77 100644
--- a/client/src/pages/Home.tsx
+++ b/client/src/pages/Home.tsx
@@ -20,6 +20,11 @@ import HalloweenVolumeThumb from "@/components/festival/HalloweenVolumeThumb";
import HalloweenPumpkinIcon from "@/components/festival/HalloweenPumpkinIcon";
import HalloweenCardDecoration from "@/components/festival/HalloweenCardDecoration";
import FestivalGreeting from "@/components/festival/FestivalGreeting";
+import {
+ NewYearCardDecoration,
+ NewYearVolumeIcon,
+ NewYearVolumeThumb,
+} from "@/components/festival/NewYearDecoration";
import "@/components/festival/christmas.css";
import "@/components/festival/halloween.css";
import "@/components/festival/festival.css";
@@ -203,6 +208,7 @@ export default function Home() {
const festival = useFestival();
const isChristmas = festival === "christmas";
const isHalloween = festival === "halloween";
+ const isNewYear = festival === "newyear";
const powerActionRef = useRef(false);
const hpEqLastTapRef = useRef(null);
const hpEqNavigateTimerRef = useRef | null>(null);
@@ -559,6 +565,7 @@ export default function Home() {
style={{ background: "rgba(18,18,20,0.8)", minHeight: 140 }}>
{isChristmas && }
{isHalloween && }
+ {isNewYear && }
{/* Stylized device illustration */}
{/* Device body */}
@@ -687,6 +694,7 @@ export default function Home() {
@@ -752,6 +764,7 @@ export default function Home() {