1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
1e5e2c682c Audio/Video indicators are getting closer 2025-10-11 00:24:07 -07:00
f4fe180fb1 Game almost launches 2025-10-11 00:00:11 -07:00
9 changed files with 227 additions and 111 deletions

View File

@ -44,7 +44,7 @@
height: 3.75rem; height: 3.75rem;
min-width: 3.5rem; min-width: 3.5rem;
min-height: 1.8725rem; min-height: 1.8725rem;
z-index: 1200; z-index: 12000; /* Above Hand and other MUI elements */
border-radius: 0.25rem; border-radius: 0.25rem;
} }
@ -78,6 +78,7 @@
z-index: 1251; /* Above the Indicators but below active move handles */ z-index: 1251; /* Above the Indicators but below active move handles */
background-color: rgba(64, 64, 64, 64); background-color: rgba(64, 64, 64, 64);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
opacity: 0.85;
} }
.MediaControl:hover .Controls { .MediaControl:hover .Controls {
@ -97,6 +98,7 @@
flex-direction: column; flex-direction: column;
pointer-events: none; /* non-interactive */ pointer-events: none; /* non-interactive */
align-items: flex-start; align-items: flex-start;
opacity: 0.5;
} }
.Indicators .IndicatorRow { .Indicators .IndicatorRow {
@ -108,7 +110,7 @@
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
padding: 0.12rem; padding: 0.12rem;
border-radius: 999px; 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; color: white;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -143,6 +145,31 @@
cursor: pointer; 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 { .moveable-control-box {
border: none; border: none;
--moveable-color: unset !important; --moveable-color: unset !important;

View File

@ -1338,6 +1338,7 @@ const MediaControl: React.FC<MediaControlProps> = ({
const spacerRef = useRef<HTMLDivElement>(null); const spacerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null); const controlsRef = useRef<HTMLDivElement>(null);
const indicatorsRef = useRef<HTMLDivElement>(null); const indicatorsRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const moveableRef = useRef<any>(null); const moveableRef = useRef<any>(null);
const [isDragging, setIsDragging] = useState<boolean>(false); const [isDragging, setIsDragging] = useState<boolean>(false);
// Controls expansion state for hover/tap compact mode // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [muted, peer?.attributes?.srcObject, peer?.dead, peer]); }, [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(() => { useEffect(() => {
if (!peer || peer.dead || !peer.attributes?.srcObject) return; 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] [peer, muted, videoOn, sendJsonMessage, isSelf]
); );
@ -1700,42 +1753,40 @@ const MediaControl: React.FC<MediaControlProps> = ({
</> </>
) : ( ) : (
<> <>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />} {muted ? <VolumeOff /> : <VolumeUp />}
{remoteAudioMuted && <MicOff />}
{videoOn ? <Videocam /> : <VideocamOff />} {videoOn ? <Videocam /> : <VideocamOff />}
</> </>
)} )}
{!isSelf && (
<>
{remoteAudioMuted && <MicOff color="warning" />}
{remoteVideoOff && <VideocamOff color="warning" />}
</>
)}
</Box> </Box>
{/* Interactive controls: rendered inside target but referenced separately */} {/* Interactive controls: rendered inside target but referenced separately */}
<Box className="Controls" ref={controlsRef}> <Box
{isSelf ? ( className="Controls"
<IconButton onClick={toggleMute}> ref={controlsRef}
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />} sx={{ display: "flex", flexDirection: "row", justifyItems: "center" }}
</IconButton> >
) : ( <IconButton onClick={toggleMute}>
<Box sx={{ display: "flex", flexDirection: "row", gap: 0, alignItems: "center", p: 0, m: 0 }}> {isSelf ? (
<IconButton onClick={toggleMute}> muted ? (
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />} <MicOff color={colorAudio} />
</IconButton> ) : (
{remoteAudioMuted && <MicOff color="warning" />} <Mic color={colorAudio} />
</Box> )
)} ) : muted ? (
<Box <VolumeOff color={colorAudio} />
sx={{ ) : (
display: "flex", <VolumeUp color={colorAudio} />
flexDirection: "row", )}
gap: 0, </IconButton>
alignItems: "center", <IconButton onClick={toggleVideo}>
p: 0, {videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
m: 0, </IconButton>
}}
>
<IconButton onClick={toggleVideo}>
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
</IconButton>
{!isSelf && remoteVideoOff && <VideocamOff color="warning" />}
</Box>
</Box> </Box>
{isValid ? ( {isValid ? (
peer.attributes?.srcObject && ( peer.attributes?.srcObject && (
@ -1750,6 +1801,14 @@ const MediaControl: React.FC<MediaControlProps> = ({
muted={peer.local || muted} muted={peer.local || muted}
onDoubleClick={handleDoubleClick} 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} />
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} /> <WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
</> </>

41
client/src/NameSetter.css Normal file
View 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;
}

View File

@ -1,4 +1,6 @@
import React, { useState, KeyboardEvent, useRef, useContext } from "react"; import React, { useState, KeyboardEvent, useRef, useContext } from "react";
import "./NameSetter.css";
import pokTitle from "./assets/pok-title.png";
import { import {
Input, Input,
Button, Button,
@ -9,6 +11,7 @@ import {
DialogTitle, DialogTitle,
DialogContent, DialogContent,
DialogActions, DialogActions,
Paper,
} from "@mui/material"; } from "@mui/material";
import { GlobalContext, Session } from "./GlobalContext"; import { GlobalContext, Session } from "./GlobalContext";
@ -48,6 +51,9 @@ const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", in
event.preventDefault(); event.preventDefault();
if (passwordInputRef.current) { if (passwordInputRef.current) {
passwordInputRef.current.focus(); 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); 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 hasNameChanged = editName.trim() !== (session.name || "");
const canSubmit = editName.trim() && hasNameChanged && !isSubmitting; const canSubmit = editName.trim() && hasNameChanged && !isSubmitting;
return ( return (
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}> <Box
{session.name && !showDialog && ( className="NameSetter-root"
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> sx={{
<Typography>You are logged in as: {session.name}</Typography> display: "flex",
<Button variant="outlined" size="small" onClick={handleOpenDialog}> flexDirection: "column",
Change Name alignItems: "center",
</Button> maxWidth: "800px",
</Box> maxHeight: "100dvh",
)} width: "100%",
height: "100dvh",
{/* Dialog for name change */} margin: "auto",
<Dialog open={showDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth> backgroundImage: `url(${pokTitle})`,
<DialogTitle>{session.name ? "Change Your Name" : "Enter Your Name"}</DialogTitle> 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> <DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}> <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 <Input
inputRef={nameInputRef} inputRef={nameInputRef}
type="text" type="text"
@ -120,40 +115,36 @@ const NameSetter: React.FC<NameSetterProps> = ({ onNameSet, initialName = "", in
setEditName(e.target.value); setEditName(e.target.value);
}} }}
onKeyDown={handleNameKeyDown} onKeyDown={handleNameKeyDown}
placeholder="Your name" placeholder="Your quipy handle"
fullWidth fullWidth
autoFocus 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> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog} disabled={isSubmitting}> {/* <Button onClick={handleCloseDialog} disabled={isSubmitting}>
Cancel Cancel
</Button> </Button> */}
<Button <Button
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
color={hasNameChanged ? "primary" : "inherit"} 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> </Button>
</DialogActions> </DialogActions>
</Dialog> </Paper>
</Box> </Box>
); );
}; };

View File

@ -195,7 +195,6 @@ const PlayerList: React.FC = () => {
{player.name && player.live && peerObj && (player.local || player.has_media !== false) ? ( {player.name && player.live && peerObj && (player.local || player.has_media !== false) ? (
<> <>
<MediaControl <MediaControl
sx={{ border: "3px solid blue" }}
className="Medium" className="Medium"
key={player.session_id} key={player.session_id}
peer={peerObj} peer={peerObj}

View File

@ -93,7 +93,7 @@
justify-content: space-between; justify-content: space-between;
width: 25rem; width: 25rem;
max-width: 25rem; max-width: 25rem;
z-index: 5000; z-index: 11500;
} }
.RoomView .Sidebar .Chat { .RoomView .Sidebar .Chat {

View File

@ -305,9 +305,7 @@ const RoomView = (props: RoomProps) => {
<GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}> <GlobalContext.Provider value={{ socketUrl, session, name, roomName, sendJsonMessage, lastJsonMessage, readyState }}>
<div className="RoomView"> <div className="RoomView">
{!name ? ( {!name ? (
<Paper> <NameSetter />
<NameSetter />
</Paper>
) : ( ) : (
<> <>
<div className="ActivitiesBox"> <div className="ActivitiesBox">

BIN
client/src/assets/pok-title.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -6,7 +6,7 @@ import { getVictoryPointRule } from "./rules";
export const addActivity = (game: Game, session: Session | null, message: string): void => { export const addActivity = (game: Game, session: Session | null, message: string): void => {
let date = Date.now(); 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) { if (game.activities.length && game.activities[game.activities.length - 1].date === 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) => { export const addChatMessage = (game: Game, session: Session | null, message: string, isNormalChat?: boolean) => {
let now = Date.now(); let now = Date.now();
let lastTime = 0; let lastTime = 0;
if (!game.chat) game.chat = [] as any[]; if (!game.chat) game.chat = [];
if (game.chat.length) { if (game.chat.length) {
lastTime = game.chat[game.chat.length - 1].date; 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) => { 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 // 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 => { 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 => { export const adjustResources = (player: Player, deltas: Partial<Record<string, number>>): void => {
if (!player) return; if (!player) return;
let total = player.resources || 0; let total = player.resources || 0;
const keys = Object.keys(deltas || {}); const keys = Object.keys(deltas);
keys.forEach((k) => { keys.forEach((type) => {
const v = deltas[k] || 0; const v = deltas[type] || 0;
// update named resource slot if present // update named resource slot if present
try { try {
const current = (player as any)[k] || 0; switch (type) {
(player as any)[k] = current + v; case "wood":
total += v; case "brick":
case "sheep":
case "wheat":
case "stone":
const current = player[type] || 0;
player[type] = current + v;
total += v;
break;
}
} catch (e) { } catch (e) {
// ignore unexpected keys // ignore unexpected keys
} }