Skip to content

Commit 0a664b9

Browse files
committed
add group tournament pip
1 parent 66b201a commit 0a664b9

3 files changed

Lines changed: 309 additions & 2 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from "react";
2+
import { render, waitFor } from "@testing-library/react";
3+
import "@testing-library/jest-dom";
4+
import PictureInPicture, { copyStyles } from "../widgets/components/PictureInPicture";
5+
6+
describe("PictureInPicture component", () => {
7+
let mockPipWindow;
8+
9+
beforeEach(() => {
10+
mockPipWindow = {
11+
document: {
12+
body: {
13+
appendChild: jest.fn(),
14+
style: {},
15+
},
16+
createElement: jest.fn().mockImplementation((tag) => {
17+
return document.createElement(tag);
18+
}),
19+
},
20+
addEventListener: jest.fn(),
21+
close: jest.fn(),
22+
};
23+
24+
window.documentPictureInPicture = {
25+
requestWindow: jest.fn().mockResolvedValue(mockPipWindow),
26+
};
27+
});
28+
29+
afterEach(() => {
30+
delete window.documentPictureInPicture;
31+
});
32+
33+
test("does not render when isActive is false", () => {
34+
const { container } = render(
35+
<PictureInPicture isActive={false} onClose={jest.fn()}>
36+
<div>Timer Content</div>
37+
</PictureInPicture>
38+
);
39+
40+
expect(container).toBeEmptyDOMElement();
41+
expect(window.documentPictureInPicture.requestWindow).not.toHaveBeenCalled();
42+
});
43+
44+
test("opens pip window and renders children via portal when isActive is true", async () => {
45+
const onCloseMock = jest.fn();
46+
47+
render(
48+
<PictureInPicture isActive={true} onClose={onCloseMock}>
49+
<div data-testid="pip-child">Timer Content</div>
50+
</PictureInPicture>
51+
);
52+
53+
await waitFor(() => {
54+
expect(window.documentPictureInPicture.requestWindow).toHaveBeenCalled();
55+
});
56+
57+
expect(mockPipWindow.document.body.appendChild).toHaveBeenCalled();
58+
});
59+
60+
test("closes pip window on unmount", async () => {
61+
const { unmount } = render(
62+
<PictureInPicture isActive={true} onClose={jest.fn()}>
63+
<div>Timer Content</div>
64+
</PictureInPicture>
65+
);
66+
67+
await waitFor(() => {
68+
expect(window.documentPictureInPicture.requestWindow).toHaveBeenCalled();
69+
});
70+
71+
unmount();
72+
expect(mockPipWindow.close).toHaveBeenCalled();
73+
});
74+
75+
test("calls onClose when requestWindow fails", async () => {
76+
window.documentPictureInPicture.requestWindow.mockRejectedValue(new Error("Permission denied"));
77+
const onCloseMock = jest.fn();
78+
79+
render(
80+
<PictureInPicture isActive={true} onClose={onCloseMock}>
81+
<div>Timer Content</div>
82+
</PictureInPicture>
83+
);
84+
85+
await waitFor(() => {
86+
expect(onCloseMock).toHaveBeenCalled();
87+
});
88+
});
89+
90+
test("copyStyles copies styleSheets correctly", () => {
91+
const sourceDoc = {
92+
styleSheets: [
93+
{
94+
cssRules: [{ cssText: ".test-rule { color: red; }" }],
95+
},
96+
{
97+
href: "http://example.com/styles.css",
98+
},
99+
],
100+
};
101+
102+
const targetDoc = {
103+
createElement: jest.fn().mockImplementation((tag) => {
104+
return {
105+
appendChild: jest.fn(),
106+
appendChildNode: jest.fn(),
107+
};
108+
}),
109+
createTextNode: jest.fn().mockImplementation((text) => text),
110+
head: {
111+
appendChild: jest.fn(),
112+
},
113+
};
114+
115+
copyStyles(sourceDoc, targetDoc);
116+
117+
expect(targetDoc.createElement).toHaveBeenCalledWith("style");
118+
expect(targetDoc.createElement).toHaveBeenCalledWith("link");
119+
expect(targetDoc.head.appendChild).toHaveBeenCalled();
120+
});
121+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useState, useEffect, useRef } from "react";
2+
import { createPortal } from "react-dom";
3+
4+
export function copyStyles(sourceDoc, targetDoc) {
5+
Array.from(sourceDoc.styleSheets).forEach((styleSheet) => {
6+
try {
7+
if (styleSheet.cssRules) {
8+
// Inline styles or same-origin stylesheets
9+
const newStyleEl = targetDoc.createElement("style");
10+
for (const rule of styleSheet.cssRules) {
11+
newStyleEl.appendChild(targetDoc.createTextNode(rule.cssText));
12+
}
13+
targetDoc.head.appendChild(newStyleEl);
14+
} else if (styleSheet.href) {
15+
// External stylesheets
16+
const newLinkEl = targetDoc.createElement("link");
17+
newLinkEl.rel = "stylesheet";
18+
newLinkEl.href = styleSheet.href;
19+
targetDoc.head.appendChild(newLinkEl);
20+
}
21+
} catch (e) {
22+
// Fallback for CORS-protected stylesheets
23+
if (styleSheet.href) {
24+
const newLinkEl = targetDoc.createElement("link");
25+
newLinkEl.rel = "stylesheet";
26+
newLinkEl.href = styleSheet.href;
27+
targetDoc.head.appendChild(newLinkEl);
28+
}
29+
}
30+
});
31+
}
32+
33+
const PictureInPicture = ({
34+
isActive,
35+
onClose,
36+
children,
37+
width = 300,
38+
height = 150,
39+
}) => {
40+
const [container, setContainer] = useState(null);
41+
const pipWindowRef = useRef(null);
42+
43+
useEffect(() => {
44+
if (!isActive) {
45+
return;
46+
}
47+
48+
const startPip = async () => {
49+
if (typeof window === "undefined" || !window.documentPictureInPicture) {
50+
console.warn("Document Picture-in-Picture API is not supported in this browser.");
51+
onClose();
52+
return;
53+
}
54+
55+
if (pipWindowRef.current) return;
56+
57+
try {
58+
const pw = await window.documentPictureInPicture.requestWindow({
59+
width,
60+
height,
61+
});
62+
63+
// Setup base styles to avoid white flash or weird scrollbars
64+
pw.document.body.style.backgroundColor = "#151515";
65+
pw.document.body.style.margin = "0";
66+
pw.document.body.style.display = "flex";
67+
pw.document.body.style.flexDirection = "column";
68+
pw.document.body.style.height = "100vh";
69+
pw.document.body.style.justifyContent = "center";
70+
pw.document.body.style.alignItems = "center";
71+
pw.document.body.style.overflow = "hidden";
72+
73+
// Copy parent styles
74+
copyStyles(document, pw.document);
75+
76+
const pipContainer = pw.document.createElement("div");
77+
pipContainer.id = "pip-root";
78+
pipContainer.className = "w-100 h-100 d-flex flex-column align-items-center justify-content-center";
79+
pw.document.body.appendChild(pipContainer);
80+
81+
pw.addEventListener("pagehide", () => {
82+
pipWindowRef.current = null;
83+
setContainer(null);
84+
onClose();
85+
});
86+
87+
pipWindowRef.current = pw;
88+
setContainer(pipContainer);
89+
} catch (err) {
90+
console.error("Failed to open Document PiP window:", err);
91+
onClose();
92+
}
93+
};
94+
95+
startPip();
96+
97+
return () => {
98+
if (pipWindowRef.current) {
99+
pipWindowRef.current.close();
100+
pipWindowRef.current = null;
101+
}
102+
setContainer(null);
103+
};
104+
}, [isActive, onClose, width, height]);
105+
106+
if (!isActive || !container) {
107+
return null;
108+
}
109+
110+
return createPortal(children, container);
111+
};
112+
113+
export default PictureInPicture;

apps/codebattle/assets/js/widgets/pages/groupTournament/Header.jsx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React from "react";
1+
import React, { useState, useEffect } from "react";
22
import moment from "moment";
3+
import PictureInPicture from "@/components/PictureInPicture";
34
import i18n from "../../../i18n";
45
import useTimer from "../../utils/useTimer";
56
import { isOnBreak } from "../../utils/groupTournament";
@@ -127,15 +128,77 @@ function TournamentTimer({ groupTournament }) {
127128
);
128129
}
129130

