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;
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;

View File

@ -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,
}}
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>
{!isSelf && remoteVideoOff && <VideocamOff color="warning" />}
</Box>
</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
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 "./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>
);
};

View File

@ -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}

View File

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

View File

@ -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>
) : (
<>
<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 => {
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;
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
}