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(null); const textRef = useRef(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 (
{text} {isScrolling ? {text} : null}
); }; 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(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 (
{timeText}
{dateText}
{snapshot.unreadCount > 0 && (
)}
{snapshot.items.length === 0 ? (
{locale === 'en' ? 'No notifications' : '暂无通知消息'}
) : ( <>
{locale === 'en' ? `Unread ${snapshot.unreadCount}` : `未读 ${snapshot.unreadCount}`}
{snapshot.items.map(it => (
onOpenNotification(it)} role="button" tabIndex={0} >
{it.appId ? (() => { const manifest = getAppManifest(it.appId); return manifest ? ( ) : (
); })() : (
)}
{!it.read &&
}
{it.title}
{it.body && (
{it.body}
)}
{formatHM(it.timestamp, locale)}
))}
)}
); }; const NfcGlyph: React.FC<{ active: boolean }> = ({ active }) => ( N ); 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 ( ); }; const VerticalSlider: React.FC<{ value: number; onChange: (v: number) => void; icon: React.ReactNode; iconClassName: string; }> = ({ value, onChange, icon, iconClassName }) => { const trackRef = useRef(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 (
{icon}
); }; 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 ( ); }; const MobileDataTile = () => { const visual = getMobileDataTileVisual(qs.mobileDataEnabled, shadeText.carrier, locale); const t = getTileStyle(qs.mobileDataEnabled, visual.tileClassName); return ( ); }; 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: , label: shadeText.bluetooth }, { kind: 'toggle' as const, key: 'airplaneModeEnabled' as const, activeIcon: 'text-black', icon: , label: shadeText.airplane }, { kind: 'toggle' as const, key: 'doNotDisturbEnabled' as const, activeIcon: 'text-red-500', icon: , label: shadeText.silent }, { kind: 'toggle' as const, key: 'flashlightEnabled' as const, activeIcon: 'text-black', icon: , label: shadeText.flashlight }, // row 2 { kind: 'action' as const, id: 'screenshot', active: false, activeIcon: 'text-black', icon: , 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: , label: shadeText.batterySaver }, { kind: 'toggle' as const, key: 'screenCastEnabled' as const, activeIcon: 'text-black', icon: , label: shadeText.cast }, { kind: 'toggle' as const, key: 'rotationLocked' as const, activeIcon: 'text-blue-500', icon: , label: shadeText.rotationLock }, // row 3 { kind: 'action' as const, id: 'gallery', active: false, activeIcon: 'text-black', icon: , label: shadeText.gallery, onClick: () => { onClose(); window.__OS__?.openApp?.('gallery'); } }, { kind: 'action' as const, id: 'settings', active: false, activeIcon: 'text-black', icon: , label: shadeText.settings, onClick: openSettings }, { kind: 'toggle' as const, key: 'autoBrightnessEnabled' as const, activeIcon: 'text-amber-500', icon: (
A
), label: shadeText.display }, { kind: 'toggle' as const, key: 'nfcEnabled' as const, activeIcon: 'text-blue-500', icon: , label: 'NFC' }, // row 4 { kind: 'action' as const, id: 'ticket', active: false, activeIcon: 'text-black', icon: , 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: , label: shadeText.eyeComfort }, { kind: 'toggle' as const, key: 'locationEnabled' as const, activeIcon: 'text-blue-500', icon: , label: shadeText.location }, { kind: 'toggle' as const, key: 'darkModeEnabled' as const, activeIcon: 'text-black', icon: , label: shadeText.darkMode }, ]; return (
{shadeText.carrierHd}
{dateText}
{nfcUrl ? ( nfc ) : null} {btVisible ? ( btUrl ? ( bluetooth ) : ( ) ) : null} {airplaneMode ? ( airplaneUrl ? ( airplane ) : ( ) ) : noSim ? ( noSimUrl ? ( no-sim ) : null ) : signalUrl ? ( signal ) : ( )} {dataTypeUrl ? ( {`data-${sbState.mobileDataType}`} ) : null} {wifiVisible ? ( wifiUrl ? ( wifi ) : ( ) ) : null}
{batteryPct}% {batterySprite ? (
{useThemeRgba ? (
) : (
)} {batteryBoltUrl ? ( <>
) : null}
) : batteryFallback ? ( battery ) : sbState.charging ? ( ) : ( )}
{/* top tiles */}
{/* media + sliders */} {/* IMPORTANT: Tailwind arbitrary grid values use '_' for spaces (no commas). */}
{shadeText.noMedia}
} iconClassName="text-amber-500" /> } iconClassName="text-blue-500" />
{/* quick toggles grid */}
{grid.map((it, idx) => { if (it.kind === 'toggle') { const active = Boolean(qs[it.key]); return ( onToggle(it.key)} > {it.icon} ); } return ( {it.icon} ); })}
); }; 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(SystemShadeService.getState()); const [notif, setNotif] = useState(NotificationService.getState()); const [qs, setQs] = useState(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(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) => { if (!shade.open) return; if (e.pointerType === 'mouse' && e.button !== 0) return; // 上一次横滑若结束在 panel 空白处,浏览器不派发 click,suppressShadeClickRef 会悬挂吞掉下次合法点击。 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) => { 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) => { 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) => { 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) => { 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) => { 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 (
{/* Scrim */}
{/* Panel */}
QuickSettingsService.toggle(key)} onSetBrightness={setBrightness} onSetVolume={setVolume} withBackground={false} />
); }; export default SystemShade;