Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1235 lines
48 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { SIMULATOR_CONFIG } from '../data';
const { statusBarHeight, zIndexSystemShade, transitionDuration } = SIMULATOR_CONFIG.framework;
import { useOS } from '../OSContext';
import { useOsStateStore } from '../OsStateStore';
import { useTheme } from '../ThemeContext';
import * as TimeService from '../TimeService';
import { SystemShadeService } from '../SystemShadeService';
import { NotificationService } from '../NotificationService';
import { QuickSettingsService } from '../QuickSettingsService';
import StatusBarService from '../StatusBarService';
import type {
OSNotification,
OSNotificationSnapshot,
QuickSettingsState,
ShadePanelKind,
SystemShadeSnapshot,
} from '../types';
import { getAppManifest } from '../data/appRegistry';
import { AppIcon } from './AppIcon';
import { PendingIntent } from '../PendingIntent';
import { useLocale, type Locale } from '../locale';
import {
getShadeDragOffset,
getShadeSwipeTarget,
getShadeWheelOffset,
getShadeWheelTarget,
SHADE_SWITCH_DOMINANCE,
shouldIgnoreShadePanelGestureStart,
} from './systemShadeGestures';
import {
Bell,
BellOff,
BatteryCharging,
Bluetooth,
Cast,
Eye,
Image as ImageIcon,
Lock,
MapPin,
Moon,
Plane,
Play,
Scissors,
Settings,
Signal,
SkipBack,
SkipForward,
Sun,
Ticket,
Volume2,
Wifi,
BatteryMedium,
Zap,
} from 'lucide-react';
function formatShadeDate(d: Date, locale: Locale): string {
if (locale === 'en') {
return TimeService.formatDateTimeForLocale(d, {
month: 'short',
day: 'numeric',
weekday: 'short',
});
}
const m = d.getMonth() + 1;
const day = d.getDate();
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()];
return `${m}${day}${week}`;
}
function formatHM(ts: number, locale: Locale): string {
try {
const d = TimeService.fromTimestamp(ts);
return d.toLocaleTimeString(locale === 'en' ? 'en-US' : 'zh-CN', { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
}
function BackgroundBlur() {
// Match system control-center: blurred wallpaper + dark veil.
return {
backdropFilter: 'blur(22px)',
background:
'radial-gradient(1200px 700px at 20% 15%, rgba(255,255,255,0.10), rgba(255,255,255,0.00) 55%), linear-gradient(180deg, rgba(40,50,70,0.62), rgba(35,45,65,0.62))',
} as React.CSSProperties;
}
const connectivityTileBaseClassName =
'h-[72px] rounded-[22px] px-5 flex items-center justify-between shadow-sm active:scale-[0.99] transition-transform';
const connectivityTileActiveClassName = `${connectivityTileBaseClassName} bg-white/95`;
const connectivityTileInactiveClassName =
`${connectivityTileBaseClassName} bg-black/25 backdrop-blur-2xl border border-white/10`;
const connectivityIconSlotClassName = 'w-[26px] h-[26px] shrink-0 flex items-center justify-center';
type ConnectivityTileVisual = {
tileClassName: string;
iconSlotClassName: string;
title: string;
subtitle: string;
iconClassName: string;
titleClassName: string;
subtitleClassName: string;
};
function shadeText(locale: Locale, zh: string, en: string): string {
return locale === 'en' ? en : zh;
}
function cleanTileLabel(value: string | undefined): string {
return typeof value === 'string' ? value.trim() : '';
}
function getWifiTileVisual(enabled: boolean, ssid: string | undefined, locale: Locale): ConnectivityTileVisual {
if (!enabled) {
return {
tileClassName: connectivityTileInactiveClassName,
iconSlotClassName: connectivityIconSlotClassName,
title: shadeText(locale, 'WLAN', 'WLAN'),
subtitle: shadeText(locale, '已关闭', 'Off'),
iconClassName: 'text-white/85',
titleClassName: 'text-white/85',
subtitleClassName: 'text-white/50',
};
}
return {
tileClassName: connectivityTileActiveClassName,
iconSlotClassName: connectivityIconSlotClassName,
title: cleanTileLabel(ssid) || shadeText(locale, 'WLAN', 'WLAN'),
subtitle: shadeText(locale, '已连接', 'Connected'),
iconClassName: 'text-blue-500',
titleClassName: 'text-black/90',
subtitleClassName: 'text-black/45',
};
}
function getMobileDataTileVisual(enabled: boolean, carrier: string, locale: Locale): ConnectivityTileVisual {
if (!enabled) {
return {
tileClassName: connectivityTileInactiveClassName,
iconSlotClassName: connectivityIconSlotClassName,
title: carrier,
subtitle: shadeText(locale, '已关闭', 'Off'),
iconClassName: 'text-white/85',
titleClassName: 'text-white/85',
subtitleClassName: 'text-white/50',
};
}
return {
tileClassName: connectivityTileActiveClassName,
iconSlotClassName: connectivityIconSlotClassName,
title: carrier,
subtitle: shadeText(locale, '已开启', 'On'),
iconClassName: 'text-emerald-500',
titleClassName: 'text-black/90',
subtitleClassName: 'text-black/45',
};
}
const ScrollingTileText: React.FC<{
text: string;
className: string;
}> = ({ text, className }) => {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollDistance, setScrollDistance] = useState(0);
useEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return;
const measure = () => {
const distance = textEl.scrollWidth - container.clientWidth;
setScrollDistance(distance > 1 ? textEl.scrollWidth + 24 : 0);
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const ro = new ResizeObserver(measure);
ro.observe(container);
ro.observe(textEl);
return () => ro.disconnect();
}, [text]);
const isScrolling = scrollDistance > 0;
const animationDuration = `${Math.max(7, Math.min(14, scrollDistance / 14))}s`;
const marqueeStyle = isScrolling
? ({
'--system-shade-title-scroll': `-${scrollDistance}px`,
animation: `system-shade-title-marquee ${animationDuration} linear infinite`,
} as React.CSSProperties)
: undefined;
return (
<div
ref={containerRef}
className={`relative min-w-0 max-w-full overflow-hidden whitespace-nowrap text-left text-[16px] font-medium ${className}`}
aria-label={text}
>
<span className={isScrolling ? 'inline-flex w-max' : 'block'} style={marqueeStyle}>
<span ref={textRef} className="block">{text}</span>
{isScrolling ? <span className="block pl-6">{text}</span> : null}
</span>
<style>{`
@keyframes system-shade-title-marquee {
0%, 12% { transform: translateX(0); }
88%, 100% { transform: translateX(var(--system-shade-title-scroll)); }
}
`}</style>
</div>
);
};
const NotificationCenterPanel: React.FC<{
snapshot: OSNotificationSnapshot;
onClose: () => void;
onOpenNotification: (it: OSNotification) => void;
withBackground?: boolean;
}> = ({ snapshot, onClose, onOpenNotification, withBackground = true }) => {
const locale = useLocale();
const now = useMemo(() => TimeService.getDate(), [snapshot.items.length, snapshot.unreadCount]);
const timeText = useMemo(() => `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`, [now]);
const dateText = useMemo(() => formatShadeDate(now, locale), [locale, now]);
const scrollRef = useRef<HTMLDivElement>(null);
// Dynamically toggle touch-action on the scroll container so that
// swipe-up-to-close works in touch mode when the list is at the top.
// When scrollTop===0, set touch-action:none so the browser won't steal
// the upward swipe for native scrolling; otherwise allow pan-y for normal scroll.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const sync = () => {
el.style.touchAction = el.scrollTop <= 1 ? 'none' : 'pan-y';
};
sync();
el.addEventListener('scroll', sync, { passive: true });
return () => el.removeEventListener('scroll', sync);
}, [snapshot.items.length]);
return (
<div
className="h-full w-full text-white flex flex-col"
data-status-bar-foreground="light"
style={withBackground ? BackgroundBlur() : undefined}
>
<div className="shrink-0 px-6" style={{ paddingTop: statusBarHeight + 18 }}>
<div className="flex items-start justify-between">
<div>
<div className="text-[64px] leading-none font-light text-white">{timeText}</div>
<div className="mt-1 text-[15px] text-white/75">{dateText}</div>
</div>
<div className="relative w-10 h-10 rounded-full bg-white/10 backdrop-blur-xl flex items-center justify-center">
<Bell size={20} className="text-white/85" />
{snapshot.unreadCount > 0 && (
<div className="absolute right-[10px] top-[10px] w-1.5 h-1.5 rounded-full bg-white/85" />
)}
</div>
</div>
</div>
<div
ref={scrollRef}
className="mt-10 px-6 pb-10 min-h-0 flex-1 overflow-y-auto no-scrollbar"
data-shade-scroll="true"
>
{snapshot.items.length === 0 ? (
<div className="h-full w-full flex items-center justify-center text-white/35 text-[16px]">
{locale === 'en' ? 'No notifications' : '暂无通知消息'}
</div>
) : (
<>
<div className="flex items-center justify-between mb-3">
<div className="text-[13px] text-white/55">{locale === 'en' ? `Unread ${snapshot.unreadCount}` : `未读 ${snapshot.unreadCount}`}</div>
<button
type="button"
className="text-[13px] text-white/70 active:text-white/90"
onClick={() => {
NotificationService.clearAll();
// If clearing leaves empty, match system by staying on the empty page.
}}
>
{locale === 'en' ? 'Clear all' : '清除全部'}
</button>
</div>
<div className="flex flex-col gap-3">
{snapshot.items.map(it => (
<div
key={it.id}
className="bg-black/25 backdrop-blur-2xl border border-white/10 rounded-2xl px-4 py-3 shadow-sm active:scale-[0.99] transition-transform"
onClick={() => onOpenNotification(it)}
role="button"
tabIndex={0}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{it.appId ? (() => {
const manifest = getAppManifest(it.appId);
return manifest ? (
<AppIcon manifest={manifest} size={40} radius={12} showShadow={false} />
) : (
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Bell size={18} className="text-white/75" />
</div>
);
})() : (
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Bell size={18} className="text-white/75" />
</div>
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
{!it.read && <div className="w-2 h-2 rounded-full bg-blue-400" />}
<div className="text-[14px] font-medium text-white/90 truncate">
{it.title}
</div>
</div>
{it.body && (
<div className="mt-0.5 text-[13px] text-white/60 truncate">
{it.body}
</div>
)}
</div>
</div>
<div className="text-[12px] text-white/45 shrink-0">
{formatHM(it.timestamp, locale)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
);
};
const NfcGlyph: React.FC<{ active: boolean }> = ({ active }) => (
<span className={`text-[18px] font-semibold ${active ? 'text-blue-500' : 'text-blue-300'}`}>N</span>
);
const CircleButton: React.FC<{
active?: boolean;
onClick?: () => void;
activeIconClassName?: string;
children: React.ReactNode;
ariaLabel: string;
}> = ({ active = false, onClick, children, activeIconClassName, ariaLabel }) => {
const { themeService, version } = useTheme();
const themedBg = themeService.getShadeTileBackground(active ? 'active' : 'inactive');
return (
<button
type="button"
aria-label={ariaLabel}
className={`w-[60px] h-[60px] rounded-full flex items-center justify-center transition-transform active:scale-95 ${themedBg ? 'bg-transparent shadow-sm' : active ? 'bg-white/95 shadow-sm' : 'bg-black/25 backdrop-blur-2xl border border-white/10'
}`}
onClick={onClick}
style={
themedBg
? { backgroundImage: `url(${themedBg})`, backgroundSize: '100% 100%', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' }
: undefined
}
>
<div className={active ? (activeIconClassName ?? 'text-black') : 'text-white/85'}>
<span key={version}>{children}</span>
</div>
</button>
);
};
const VerticalSlider: React.FC<{
value: number;
onChange: (v: number) => void;
icon: React.ReactNode;
iconClassName: string;
}> = ({ value, onChange, icon, iconClassName }) => {
const trackRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
pid: number;
active: boolean;
pointerType: string;
startY: number;
startValue: number;
h: number;
moved: boolean;
} | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
// System-like: press then swipe; tap alone does nothing.
const h = Math.max(1, (e.currentTarget as HTMLDivElement).getBoundingClientRect().height);
dragRef.current = {
pid: e.pointerId,
active: true,
pointerType: e.pointerType,
startY: e.clientY,
startValue: value,
h,
moved: false,
};
try {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
} catch {
// ignore
}
// Prevent page/overlay from hijacking the gesture.
e.preventDefault?.();
e.stopPropagation?.();
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragRef.current?.active || dragRef.current.pid !== e.pointerId) return;
const s = dragRef.current;
const dy = e.clientY - s.startY;
if (!s.moved) {
// Small threshold to avoid treating tap jitter as swipe.
const SWIPE_START_PX = s.pointerType === 'mouse' ? 2 : 6;
if (Math.abs(dy) < SWIPE_START_PX) return;
s.moved = true;
}
e.preventDefault?.();
e.stopPropagation?.();
// Swipe up (clientY decreases) => increase value. 1:1 pixel follow.
const delta = (-dy / Math.max(1, s.h)) * 100;
const next = Math.max(0, Math.min(100, Math.round(s.startValue + delta)));
onChange(next);
};
const onPointerUp = (e: React.PointerEvent) => {
if (dragRef.current?.pid !== e.pointerId) return;
const s = dragRef.current;
if (s?.moved) {
// Ensure final position is applied (some environments may deliver sparse move events).
const dy = e.clientY - s.startY;
const delta = (-dy / Math.max(1, s.h)) * 100;
const next = Math.max(0, Math.min(100, Math.round(s.startValue + delta)));
onChange(next);
}
dragRef.current = null;
};
const TRACK_H = 160;
const ICON_SIZE = 22;
const PAD = 12;
const v = Math.max(0, Math.min(100, value));
const fillPx = (v / 100) * TRACK_H;
// Keep icon inside filled area; center it when fill is large enough.
let iconBottom = fillPx / 2 - ICON_SIZE / 2;
if (fillPx < ICON_SIZE + PAD * 2) {
iconBottom = PAD;
} else {
iconBottom = Math.max(PAD, Math.min(fillPx - ICON_SIZE - PAD, iconBottom));
}
const fillH = `${v}%`;
return (
<div
data-shade-slider="true"
className="relative w-full h-[160px] rounded-[28px] overflow-hidden border border-white/10 backdrop-blur-2xl bg-gradient-to-b from-black/25 to-indigo-900/15 touch-none"
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div className="absolute inset-x-0 bottom-0 bg-white/95 pointer-events-none" style={{ height: fillH }} />
<div className="absolute inset-x-0 flex justify-center pointer-events-none" style={{ bottom: `${iconBottom}px` }}>
<div className={`${iconClassName} pointer-events-none`}>
{icon}
</div>
</div>
</div>
);
};
const ControlCenterPanel: React.FC<{
qs: QuickSettingsState;
wifiSsid?: string;
brightness: number;
volume: number;
onClose: () => void;
onToggle: (key: keyof QuickSettingsState) => void;
onSetBrightness: (v: number) => void;
onSetVolume: (v: number) => void;
withBackground?: boolean;
}> = ({ qs, wifiSsid, brightness, volume, onClose, onToggle, onSetBrightness, onSetVolume, withBackground = true }) => {
const locale = useLocale();
const { themeService, version } = useTheme();
const sbState = useSyncExternalStore(StatusBarService.subscribe, StatusBarService.getState);
const batteryPct = Math.min(Math.max(sbState.batteryPercent, 0), 100);
const shadeIconTintFilter = 'brightness(0) invert(1)';
const shadeStatusIconClassName = 'w-[14px] h-[14px] object-contain';
const wifiVisible = qs.wifiEnabled;
const btVisible = qs.bluetoothEnabled;
const airplaneMode = qs.airplaneModeEnabled;
const noSim = sbState.noSim;
const signalVisible = !airplaneMode && !noSim;
const nfcUrl = qs.nfcEnabled ? themeService.getStatusBarIcon('nfc') : null;
const btUrl = btVisible ? themeService.getStatusBarIcon('bluetooth') : null;
const airplaneUrl = airplaneMode ? themeService.getStatusBarIcon('airplane') : null;
const noSimUrl = !airplaneMode && noSim
? themeService.getStatusBarIcon('no_sim') || themeService.getStatusBarIcon('signal_null')
: null;
const signalUrl = signalVisible ? themeService.getStatusBarSignalIcon(sbState.signalLevel) : null;
const dataTypeVisible =
signalVisible && qs.mobileDataEnabled && sbState.mobileDataType !== 'none';
const dataTypeUrl = dataTypeVisible ? themeService.getStatusBarDataTypeIcon(sbState.mobileDataType) : null;
const wifiUrl = wifiVisible ? themeService.getStatusBarWifiIcon(sbState.wifiLevel) : null;
// Theme-first / system-fallback (mirrors SystemShell logic):
const baseSprite = themeService.getStatusBarBatterySprite();
const lowBattery = !sbState.charging && sbState.batteryPercent < 20;
let themeRgbaSprite: { url: string; frames: number; frameSide: number } | null = null;
let rgbaHasBakedBolt = false;
if (qs.batterySaverEnabled && sbState.charging) {
themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save_charge');
if (themeRgbaSprite) rgbaHasBakedBolt = true;
if (!themeRgbaSprite) {
themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save');
}
} else if (sbState.charging) {
themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('charge');
if (themeRgbaSprite) rgbaHasBakedBolt = true;
} else if (qs.batterySaverEnabled) {
themeRgbaSprite = themeService.getStatusBarBatteryVariantSprite('power_save');
}
const batterySprite = themeRgbaSprite ?? baseSprite;
const useThemeRgba = themeRgbaSprite !== null;
const batteryFallback = themeService.getStatusBarIcon('battery');
const batteryFrames = batterySprite?.frames ?? 0;
const batteryFrameIdx =
batterySprite && batteryFrames > 1
? Math.min(Math.max(Math.floor(((batteryFrames - 1) * batteryPct) / 100), 0), batteryFrames - 1)
: 0;
// System-tint color used when no theme variant matches.
const systemTintColor = qs.batterySaverEnabled
? '#F5C518'
: sbState.charging
? '#22C55E'
: lowBattery
? '#EF4444'
: '#FFFFFF';
const needsBoltOverlay = sbState.charging && !rgbaHasBakedBolt;
const batteryBoltUrl = needsBoltOverlay
? themeService.getStatusBarBatteryBoltOverlay()
: null;
const batteryBoltCoreColor = sbState.fastCharging ? 'rgba(0,0,0,0.92)' : 'rgba(0,0,0,0.78)';
const batteryBoltHaloColor = systemTintColor;
const now = useMemo(() => TimeService.getDate(), [qs]);
const dateText = useMemo(() => formatShadeDate(now, locale), [locale, now]);
const shadeText = useMemo(() => ({
connected: locale === 'en' ? 'Connected' : '已连接',
off: locale === 'en' ? 'Off' : '已关闭',
on: locale === 'en' ? 'On' : '已开启',
mobileData: locale === 'en' ? 'Mobile data' : '移动数据',
bluetooth: locale === 'en' ? 'Bluetooth' : '蓝牙',
airplane: locale === 'en' ? 'Airplane mode' : '飞行模式',
silent: locale === 'en' ? 'Silent' : '静音',
flashlight: locale === 'en' ? 'Flashlight' : '手电筒',
screenshot: locale === 'en' ? 'Screenshot' : '截屏',
screenshotDone: locale === 'en' ? 'Screenshot simulated' : '已模拟截屏',
screenshotHint: locale === 'en' ? '(Control Center demo)' : '(控制中心示意)',
batterySaver: locale === 'en' ? 'Battery saver' : '省电模式',
cast: locale === 'en' ? 'Cast' : '投屏',
rotationLock: locale === 'en' ? 'Rotation lock' : '旋转锁定',
gallery: locale === 'en' ? 'Gallery' : '相册',
settings: locale === 'en' ? 'Settings' : '设置',
display: locale === 'en' ? 'Display' : '显示',
pass: locale === 'en' ? 'Cards' : '票证',
passHint: locale === 'en' ? '(Placeholder)' : '(占位)',
eyeComfort: locale === 'en' ? 'Eye comfort' : '护眼',
location: locale === 'en' ? 'Location' : '定位',
darkMode: locale === 'en' ? 'Dark mode' : '深色',
noMedia: locale === 'en' ? 'Nothing playing' : '暂无播放',
edit: locale === 'en' ? 'Edit' : '编辑',
carrier: locale === 'en' ? 'China Unicom' : '中国联通',
carrierHd: locale === 'en' ? 'China Telecom HD | China Unicom HD' : '中国电信HD | 中国联通HD',
}), [locale]);
const getTileStyle = (active: boolean, className: string): { className: string; style?: React.CSSProperties } => {
const bg = themeService.getShadeTileBackground(active ? 'active' : 'inactive');
if (!bg) return { className };
return {
className: className
.replace('bg-white/95', 'bg-transparent')
.replace('bg-black/25', 'bg-transparent'),
style: { backgroundImage: `url(${bg})`, backgroundSize: '100% 100%', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' },
};
};
const WifiTile = () => {
const visual = getWifiTileVisual(qs.wifiEnabled, wifiSsid, locale);
const t = getTileStyle(qs.wifiEnabled, visual.tileClassName);
return (
<button type="button" className={t.className} style={t.style} onClick={() => onToggle('wifiEnabled')} aria-label="WiFi">
<div className="flex flex-1 items-center gap-3 min-w-0">
<span className={visual.iconSlotClassName}>
<Wifi key={version} size={26} strokeWidth={2} className={`block ${visual.iconClassName}`} />
</span>
<div className="min-w-0 flex-1">
<ScrollingTileText text={visual.title} className={visual.titleClassName} />
<div className={`text-left text-[12px] ${visual.subtitleClassName}`}>{visual.subtitle}</div>
</div>
</div>
</button>
);
};
const MobileDataTile = () => {
const visual = getMobileDataTileVisual(qs.mobileDataEnabled, shadeText.carrier, locale);
const t = getTileStyle(qs.mobileDataEnabled, visual.tileClassName);
return (
<button type="button" className={t.className} style={t.style} onClick={() => onToggle('mobileDataEnabled')} aria-label={shadeText.mobileData}>
<div className="flex flex-1 items-center gap-3 min-w-0">
<span className={visual.iconSlotClassName}>
<Signal key={version} size={26} strokeWidth={2} className={`block ${visual.iconClassName}`} />
</span>
<div className="min-w-0 flex-1">
<ScrollingTileText text={visual.title} className={visual.titleClassName} />
<div className={`text-left text-[12px] ${visual.subtitleClassName}`}>{visual.subtitle}</div>
</div>
</div>
</button>
);
};
const openSettings = () => {
onClose();
const os = window.__OS__;
if (os && typeof os.openApp === 'function') {
os.openApp('settings');
}
};
const grid = [
// row 1
{ kind: 'toggle' as const, key: 'bluetoothEnabled' as const, activeIcon: 'text-blue-500', icon: <Bluetooth size={22} />, label: shadeText.bluetooth },
{ kind: 'toggle' as const, key: 'airplaneModeEnabled' as const, activeIcon: 'text-black', icon: <Plane size={22} />, label: shadeText.airplane },
{ kind: 'toggle' as const, key: 'doNotDisturbEnabled' as const, activeIcon: 'text-red-500', icon: <BellOff size={22} />, label: shadeText.silent },
{ kind: 'toggle' as const, key: 'flashlightEnabled' as const, activeIcon: 'text-black', icon: <Zap size={22} />, label: shadeText.flashlight },
// row 2
{ kind: 'action' as const, id: 'screenshot', active: false, activeIcon: 'text-black', icon: <Scissors size={22} />, label: shadeText.screenshot, onClick: () => NotificationService.push({ title: shadeText.screenshotDone, body: shadeText.screenshotHint, read: true }) },
{ kind: 'toggle' as const, key: 'batterySaverEnabled' as const, activeIcon: 'text-black', icon: <BatteryCharging size={22} />, label: shadeText.batterySaver },
{ kind: 'toggle' as const, key: 'screenCastEnabled' as const, activeIcon: 'text-black', icon: <Cast size={22} />, label: shadeText.cast },
{ kind: 'toggle' as const, key: 'rotationLocked' as const, activeIcon: 'text-blue-500', icon: <Lock size={22} />, label: shadeText.rotationLock },
// row 3
{ kind: 'action' as const, id: 'gallery', active: false, activeIcon: 'text-black', icon: <ImageIcon size={22} />, label: shadeText.gallery, onClick: () => { onClose(); window.__OS__?.openApp?.('gallery'); } },
{ kind: 'action' as const, id: 'settings', active: false, activeIcon: 'text-black', icon: <Settings size={22} />, label: shadeText.settings, onClick: openSettings },
{
kind: 'toggle' as const, key: 'autoBrightnessEnabled' as const, activeIcon: 'text-amber-500', icon: (
<div className="relative">
<Sun size={22} />
<span className="absolute -right-1 -top-1 text-[10px] font-semibold">A</span>
</div>
), label: shadeText.display
},
{ kind: 'toggle' as const, key: 'nfcEnabled' as const, activeIcon: 'text-blue-500', icon: <NfcGlyph active={qs.nfcEnabled} />, label: 'NFC' },
// row 4
{ kind: 'action' as const, id: 'ticket', active: false, activeIcon: 'text-black', icon: <Ticket size={22} />, label: shadeText.pass, onClick: () => NotificationService.push({ title: shadeText.pass, body: shadeText.passHint, read: true }) },
{ kind: 'toggle' as const, key: 'eyeComfortEnabled' as const, activeIcon: 'text-black', icon: <Eye size={22} />, label: shadeText.eyeComfort },
{ kind: 'toggle' as const, key: 'locationEnabled' as const, activeIcon: 'text-blue-500', icon: <MapPin size={22} />, label: shadeText.location },
{ kind: 'toggle' as const, key: 'darkModeEnabled' as const, activeIcon: 'text-black', icon: <Moon size={22} />, label: shadeText.darkMode },
];
return (
<div className="h-full w-full text-white" data-status-bar-foreground="light" style={withBackground ? BackgroundBlur() : undefined}>
<div className="px-4" style={{ paddingTop: statusBarHeight + 18 }}>
<div className="flex justify-end">
<div className="text-[12px] text-white/55 leading-none text-right">{shadeText.carrierHd}</div>
</div>
<div className="mt-1 flex items-center justify-between">
<div className="text-[15px] text-white/80 leading-none">
{dateText}
</div>
<div className="flex items-center justify-end gap-1.5 text-white/85 text-[12px] leading-none">
{nfcUrl ? (
<img
key={`shade_nfc_${version}`}
src={nfcUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="nfc"
/>
) : null}
{btVisible ? (
btUrl ? (
<img
key={`shade_bt_${version}`}
src={btUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="bluetooth"
/>
) : (
<Bluetooth size={14} />
)
) : null}
{airplaneMode ? (
airplaneUrl ? (
<img
key={`shade_airplane_${version}`}
src={airplaneUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="airplane"
/>
) : (
<Plane size={14} />
)
) : noSim ? (
noSimUrl ? (
<img
key={`shade_no_sim_${version}`}
src={noSimUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="no-sim"
/>
) : null
) : signalUrl ? (
<img
key={`shade_signal_${sbState.signalLevel}_${version}`}
src={signalUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="signal"
/>
) : (
<Signal size={14} />
)}
{dataTypeUrl ? (
<img
key={`shade_data_${sbState.mobileDataType}_${version}`}
src={dataTypeUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt={`data-${sbState.mobileDataType}`}
/>
) : null}
{wifiVisible ? (
wifiUrl ? (
<img
key={`shade_wifi_${sbState.wifiLevel}_${version}`}
src={wifiUrl}
className={shadeStatusIconClassName}
style={{ filter: shadeIconTintFilter }}
alt="wifi"
/>
) : (
<Wifi size={14} />
)
) : null}
<div className="flex items-center gap-1 text-white/90">
<span className="text-[11px] leading-none">{batteryPct}%</span>
{batterySprite ? (
<div
key={`shade_battery_${batteryPct}_${sbState.charging ? 1 : 0}_${sbState.fastCharging ? 1 : 0}_${qs.batterySaverEnabled ? 1 : 0}_${useThemeRgba ? 'r' : 't'}_${version}`}
className="relative w-[18px] h-[18px]"
aria-label="battery"
>
{useThemeRgba ? (
<div
className="absolute inset-0 bg-no-repeat"
style={{
backgroundImage: `url(${batterySprite.url})`,
backgroundSize: `18px ${batteryFrames * 18}px`,
backgroundPosition: `0px ${-batteryFrameIdx * 18}px`,
}}
/>
) : (
<div
className="absolute inset-0"
style={{
backgroundColor: systemTintColor,
WebkitMaskImage: `url(${batterySprite.url})`,
maskImage: `url(${batterySprite.url})`,
WebkitMaskSize: `18px ${batteryFrames * 18}px`,
maskSize: `18px ${batteryFrames * 18}px`,
WebkitMaskPosition: `0px ${-batteryFrameIdx * 18}px`,
maskPosition: `0px ${-batteryFrameIdx * 18}px`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
}}
/>
)}
{batteryBoltUrl ? (
<>
<div
className="absolute inset-0"
style={{
backgroundColor: batteryBoltHaloColor,
WebkitMaskImage: `url(${batteryBoltUrl})`,
maskImage: `url(${batteryBoltUrl})`,
WebkitMaskSize: '20px 20px',
maskSize: '20px 20px',
WebkitMaskPosition: '-1px -1px',
maskPosition: '-1px -1px',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
}}
/>
<div
className="absolute inset-0"
style={{
backgroundColor: batteryBoltCoreColor,
WebkitMaskImage: `url(${batteryBoltUrl})`,
maskImage: `url(${batteryBoltUrl})`,
WebkitMaskSize: '18px 18px',
maskSize: '18px 18px',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
}}
aria-label="battery-charging"
/>
</>
) : null}
</div>
) : batteryFallback ? (
<img
key={`shade_battery_${version}`}
src={batteryFallback}
className="w-[18px] h-[18px] object-contain"
style={{ filter: shadeIconTintFilter }}
alt="battery"
/>
) : sbState.charging ? (
<BatteryCharging size={16} />
) : (
<BatteryMedium size={16} fill="currentColor" />
)}
</div>
</div>
</div>
</div>
<div className="mt-5 px-4 pb-10">
{/* top tiles */}
<div className="grid grid-cols-2 gap-3">
<WifiTile />
<MobileDataTile />
</div>
{/* media + sliders */}
{/* IMPORTANT: Tailwind arbitrary grid values use '_' for spaces (no commas). */}
<div className="mt-4 grid grid-cols-[2.2fr_1fr_1fr] gap-3">
<div className="rounded-[28px] bg-gradient-to-br from-black/35 via-black/25 to-indigo-900/20 backdrop-blur-2xl border border-white/10 p-4 h-[160px] flex flex-col justify-between">
<div className="flex-1 flex items-center justify-center text-white/35 text-[16px]">
{shadeText.noMedia}
</div>
<div className="flex items-center justify-between px-4 pb-1 text-white/85">
<SkipBack size={22} className="opacity-70" />
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center">
<Play size={22} className="text-white/90" />
</div>
<SkipForward size={22} className="opacity-70" />
</div>
</div>
<VerticalSlider
value={brightness}
onChange={onSetBrightness}
icon={<Sun size={22} />}
iconClassName="text-amber-500"
/>
<VerticalSlider
value={volume}
onChange={onSetVolume}
icon={<Volume2 size={22} />}
iconClassName="text-blue-500"
/>
</div>
{/* quick toggles grid */}
<div className="mt-5 grid grid-cols-4 gap-y-4 justify-items-center">
{grid.map((it, idx) => {
if (it.kind === 'toggle') {
const active = Boolean(qs[it.key]);
return (
<CircleButton
key={`${it.kind}-${it.key}-${idx}`}
active={active}
activeIconClassName={it.activeIcon}
ariaLabel={it.label}
onClick={() => onToggle(it.key)}
>
{it.icon}
</CircleButton>
);
}
return (
<CircleButton
key={`${it.kind}-${it.id}-${idx}`}
active={Boolean(it.active)}
activeIconClassName={it.activeIcon}
ariaLabel={it.label}
onClick={it.onClick}
>
{it.icon}
</CircleButton>
);
})}
</div>
<button
type="button"
className="mt-6 mx-auto block px-6 py-1.5 rounded-full bg-black/25 backdrop-blur-xl text-white/70 text-[13px] active:bg-black/35"
onClick={() => {
// placeholder: edit mode for quick tiles
}}
>
{shadeText.edit}
</button>
</div>
</div>
);
};
export const SystemShade: React.FC = () => {
const { launchApp, setBrightness, setVolume } = useOS();
const brightness = useOsStateStore(s => s.settings.system.brightness);
const volume = useOsStateStore(s => s.settings.system.mediaVolume);
const wifiSsid = useOsStateStore(s => s.hardware.wifi.connectedSsid);
const [shade, setShade] = useState<SystemShadeSnapshot>(SystemShadeService.getState());
const [notif, setNotif] = useState<OSNotificationSnapshot>(NotificationService.getState());
const [qs, setQs] = useState<QuickSettingsState>(QuickSettingsService.getState());
useEffect(() => SystemShadeService.subscribe(setShade), []);
useEffect(() => NotificationService.subscribe(setNotif), []);
useEffect(() => QuickSettingsService.subscribe(setQs), []);
// ESC closes shade (desktop convenience)
useEffect(() => {
if (!shade.open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') SystemShadeService.close();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [shade.open]);
const onClose = () => SystemShadeService.close();
const panelRef = useRef<HTMLDivElement>(null);
const [shadeDragOffsetX, setShadeDragOffsetX] = useState(0);
const [isShadeDragging, setIsShadeDragging] = useState(false);
const suppressShadeClickRef = useRef(false);
const wasShadeOpenRef = useRef(false);
const wheelSwitchRef = useRef<{ dx: number; dy: number; timer: number | null; kind: ShadePanelKind | null }>({
dx: 0,
dy: 0,
timer: null,
kind: null,
});
// 横滑切换 + 上滑关闭。避免和亮度/音量 slider、可滚动通知列表抢手势。
const shadeGestureRef = useRef<{
pid: number;
x: number;
y: number;
kind: ShadePanelKind;
mode: 'pending' | 'horizontal' | 'vertical';
canVerticalClose: boolean;
} | null>(null);
const shouldIgnoreSwipeClose = (target: EventTarget | null): boolean => {
const el = target instanceof Element ? target : null;
if (!el) return false;
if (el.closest?.('[data-shade-slider="true"]')) return true;
if (el.closest?.('[role="button"], button, a, [data-clickable="true"]')) return true;
const scrollEl = el.closest?.('[data-shade-scroll="true"]') as HTMLElement | null;
if (scrollEl) {
const isScrollable = scrollEl.scrollHeight > scrollEl.clientHeight + 2;
if (!isScrollable) return false;
return scrollEl.scrollTop > 1;
}
return false;
};
const shouldBlockShadeGesture = (target: EventTarget | null): boolean => {
const el = target instanceof Element ? target : null;
return shouldIgnoreShadePanelGestureStart(el);
};
const getPanelWidth = () => panelRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 360);
useEffect(() => {
setShadeDragOffsetX(0);
setIsShadeDragging(false);
const w = wheelSwitchRef.current;
w.dx = 0;
w.dy = 0;
w.kind = shade.kind;
if (w.timer) {
window.clearTimeout(w.timer);
w.timer = null;
}
}, [shade.kind, shade.open]);
useEffect(() => () => {
const w = wheelSwitchRef.current;
if (w.timer) {
window.clearTimeout(w.timer);
w.timer = null;
}
}, []);
const onPanelPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!shade.open) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
// 上一次横滑若结束在 panel 空白处,浏览器不派发 clicksuppressShadeClickRef 会悬挂吞掉下次合法点击。
suppressShadeClickRef.current = false;
shadeGestureRef.current = null;
if (shouldBlockShadeGesture(e.target)) return;
shadeGestureRef.current = {
pid: e.pointerId,
x: e.clientX,
y: e.clientY,
kind: shade.kind,
mode: 'pending',
canVerticalClose: !shouldIgnoreSwipeClose(e.target),
};
};
const onPanelPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const s = shadeGestureRef.current;
if (!s || s.pid !== e.pointerId) return;
if (e.pointerType === 'mouse' && e.buttons === 0) {
shadeGestureRef.current = null;
setShadeDragOffsetX(0);
setIsShadeDragging(false);
return;
}
const dy = e.clientY - s.y;
const dx = e.clientX - s.x;
if (s.mode === 'pending') {
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absX > 10 && absX >= absY * SHADE_SWITCH_DOMINANCE && getShadeDragOffset(s.kind, dx, getPanelWidth()) !== 0) {
s.mode = 'horizontal';
setIsShadeDragging(true);
try {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
} catch {
// ignore
}
} else if (absY > 10) {
s.mode = 'vertical';
if (s.canVerticalClose) {
try {
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
} catch {
// ignore
}
}
}
}
if (s.mode === 'horizontal') {
suppressShadeClickRef.current = true;
e.preventDefault?.();
e.stopPropagation?.();
setShadeDragOffsetX(getShadeDragOffset(s.kind, dx, getPanelWidth()));
return;
}
if (s.mode === 'vertical' && s.canVerticalClose && dy < -90 && Math.abs(dx) < 90) {
shadeGestureRef.current = null;
setShadeDragOffsetX(0);
setIsShadeDragging(false);
onClose();
}
};
const onPanelPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const s = shadeGestureRef.current;
if (!s || s.pid !== e.pointerId) return;
const dy = e.clientY - s.y;
const dx = e.clientX - s.x;
shadeGestureRef.current = null;
if (s.mode === 'horizontal') {
const target = getShadeSwipeTarget(s.kind, { dx, dy });
if (target) SystemShadeService.open(target);
suppressShadeClickRef.current = true;
}
setShadeDragOffsetX(0);
setIsShadeDragging(false);
};
const onPanelPointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {
const s = shadeGestureRef.current;
if (!s || s.pid !== e.pointerId) return;
shadeGestureRef.current = null;
// pointercancel 不会有后续 click,绝不能置位 suppressShadeClickRef,否则会吞掉下次合法点击。
setShadeDragOffsetX(0);
setIsShadeDragging(false);
};
const onPanelWheel = (e: React.WheelEvent<HTMLDivElement>) => {
if (!shade.open) return;
if (shouldBlockShadeGesture(e.target)) return;
const absX = Math.abs(e.deltaX);
const absY = Math.abs(e.deltaY);
if (absX < absY * SHADE_SWITCH_DOMINANCE) return;
if (e.cancelable) e.preventDefault();
const w = wheelSwitchRef.current;
if (w.kind !== shade.kind) {
w.dx = 0;
w.dy = 0;
w.kind = shade.kind;
}
w.dx += e.deltaX;
w.dy += e.deltaY;
if (w.timer) window.clearTimeout(w.timer);
setIsShadeDragging(true);
setShadeDragOffsetX(getShadeWheelOffset(shade.kind, w.dx, getPanelWidth()));
w.timer = window.setTimeout(() => {
const target = getShadeWheelTarget(shade.kind, { deltaX: w.dx, deltaY: w.dy });
if (target) SystemShadeService.open(target);
setShadeDragOffsetX(0);
setIsShadeDragging(false);
w.dx = 0;
w.dy = 0;
w.kind = null;
w.timer = null;
}, 180);
};
const onPanelClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
if (!suppressShadeClickRef.current) return;
suppressShadeClickRef.current = false;
e.stopPropagation();
e.preventDefault();
};
const onOpenNotification = (it: OSNotification) => {
if (it.autoCancel !== false) {
NotificationService.dismiss(it.id);
} else {
NotificationService.markRead(it.id, true);
}
onClose();
if (it.pendingIntent) {
PendingIntent.send(it.pendingIntent);
return;
}
if (!it.appId) return;
const os = window.__OS__;
if (it.route && os && typeof os.openApp === 'function') {
os.openApp(it.appId, it.route);
return;
}
launchApp(it.appId);
};
const kind: ShadePanelKind = shade.kind;
const open = shade.open;
const panelWidth = getPanelWidth();
const activePanelIndex = kind === 'control' ? 1 : 0;
const panelTrackX = -activePanelIndex * panelWidth + shadeDragOffsetX;
const shouldAnimatePanelTrack = open && wasShadeOpenRef.current && !isShadeDragging;
useEffect(() => {
wasShadeOpenRef.current = open;
}, [open]);
return (
<div
data-no-clipboard="true"
className={`fixed inset-0 select-none ${open ? 'pointer-events-auto' : 'pointer-events-none'}`}
style={{ zIndex: zIndexSystemShade }}
>
{/* Scrim */}
<div
className={`absolute inset-0 transition-opacity ${open ? 'opacity-100' : 'opacity-0'
}`}
style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}
onClick={onClose}
/>
{/* Panel */}
<div
ref={panelRef}
className={`absolute inset-0 touch-none overflow-hidden transition-transform ease-out ${open ? 'translate-y-0' : '-translate-y-full'
}`}
style={{ transitionDuration: `${transitionDuration}ms` }}
onClickCapture={onPanelClickCapture}
onPointerDown={onPanelPointerDown}
onPointerMove={onPanelPointerMove}
onPointerUp={onPanelPointerUp}
onPointerCancel={onPanelPointerCancel}
onWheel={onPanelWheel}
>
<div className="absolute inset-0 pointer-events-none" style={BackgroundBlur()} />
<div
className="relative flex h-full will-change-transform ease-out"
style={{
transform: `translate3d(${panelTrackX}px, 0, 0)`,
transitionProperty: 'transform',
transitionDuration: shouldAnimatePanelTrack ? `${transitionDuration}ms` : '0ms',
}}
>
<div className="h-full min-w-full">
<NotificationCenterPanel
snapshot={notif}
onClose={onClose}
onOpenNotification={onOpenNotification}
withBackground={false}
/>
</div>
<div className="h-full min-w-full">
<ControlCenterPanel
qs={qs}
wifiSsid={wifiSsid}
brightness={brightness}
volume={volume}
onClose={onClose}
onToggle={(key) => QuickSettingsService.toggle(key)}
onSetBrightness={setBrightness}
onSetVolume={setVolume}
withBackground={false}
/>
</div>
</div>
</div>
</div>
);
};
export default SystemShade;