131+
function PipTimerContent({ status, groupTournament }) {
132+
const isWaiting = status === "waiting_participants";
133+
134+
if (status === "finished" || groupTournament?.state === "finished") {
135+
return (
136+
<span
137+
className="border border-secondary bg-secondary text-white rounded-pill px-4 py-2 font-weight-bold"
138+
style={{ fontSize: "1.5rem" }}
139+
>
140+
{i18n.t("Finished")}
141+
</span>
142+
);
143+
}
144+
145+
const isTimerActive = !isWaiting && groupTournament?.state === "active";
146+
147+
return (
148+
<div className="d-flex flex-column align-items-center justify-content-center text-center p-2">
149+
{isWaiting ? (
150+
<WaitingStartTimer startsAt={groupTournament?.startsAt} />
151+
) : isTimerActive ? (
152+
<TournamentTimer groupTournament={groupTournament} />
153+
) : (
154+
<span
155+
className="text-monospace rounded-pill px-4 py-2 border border-warning text-warning"
156+
style={{ fontSize: "1.5rem" }}
157+
>
158+
{i18n.t(statusBadge[status]?.labelKey || "Group Tournament")}
159+
</span>
160+
)}
161+
</div>
162+
);
163+
}
164+
130165
function Header({ name, status, groupTournament }) {
166+
const [isPipActive, setIsPipActive] = useState(false);
131167
const badge = statusBadge[status] || statusBadge.loading;
132168
const isWaiting = status === "waiting_participants";
169+
const isPipSupported = typeof window !== "undefined" && "documentPictureInPicture" in window;
170+
171+
useEffect(() => {
172+
if (!isPipSupported) return;
173+
174+
const handleVisibilityChange = () => {
175+
if (document.visibilityState === "hidden") {
176+
setIsPipActive(true);
177+
}
178+
};
179+
180+
const handleInteraction = () => {
181+
if (document.visibilityState === "visible") {
182+
setIsPipActive(false);
183+
}
184+
};
185+
186+
document.addEventListener("visibilitychange", handleVisibilityChange);
187+
document.addEventListener("click", handleInteraction);
188+
document.addEventListener("keydown", handleInteraction);
189+
190+
return () => {
191+
document.removeEventListener("visibilitychange", handleVisibilityChange);
192+
document.removeEventListener("click", handleInteraction);
193+
document.removeEventListener("keydown", handleInteraction);
194+
};
195+
}, [isPipSupported]);
133196

134197
return (
135198
<div className="cb-custom-event-profile d-flex align-items-center w-100 position-relative">
136199
<h4 className="mb-0 mr-3 text-white">{name || i18n.t("Group Tournament")}</h4>
137200
<div
138-
className="position-absolute"
201+
className="position-absolute d-flex align-items-center"
139202
style={{ left: "50%", top: "50%", transform: "translate(-50%, -50%)" }}
140203
>
141204
{isWaiting ? (
@@ -158,6 +221,16 @@ function Header({ name, status, groupTournament }) {
158221
{i18n.t("Back to event")}
159222
</a>
160223
</div>
224+
{isPipSupported && (
225+
<PictureInPicture
226+
isActive={isPipActive}
227+
onClose={() => setIsPipActive(false)}
228+
width={320}
229+
height={150}
230+
>
231+
<PipTimerContent status={status} groupTournament={groupTournament} />
232+
</PictureInPicture>
233+
)}
161234
</div>
162235
);
163236
}

0 commit comments

Comments
 (0)