Compare commits
2 Commits
a586f3b491
...
1e5e2c682c
Author | SHA1 | Date | |
---|---|---|---|
1e5e2c682c | |||
f4fe180fb1 |
@ -44,7 +44,7 @@
|
||||
height: 3.75rem;
|
||||
min-width: 3.5rem;
|
||||
min-height: 1.8725rem;
|
||||
z-index: 1200;
|
||||
z-index: 12000; /* Above Hand and other MUI elements */
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@ -78,6 +78,7 @@
|
||||
z-index: 1251; /* Above the Indicators but below active move handles */
|
||||
background-color: rgba(64, 64, 64, 64);
|
||||
backdrop-filter: blur(5px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.MediaControl:hover .Controls {
|
||||
@ -97,6 +98,7 @@
|
||||
flex-direction: column;
|
||||
pointer-events: none; /* non-interactive */
|
||||
align-items: flex-start;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.Indicators .IndicatorRow {
|
||||
@ -108,7 +110,7 @@
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
padding: 0.12rem;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -143,6 +145,31 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Invert highlight behavior for control buttons:
|
||||
- Buttons are transparent by default so they don't visually obscure the video when not hovered.
|
||||
- On hover/focus (keyboard or pointer) they show a light background to indicate interactivity.
|
||||
- Scope to MUI IconButton root class to avoid affecting other controls.
|
||||
*/
|
||||
.MediaControl .Controls .MuiIconButton-root {
|
||||
background: transparent;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
padding: 0.25rem;
|
||||
margin: 0.08rem;
|
||||
}
|
||||
.MediaControl .Controls .MuiIconButton-root:hover,
|
||||
.MediaControl .Controls .MuiIconButton-root:focus {
|
||||
background: rgba(255,255,255,0.5); /* subtle light highlight on hover/focus */
|
||||
}
|
||||
.MediaControl .Controls .MuiIconButton-root:active {
|
||||
background: rgba(255,255,255,0.18);
|
||||
}
|
||||
|
||||
/* Ensure accessible focus indicator for keyboard users */
|
||||
.MediaControl .Controls .MuiIconButton-root:focus-visible {
|
||||
outline: 2px solid rgba(255,255,255,0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.moveable-control-box {
|
||||
border: none;
|
||||
--moveable-color: unset !important;
|
||||
|
@ -1338,6 +1338,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
const spacerRef = useRef<HTMLDivElement>(null);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const indicatorsRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const moveableRef = useRef<any>(null);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
// Controls expansion state for hover/tap compact mode
|
||||
@ -1508,6 +1509,43 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [muted, peer?.attributes?.srcObject, peer?.dead, peer]);
|
||||
|
||||
// Attach remote stream to a hidden <audio> element so audio can be played/unmuted
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
const audioEl = audioRef.current;
|
||||
if (!peer || peer.dead || !peer.attributes?.srcObject) {
|
||||
// Clear srcObject when no media
|
||||
try {
|
||||
audioEl.pause();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
(audioEl as any).srcObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = peer.attributes.srcObject as MediaStream;
|
||||
(audioEl as any).srcObject = stream;
|
||||
// Ensure audio element muted state matches our UI muted flag (local consumption mute)
|
||||
audioEl.muted = !!(peer.local || muted);
|
||||
|
||||
// Try to play - if browser blocks autoplay, this will be allowed when user interacts
|
||||
audioEl
|
||||
.play()
|
||||
.then(() => console.log(`media-agent - audio element playing for ${peer.peer_name}`))
|
||||
.catch((err) => console.log(`media-agent - audio play blocked for ${peer.peer_name}:`, err));
|
||||
|
||||
return () => {
|
||||
try {
|
||||
audioEl.pause();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
(audioEl as any).srcObject = null;
|
||||
};
|
||||
// We intentionally depend on muted so the audio element updates when user toggles mute
|
||||
}, [peer?.attributes?.srcObject, peer?.dead, peer?.local, muted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!peer || peer.dead || !peer.attributes?.srcObject) return;
|
||||
|
||||
@ -1540,6 +1578,21 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If we have an attached audio element (remote or local copy), update it and try to play when unmuting.
|
||||
try {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !!(peer.local || newMutedState);
|
||||
if (!audioRef.current.muted) {
|
||||
audioRef.current.play().catch((err) => console.log("media-agent - play error:", err));
|
||||
} else {
|
||||
// When muting, pause to stop consuming resources
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("media-agent - toggleMute audioRef handling failed:", err);
|
||||
}
|
||||
},
|
||||
[peer, muted, videoOn, sendJsonMessage, isSelf]
|
||||
);
|
||||
@ -1700,42 +1753,40 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
|
||||
{remoteAudioMuted && <MicOff />}
|
||||
{muted ? <VolumeOff /> : <VolumeUp />}
|
||||
{videoOn ? <Videocam /> : <VideocamOff />}
|
||||
</>
|
||||
)}
|
||||
{!isSelf && (
|
||||
<>
|
||||
{remoteAudioMuted && <MicOff color="warning" />}
|
||||
{remoteVideoOff && <VideocamOff color="warning" />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Interactive controls: rendered inside target but referenced separately */}
|
||||
<Box className="Controls" ref={controlsRef}>
|
||||
{isSelf ? (
|
||||
<IconButton onClick={toggleMute}>
|
||||
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}
|
||||
</IconButton>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "row", gap: 0, alignItems: "center", p: 0, m: 0 }}>
|
||||
<IconButton onClick={toggleMute}>
|
||||
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
|
||||
</IconButton>
|
||||
{remoteAudioMuted && <MicOff color="warning" />}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 0,
|
||||
alignItems: "center",
|
||||
p: 0,
|
||||
m: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={toggleVideo}>
|
||||
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
|
||||
</IconButton>
|
||||
{!isSelf && remoteVideoOff && <VideocamOff color="warning" />}
|
||||
</Box>
|
||||
<Box
|
||||
className="Controls"
|
||||
ref={controlsRef}
|
||||
sx={{ display: "flex", flexDirection: "row", justifyItems: "center" }}
|
||||
>
|
||||
<IconButton onClick={toggleMute}>
|
||||
{isSelf ? (
|
||||
muted ? (
|
||||
<MicOff color={colorAudio} />
|
||||
) : (
|
||||
<Mic color={colorAudio} />
|
||||
)
|
||||
) : muted ? (
|
||||
<VolumeOff color={colorAudio} />
|
||||
) : (
|
||||
<VolumeUp color={colorAudio} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton onClick={toggleVideo}>
|
||||
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
{isValid ? (
|
||||
peer.attributes?.srcObject && (
|
||||
@ -1750,6 +1801,14 @@ const MediaControl: React.FC<MediaControlProps> = ({
|
||||
muted={peer.local || muted}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
{/* Hidden audio element to ensure audio playback/unmute works reliably */}
|
||||
<audio
|
||||
ref={(el) => {
|
||||
audioRef.current = el;
|
||||
}}
|
||||
style={{ display: "none" }}
|
||||
// Important: playsInline not standard on audio but keep attributes minimal
|
||||
/>
|
||||
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
|
||||
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
|
||||
</>
|
||||
|
41
client/src/NameSetter.css
Normal file
41
client/src/NameSetter.css
Normal file
@ -0,0 +1,41 @@
|
||||
/* Styles for NameSetter component when no name is set */
|
||||
.NameSetter-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.NameSetter-titleWrapper {
|
||||
flex-grow: 1;
|
||||
z-index: 50; /* behind dialog which MUI renders with higher z-index */
|
||||
}
|
||||
|
||||
.NameSetter-titleImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
/* width: 100%; */
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* When name is not set, position the dialog 10% from bottom and center it horizontally */
|
||||
.NameSetter-dialogPaperNoName {
|
||||
position: fixed !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
bottom: 10vh !important; /* 10% from bottom of viewport */
|
||||
margin: 0 !important;
|
||||
max-width: 600px;
|
||||
width: min(90%, 600px);
|
||||
z-index: 100; /* above the title image */
|
||||
}
|
||||
|
||||
/* Keep the dialog title content centered when overlaying the image */
|
||||
.NameSetter-dialogTitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Ensure the input doesn't stretch full width too much when overlaying */
|
||||
.NameSetter-nameInput {
|
||||
text-align: center;
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import React, { useState, KeyboardEvent, useRef, useContext } from "react";
|
||||
import "./NameSetter.css";
|
||||
import pokTitle from "./assets/pok-title.png";
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
@ -9,6 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { GlobalContext, Session } from "./GlobalContext";
|
||||
|
||||
@ -48,6 +51,9 @@ const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", in
|
||||
event.preventDefault();
|
||||
if (passwordInputRef.current) {
|
||||
passwordInputRef.current.focus();
|
||||
} else {
|
||||
// No password input present — submit the name directly
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -67,51 +73,40 @@ const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", in
|
||||
setName(newName);
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setEditName(session.name || "");
|
||||
setEditPassword("");
|
||||
setShowDialog(true);
|
||||
// Focus the name input when dialog opens
|
||||
setTimeout(() => {
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setShowDialog(false);
|
||||
setEditName("");
|
||||
setEditPassword("");
|
||||
};
|
||||
|
||||
const hasNameChanged = editName.trim() !== (session.name || "");
|
||||
const canSubmit = editName.trim() && hasNameChanged && !isSubmitting;
|
||||
|
||||
return (
|
||||
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
{session.name && !showDialog && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography>You are logged in as: {session.name}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={handleOpenDialog}>
|
||||
Change Name
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Dialog for name change */}
|
||||
<Dialog open={showDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{session.name ? "Change Your Name" : "Enter Your Name"}</DialogTitle>
|
||||
<Box
|
||||
className="NameSetter-root"
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
maxWidth: "800px",
|
||||
maxHeight: "100dvh",
|
||||
width: "100%",
|
||||
height: "100dvh",
|
||||
margin: "auto",
|
||||
backgroundImage: `url(${pokTitle})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "top center",
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "10%",
|
||||
width: "calc(100% - 2rem)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.8) !important",
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="NameSetter-dialogTitle">
|
||||
{session.name ? "Change Your Name" : "How shall you be called?"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{session.name ? "Enter a new name to change your current name." : "Enter your name to join the lobby."}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
You can optionally set a password to reserve this name; supply it again to takeover the name from another
|
||||
client.
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
inputRef={nameInputRef}
|
||||
type="text"
|
||||
@ -120,40 +115,36 @@ const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", in
|
||||
setEditName(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
placeholder="Your name"
|
||||
placeholder="Your quipy handle"
|
||||
fullWidth
|
||||
autoFocus
|
||||
className="NameSetter-nameInput"
|
||||
/>
|
||||
|
||||
<Input
|
||||
inputRef={passwordInputRef}
|
||||
type="password"
|
||||
value={editPassword}
|
||||
onChange={(e): void => setEditPassword(e.target.value)}
|
||||
onKeyDown={handlePasswordKeyDown}
|
||||
placeholder="Optional password"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Tooltip title="Optional: choose a short password to reserve this name. Keep it secret.">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog} disabled={isSubmitting}>
|
||||
{/* <Button onClick={handleCloseDialog} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
color={hasNameChanged ? "primary" : "inherit"}
|
||||
sx={
|
||||
hasNameChanged
|
||||
? {
|
||||
backgroundColor: "primary.main",
|
||||
color: "#444",
|
||||
"&:hover": { backgroundColor: "primary.dark", color: "#fff" },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Changing..." : session.name ? "Change Name" : "Join"}
|
||||
{isSubmitting ? "Changing..." : session.name ? "Change Name" : "Enter Ketran!"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -195,7 +195,6 @@ const PlayerList: React.FC = () => {
|
||||
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
|
||||
<>
|
||||
<MediaControl
|
||||
sx={{ border: "3px solid blue" }}
|
||||
className="Medium"
|
||||
key={player.session_id}
|
||||
peer={peerObj}
|
||||
|
@ -93,7 +93,7 @@
|
||||
justify-content: space-between;
|
||||
width: 25rem;
|
||||
max-width: 25rem;
|
||||
z-index: 5000;
|
||||
z-index: 11500;
|
||||
}
|
||||
|
||||
.RoomView .Sidebar .Chat {
|
||||
|
@ -305,9 +305,7 @@ const RoomView = (props: RoomProps) => {
|
||||
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}>
|
||||
<div className="RoomView">
|
||||
{!name ? (
|
||||
<Paper>
|
||||
<NameSetter />
|
||||
</Paper>
|
||||
<NameSetter />
|
||||
) : (
|
||||
<>
|
||||
<div className="ActivitiesBox">
|
||||
|
BIN
client/src/assets/pok-title.png
Executable file
BIN
client/src/assets/pok-title.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 2.4 MiB |
@ -6,7 +6,7 @@ import { getVictoryPointRule } from "./rules";
|
||||
|
||||
export const addActivity = (game: Game, session: Session | null, message: string): void => {
|
||||
let date = Date.now();
|
||||
if (!game.activities) game.activities = [] as any[];
|
||||
if (!game.activities) game.activities = [];
|
||||
if (game.activities.length && game.activities[game.activities.length - 1].date === date) {
|
||||
date++;
|
||||
}
|
||||
@ -20,7 +20,7 @@ export const addActivity = (game: Game, session: Session | null, message: string
|
||||
export const addChatMessage = (game: Game, session: Session | null, message: string, isNormalChat?: boolean) => {
|
||||
let now = Date.now();
|
||||
let lastTime = 0;
|
||||
if (!game.chat) game.chat = [] as any[];
|
||||
if (!game.chat) game.chat = [];
|
||||
if (game.chat.length) {
|
||||
lastTime = game.chat[game.chat.length - 1].date;
|
||||
}
|
||||
@ -136,15 +136,8 @@ export const getPrevPlayerSession = (game: Game, name: string): Session | undefi
|
||||
};
|
||||
|
||||
export const clearPlayer = (player: Player) => {
|
||||
const color = player.color;
|
||||
for (let key in player) {
|
||||
// delete all runtime fields
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete (player as any)[key];
|
||||
}
|
||||
|
||||
// Use shared factory to ensure a single source of defaults
|
||||
Object.assign(player, newPlayer(color || ""));
|
||||
Object.assign(player, newPlayer(player.color));
|
||||
};
|
||||
|
||||
export const canGiveBuilding = (game: Game): string | undefined => {
|
||||
@ -176,14 +169,22 @@ export const setForSettlementPlacement = (game: Game, limits: number[]): void =>
|
||||
export const adjustResources = (player: Player, deltas: Partial<Record<string, number>>): void => {
|
||||
if (!player) return;
|
||||
let total = player.resources || 0;
|
||||
const keys = Object.keys(deltas || {});
|
||||
keys.forEach((k) => {
|
||||
const v = deltas[k] || 0;
|
||||
const keys = Object.keys(deltas);
|
||||
keys.forEach((type) => {
|
||||
const v = deltas[type] || 0;
|
||||
// update named resource slot if present
|
||||
try {
|
||||
const current = (player as any)[k] || 0;
|
||||
(player as any)[k] = current + v;
|
||||
total += v;
|
||||
switch (type) {
|
||||
case "wood":
|
||||
case "brick":
|
||||
case "sheep":
|
||||
case "wheat":
|
||||
case "stone":
|
||||
const current = player[type] || 0;
|
||||
player[type] = current + v;
|
||||
total += v;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore unexpected keys
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user