Skip to content

Commit d3fa5b2

Browse files
committed
feat(shell): replace fullscreen video wallpaper with draggable PiP window
Convert the live wallpaper from a fullscreen background video to a picture-in-picture floating window (200×280px) centered above the bottom toolbar, similar to WeChat video calls. - Add draggable PiP container with rounded corners and shadow - Show close button on hover to dismiss the video - Always use static wallpaper as background regardless of PiP state - Default live wallpaper to off; toggle via toolbar camera button - Dynamically position PiP based on toolbar's actual DOM position
1 parent 261426f commit d3fa5b2

2 files changed

Lines changed: 127 additions & 27 deletions

File tree

apps/webuiapps/src/components/Shell/index.module.scss

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,66 @@
88
position: relative;
99
}
1010

11-
.videoBg {
11+
.videoPip {
12+
position: fixed;
13+
bottom: 80px;
14+
left: 16px;
15+
width: 200px;
16+
height: 280px;
17+
border-radius: 16px;
18+
overflow: hidden;
19+
z-index: 10001;
20+
box-shadow:
21+
0 8px 32px rgba(0, 0, 0, 0.6),
22+
0 0 0 1px rgba(255, 255, 255, 0.12);
23+
background: #000;
24+
cursor: grab;
25+
transition: box-shadow 0.2s;
26+
27+
&:hover {
28+
box-shadow:
29+
0 8px 40px rgba(0, 0, 0, 0.7),
30+
0 0 0 1px rgba(255, 255, 255, 0.2);
31+
}
32+
33+
&:active {
34+
cursor: grabbing;
35+
}
36+
37+
video {
38+
width: 100%;
39+
height: 100%;
40+
object-fit: cover;
41+
pointer-events: none;
42+
}
43+
}
44+
45+
.pipClose {
1246
position: absolute;
13-
top: 0;
14-
left: 0;
15-
width: 100%;
16-
height: 100%;
17-
object-fit: cover;
18-
z-index: 0;
19-
pointer-events: none;
47+
top: 8px;
48+
right: 8px;
49+
width: 24px;
50+
height: 24px;
51+
border-radius: 50%;
52+
border: none;
53+
background: rgba(0, 0, 0, 0.5);
54+
backdrop-filter: blur(8px);
55+
color: rgba(255, 255, 255, 0.8);
56+
cursor: pointer;
57+
display: flex;
58+
align-items: center;
59+
justify-content: center;
60+
opacity: 0;
61+
transition: opacity 0.15s;
62+
63+
.videoPip:hover & {
64+
opacity: 1;
65+
}
66+
67+
&:hover {
68+
background: rgba(0, 0, 0, 0.7);
69+
color: #fff;
70+
}
2071
}
2172

2273
.desktop {

apps/webuiapps/src/components/Shell/index.tsx

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useCallback, useEffect, useSyncExternalStore } from 'react';
1+
import React, { useState, useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
22
import {
33
MessageCircle,
44
Twitter,
@@ -14,6 +14,7 @@ import {
1414
Radio,
1515
Video,
1616
VideoOff,
17+
X,
1718
type LucideIcon,
1819
} from 'lucide-react';
1920
import ChatPanel from '../ChatPanel';
@@ -70,18 +71,59 @@ const Shell: React.FC = () => {
7071
const [chatOpen, setChatOpen] = useState(true);
7172
const [reportEnabled, setReportEnabled] = useState(true);
7273
const [lang, setLang] = useState<'en' | 'zh'>('en');
73-
const [liveWallpaper, setLiveWallpaper] = useState(true);
74+
const [liveWallpaper, setLiveWallpaper] = useState(false);
7475
// eslint-disable-next-line @typescript-eslint/no-unused-vars
7576
const [wallpaper, setWallpaper] = useState(VIDEO_WALLPAPER);
77+
const [pipPos, setPipPos] = useState<{ x: number; y: number } | null>(null);
78+
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(
79+
null,
80+
);
81+
const pipRef = useRef<HTMLDivElement>(null);
82+
const barRef = useRef<HTMLDivElement>(null);
7683
const windows = useWindows();
7784

78-
const activeWallpaper = liveWallpaper
79-
? wallpaper
80-
: isVideoUrl(wallpaper)
81-
? STATIC_WALLPAPER
82-
: wallpaper;
85+
const bgWallpaper = isVideoUrl(wallpaper) ? STATIC_WALLPAPER : wallpaper;
8386
const showVideo = liveWallpaper && isVideoUrl(wallpaper);
8487

88+
const PIP_W = 200;
89+
const PIP_H = 280;
90+
91+
useEffect(() => {
92+
if (!pipPos && barRef.current) {
93+
const bar = barRef.current.getBoundingClientRect();
94+
const barCenterX = bar.left + bar.width / 2;
95+
setPipPos({
96+
x: barCenterX - PIP_W / 2,
97+
y: bar.top - PIP_H - 16,
98+
});
99+
}
100+
}, [pipPos]);
101+
102+
const handlePipMouseDown = useCallback(
103+
(e: React.MouseEvent) => {
104+
if ((e.target as HTMLElement).closest('button') || !pipPos) return;
105+
e.preventDefault();
106+
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pipPos.x, origY: pipPos.y };
107+
const onMove = (ev: MouseEvent) => {
108+
if (!dragRef.current) return;
109+
const dx = ev.clientX - dragRef.current.startX;
110+
const dy = ev.clientY - dragRef.current.startY;
111+
setPipPos({
112+
x: Math.max(0, Math.min(window.innerWidth - PIP_W, dragRef.current.origX + dx)),
113+
y: Math.max(0, Math.min(window.innerHeight - PIP_H, dragRef.current.origY + dy)),
114+
});
115+
};
116+
const onUp = () => {
117+
dragRef.current = null;
118+
window.removeEventListener('mousemove', onMove);
119+
window.removeEventListener('mouseup', onUp);
120+
};
121+
window.addEventListener('mousemove', onMove);
122+
window.addEventListener('mouseup', onUp);
123+
},
124+
[pipPos],
125+
);
126+
85127
const handleToggleReport = useCallback(() => {
86128
setReportEnabled((prev) => {
87129
const next = !prev;
@@ -115,18 +157,25 @@ const Shell: React.FC = () => {
115157
<div
116158
className={styles.shell}
117159
data-testid="shell"
118-
style={
119-
activeWallpaper && !showVideo
120-
? {
121-
backgroundImage: `url(${activeWallpaper})`,
122-
backgroundSize: 'cover',
123-
backgroundPosition: 'center',
124-
}
125-
: undefined
126-
}
160+
style={{
161+
backgroundImage: `url(${bgWallpaper})`,
162+
backgroundSize: 'cover',
163+
backgroundPosition: 'center',
164+
}}
127165
>
128-
{showVideo && (
129-
<video className={styles.videoBg} src={wallpaper} autoPlay loop muted playsInline />
166+
{showVideo && pipPos && (
167+
<div
168+
ref={pipRef}
169+
className={styles.videoPip}
170+
style={{ left: pipPos.x, top: pipPos.y, bottom: 'auto' }}
171+
onMouseDown={handlePipMouseDown}
172+
data-testid="video-pip"
173+
>
174+
<video src={wallpaper} autoPlay loop muted playsInline />
175+
<button className={styles.pipClose} onClick={() => setLiveWallpaper(false)} title="Close">
176+
<X size={14} />
177+
</button>
178+
</div>
130179
)}
131180
{/* Desktop with app icons */}
132181
<div className={styles.desktop} data-testid="desktop">
@@ -162,7 +211,7 @@ const Shell: React.FC = () => {
162211
{/* Chat Panel — always mounted to preserve chat history */}
163212
<ChatPanel onClose={() => setChatOpen(false)} visible={chatOpen} />
164213

165-
<div className={`${styles.bottomBar} ${chatOpen ? styles.chatOpen : ''}`}>
214+
<div ref={barRef} className={`${styles.bottomBar} ${chatOpen ? styles.chatOpen : ''}`}>
166215
<button
167216
className={`${styles.barBtn} ${liveWallpaper ? styles.liveOn : styles.liveOff}`}
168217
onClick={() => setLiveWallpaper((prev) => !prev)}

0 commit comments

Comments
 (0)