commit
42f4d0fdfa
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "owlbear-rodeo",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^1.12.1",
|
||||
@ -31,7 +31,7 @@
|
||||
"react-spring": "^8.0.27",
|
||||
"react-use-gesture": "^7.0.15",
|
||||
"shortid": "^2.2.15",
|
||||
"simple-peer": "^9.6.2",
|
||||
"simple-peer": "feross/simple-peer#694/head",
|
||||
"simplebar-react": "^2.1.0",
|
||||
"simplify-js": "^1.2.4",
|
||||
"socket.io-client": "^2.3.0",
|
||||
|
@ -3,7 +3,7 @@ import { Box } from "theme-ui";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
function LoadingOverlay() {
|
||||
function LoadingOverlay(bg) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@ -17,11 +17,15 @@ function LoadingOverlay() {
|
||||
left: 0,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
bg="muted"
|
||||
bg={bg}
|
||||
>
|
||||
<Spinner />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
LoadingOverlay.defaultProps = {
|
||||
bg: "muted",
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
|
@ -30,20 +30,14 @@ function StyledModal({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
{allowClose && (
|
||||
<Close
|
||||
m={0}
|
||||
sx={{ position: "absolute", top: 0, right: 0 }}
|
||||
onClick={onRequestClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
{allowClose && (
|
||||
<Close
|
||||
m={0}
|
||||
sx={{ position: "absolute", top: 0, right: 0 }}
|
||||
onClick={onRequestClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import { Group } from "react-konva";
|
||||
|
||||
import MapControls from "./MapControls";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
@ -32,6 +33,7 @@ function Map({
|
||||
onFogDrawRedo,
|
||||
allowMapDrawing,
|
||||
allowFogDrawing,
|
||||
allowMapChange,
|
||||
disabledTokens,
|
||||
}) {
|
||||
const { tokensById } = useContext(TokenDataContext);
|
||||
@ -141,6 +143,9 @@ function Map({
|
||||
if (!allowFogDrawing) {
|
||||
disabledControls.push("fog");
|
||||
}
|
||||
if (!allowMapChange) {
|
||||
disabledControls.push("map");
|
||||
}
|
||||
|
||||
const disabledSettings = { fog: [], drawing: [] };
|
||||
if (mapShapes.length === 0) {
|
||||
@ -204,29 +209,39 @@ function Map({
|
||||
}
|
||||
}
|
||||
|
||||
const mapTokens =
|
||||
mapState &&
|
||||
Object.values(mapState.tokens)
|
||||
.sort(sortMapTokenStates)
|
||||
.map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokensById[tokenState.tokenId]}
|
||||
tokenState={tokenState}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
onTokenDragStart={(e) =>
|
||||
setDraggingTokenOptions({ tokenState, tokenImage: e.target })
|
||||
}
|
||||
onTokenDragEnd={() => setDraggingTokenOptions(null)}
|
||||
draggable={
|
||||
(selectedToolId === "pan" || selectedToolId === "erase") &&
|
||||
!(tokenState.id in disabledTokens)
|
||||
}
|
||||
mapState={mapState}
|
||||
/>
|
||||
));
|
||||
const mapTokens = mapState && (
|
||||
<Group>
|
||||
{Object.values(mapState.tokens)
|
||||
.sort(sortMapTokenStates)
|
||||
.map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokensById[tokenState.tokenId]}
|
||||
tokenState={tokenState}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
onTokenDragStart={(e) =>
|
||||
setDraggingTokenOptions({
|
||||
dragging: true,
|
||||
tokenState,
|
||||
tokenGroup: e.target,
|
||||
})
|
||||
}
|
||||
onTokenDragEnd={() =>
|
||||
setDraggingTokenOptions({
|
||||
...draggingTokenOptions,
|
||||
dragging: false,
|
||||
})
|
||||
}
|
||||
draggable={
|
||||
selectedToolId === "pan" && !(tokenState.id in disabledTokens)
|
||||
}
|
||||
mapState={mapState}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const tokenMenu = (
|
||||
<TokenMenu
|
||||
@ -246,7 +261,8 @@ function Map({
|
||||
}}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
|
||||
tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
|
||||
tokenGroup={draggingTokenOptions && draggingTokenOptions.tokenGroup}
|
||||
dragging={draggingTokenOptions && draggingTokenOptions.dragging}
|
||||
token={tokensById[draggingTokenOptions.tokenState.tokenId]}
|
||||
mapState={mapState}
|
||||
/>
|
||||
|
@ -67,6 +67,7 @@ function MapContols({
|
||||
onMapStateChange={onMapStateChange}
|
||||
currentMap={currentMap}
|
||||
currentMapState={currentMapState}
|
||||
disabled={disabledControls.includes("map")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -8,8 +8,7 @@ import useDataSource from "../../helpers/useDataSource";
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
import { getStrokeWidth } from "../../helpers/drawing";
|
||||
|
||||
const lightnessDetectionOffset = 0.1;
|
||||
import { getImageLightness } from "../../helpers/image";
|
||||
|
||||
function MapGrid({ map, gridSize }) {
|
||||
const mapSource = useDataSource(map, defaultMapSources);
|
||||
@ -28,38 +27,7 @@ function MapGrid({ map, gridSize }) {
|
||||
// When the map changes find the average lightness of its pixels
|
||||
useEffect(() => {
|
||||
if (mapLoadingStatus === "loaded") {
|
||||
const imageWidth = mapImage.width;
|
||||
const imageHeight = mapImage.height;
|
||||
let canvas = document.createElement("canvas");
|
||||
canvas.width = imageWidth;
|
||||
canvas.height = imageHeight;
|
||||
let context = canvas.getContext("2d");
|
||||
context.drawImage(mapImage, 0, 0);
|
||||
const imageData = context.getImageData(0, 0, imageWidth, imageHeight);
|
||||
|
||||
const data = imageData.data;
|
||||
let lightPixels = 0;
|
||||
let darkPixels = 0;
|
||||
// Loop over every pixels rgba values
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
const max = Math.max(Math.max(r, g), b);
|
||||
if (max < 128) {
|
||||
darkPixels++;
|
||||
} else {
|
||||
lightPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (lightPixels - darkPixels) / (imageWidth * imageHeight);
|
||||
if (norm + lightnessDetectionOffset < 0) {
|
||||
setIsImageLight(false);
|
||||
} else {
|
||||
setIsImageLight(true);
|
||||
}
|
||||
setIsImageLight(getImageLightness(mapImage));
|
||||
}
|
||||
}, [mapImage, mapLoadingStatus]);
|
||||
|
||||
|
@ -30,8 +30,38 @@ function MapInteraction({
|
||||
onSelectedToolChange,
|
||||
disabledControls,
|
||||
}) {
|
||||
const mapSource = useDataSource(map, defaultMapSources);
|
||||
const [mapSourceImage] = useImage(mapSource);
|
||||
let mapSourceMap = map;
|
||||
if (map && map.type === "file") {
|
||||
if (
|
||||
map.resolutions &&
|
||||
map.quality !== "original" &&
|
||||
map.resolutions[map.quality]
|
||||
) {
|
||||
mapSourceMap = map.resolutions[map.quality];
|
||||
}
|
||||
}
|
||||
|
||||
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
|
||||
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
|
||||
|
||||
// Create a map source that only updates when the image is fully loaded
|
||||
const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
|
||||
useEffect(() => {
|
||||
if (mapSourceImageStatus === "loaded") {
|
||||
setLoadedMapSourceImage(mapSourceImage);
|
||||
}
|
||||
}, [mapSourceImage, mapSourceImageStatus]);
|
||||
|
||||
// Map loaded taking in to account different resolutions
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (map === null) {
|
||||
setMapLoaded(false);
|
||||
}
|
||||
if (mapSourceImageStatus === "loaded") {
|
||||
setMapLoaded(true);
|
||||
}
|
||||
}, [mapSourceImageStatus, map]);
|
||||
|
||||
const [stageWidth, setStageWidth] = useState(1);
|
||||
const [stageHeight, setStageHeight] = useState(1);
|
||||
@ -46,7 +76,7 @@ function MapInteraction({
|
||||
// Reset transform when map changes
|
||||
useEffect(() => {
|
||||
const layer = mapLayerRef.current;
|
||||
if (map && layer) {
|
||||
if (map && layer && !mapLoaded) {
|
||||
const mapHeight = stageWidthRef.current * (map.height / map.width);
|
||||
const newTranslate = {
|
||||
x: 0,
|
||||
@ -59,7 +89,7 @@ function MapInteraction({
|
||||
|
||||
setStageScale(1);
|
||||
}
|
||||
}, [map]);
|
||||
}, [map, mapLoaded]);
|
||||
|
||||
const pinchPreviousDistanceRef = useRef();
|
||||
const pinchPreviousOriginRef = useRef();
|
||||
@ -167,45 +197,85 @@ function MapInteraction({
|
||||
stageHeightRef.current = height;
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
// Change to pan tool when pressing space
|
||||
if (event.key === " " && selectedToolId === "pan") {
|
||||
// Stop active state on pan icon from being selected
|
||||
event.preventDefault();
|
||||
}
|
||||
if (
|
||||
event.key === " " &&
|
||||
selectedToolId !== "pan" &&
|
||||
!disabledControls.includes("pan")
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousSelectedToolRef.current = selectedToolId;
|
||||
onSelectedToolChange("pan");
|
||||
// Added key events to interaction emitter
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
// Ignore text input
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
return;
|
||||
}
|
||||
interactionEmitter.emit("keyDown", event);
|
||||
}
|
||||
|
||||
// Basic keyboard shortcuts
|
||||
if (event.key === "w" && !disabledControls.includes("pan")) {
|
||||
onSelectedToolChange("pan");
|
||||
}
|
||||
if (event.key === "d" && !disabledControls.includes("drawing")) {
|
||||
onSelectedToolChange("drawing");
|
||||
}
|
||||
if (event.key === "f" && !disabledControls.includes("fog")) {
|
||||
onSelectedToolChange("fog");
|
||||
}
|
||||
if (event.key === "m" && !disabledControls.includes("measure")) {
|
||||
onSelectedToolChange("measure");
|
||||
function handleKeyUp(event) {
|
||||
// Ignore text input
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
return;
|
||||
}
|
||||
interactionEmitter.emit("keyUp", event);
|
||||
}
|
||||
|
||||
interactionEmitter.emit("keyDown", event);
|
||||
}
|
||||
document.body.addEventListener("keydown", handleKeyDown);
|
||||
document.body.addEventListener("keyup", handleKeyUp);
|
||||
document.body.tabIndex = 1;
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.removeEventListener("keyup", handleKeyUp);
|
||||
document.body.tabIndex = 0;
|
||||
};
|
||||
}, [interactionEmitter]);
|
||||
|
||||
function handleKeyUp(event) {
|
||||
if (event.key === " " && selectedToolId === "pan") {
|
||||
onSelectedToolChange(previousSelectedToolRef.current);
|
||||
// Create default keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
// Change to pan tool when pressing space
|
||||
if (event.key === " " && selectedToolId === "pan") {
|
||||
// Stop active state on pan icon from being selected
|
||||
event.preventDefault();
|
||||
}
|
||||
if (
|
||||
event.key === " " &&
|
||||
selectedToolId !== "pan" &&
|
||||
!disabledControls.includes("pan")
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousSelectedToolRef.current = selectedToolId;
|
||||
onSelectedToolChange("pan");
|
||||
}
|
||||
|
||||
// Basic keyboard shortcuts
|
||||
if (event.key === "w" && !disabledControls.includes("pan")) {
|
||||
onSelectedToolChange("pan");
|
||||
}
|
||||
if (event.key === "d" && !disabledControls.includes("drawing")) {
|
||||
onSelectedToolChange("drawing");
|
||||
}
|
||||
if (event.key === "f" && !disabledControls.includes("fog")) {
|
||||
onSelectedToolChange("fog");
|
||||
}
|
||||
if (event.key === "m" && !disabledControls.includes("measure")) {
|
||||
onSelectedToolChange("measure");
|
||||
}
|
||||
}
|
||||
interactionEmitter.emit("keyUp", event);
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
if (event.key === " " && selectedToolId === "pan") {
|
||||
onSelectedToolChange(previousSelectedToolRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
interactionEmitter.on("keyDown", handleKeyDown);
|
||||
interactionEmitter.on("keyUp", handleKeyUp);
|
||||
return () => {
|
||||
interactionEmitter.off("keyDown", handleKeyDown);
|
||||
interactionEmitter.off("keyUp", handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
interactionEmitter,
|
||||
onSelectedToolChange,
|
||||
disabledControls,
|
||||
selectedToolId,
|
||||
]);
|
||||
|
||||
function getCursorForTool(tool) {
|
||||
switch (tool) {
|
||||
@ -254,9 +324,6 @@ function MapInteraction({
|
||||
ref={containerRef}
|
||||
{...bind()}
|
||||
className="map"
|
||||
tabIndex={1}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
||||
<Stage
|
||||
@ -270,7 +337,7 @@ function MapInteraction({
|
||||
>
|
||||
<Layer ref={mapLayerRef}>
|
||||
<Image
|
||||
image={mapSourceImage}
|
||||
image={mapLoaded && loadedMapSourceImage}
|
||||
width={mapWidth}
|
||||
height={mapHeight}
|
||||
id="mapImage"
|
||||
@ -280,7 +347,7 @@ function MapInteraction({
|
||||
<AuthContext.Provider value={auth}>
|
||||
<MapInteractionProvider value={mapInteraction}>
|
||||
<MapStageProvider value={mapStageRef}>
|
||||
{children}
|
||||
{mapLoaded && children}
|
||||
</MapStageProvider>
|
||||
</MapInteractionProvider>
|
||||
</AuthContext.Provider>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useContext, useEffect, useRef } from "react";
|
||||
import { Box, Progress } from "theme-ui";
|
||||
|
||||
import Spinner from "../Spinner";
|
||||
import MapLoadingContext from "../../contexts/MapLoadingContext";
|
||||
|
||||
function MapLoadingOverlay() {
|
||||
@ -36,18 +35,16 @@ function MapLoadingOverlay() {
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
top: 0,
|
||||
left: 0,
|
||||
left: "8px",
|
||||
bottom: "8px",
|
||||
flexDirection: "column",
|
||||
borderRadius: "28px",
|
||||
}}
|
||||
bg="muted"
|
||||
bg="overlay"
|
||||
>
|
||||
<Spinner />
|
||||
<Progress
|
||||
ref={progressBarRef}
|
||||
max={1}
|
||||
|
@ -79,13 +79,7 @@ function MapMenu({
|
||||
}}
|
||||
contentRef={handleModalContent}
|
||||
>
|
||||
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
|
||||
<div
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
@ -15,6 +15,14 @@ import { isEmpty } from "../../helpers/shared";
|
||||
|
||||
import Divider from "../Divider";
|
||||
|
||||
const qualitySettings = [
|
||||
{ id: "low", name: "Low" },
|
||||
{ id: "medium", name: "Medium" },
|
||||
{ id: "high", name: "High" },
|
||||
{ id: "ultra", name: "Ultra High" },
|
||||
{ id: "original", name: "Original" },
|
||||
];
|
||||
|
||||
function MapSettings({
|
||||
map,
|
||||
mapState,
|
||||
@ -34,6 +42,17 @@ function MapSettings({
|
||||
}
|
||||
}
|
||||
|
||||
function getMapSize() {
|
||||
let size = 0;
|
||||
if (map.quality === "original") {
|
||||
size = map.file.length;
|
||||
} else {
|
||||
size = map.resolutions[map.quality].file.length;
|
||||
}
|
||||
size /= 1000000; // Bytes to Megabytes
|
||||
return `${size.toFixed(2)}MB`;
|
||||
}
|
||||
|
||||
const mapEmpty = !map || isEmpty(map);
|
||||
const mapStateEmpty = !mapState || isEmpty(mapState);
|
||||
|
||||
@ -81,7 +100,11 @@ function MapSettings({
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
<Flex my={2} sx={{ alignItems: "center" }}>
|
||||
<Flex
|
||||
mt={2}
|
||||
mb={map.type === "default" ? 2 : 0}
|
||||
sx={{ alignItems: "center" }}
|
||||
>
|
||||
<Box sx={{ width: "50%" }}>
|
||||
<Label>Grid Type</Label>
|
||||
<Select
|
||||
@ -102,6 +125,35 @@ function MapSettings({
|
||||
Show Grid
|
||||
</Label>
|
||||
</Flex>
|
||||
{!mapEmpty && map.type !== "default" && (
|
||||
<Flex my={2} sx={{ alignItems: "center" }}>
|
||||
<Box sx={{ width: "50%" }}>
|
||||
<Label>Quality</Label>
|
||||
<Select
|
||||
my={1}
|
||||
value={!mapEmpty && map.quality}
|
||||
disabled={mapEmpty}
|
||||
onChange={(e) => onSettingsChange("quality", e.target.value)}
|
||||
>
|
||||
{qualitySettings.map((quality) => (
|
||||
<option
|
||||
key={quality.id}
|
||||
value={quality.id}
|
||||
disabled={
|
||||
quality.id !== "original" &&
|
||||
!map.resolutions[quality.id]
|
||||
}
|
||||
>
|
||||
{quality.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
<Label sx={{ width: "50%" }} ml={2}>
|
||||
Size: {getMapSize()}
|
||||
</Label>
|
||||
</Flex>
|
||||
)}
|
||||
<Divider fill />
|
||||
<Box my={2} sx={{ flexGrow: 1 }}>
|
||||
<Label>Allow Others to Edit</Label>
|
||||
|
@ -17,9 +17,18 @@ function MapTile({
|
||||
onMapReset,
|
||||
onDone,
|
||||
}) {
|
||||
const mapSource = useDataSource(map, defaultMapSources, unknownSource);
|
||||
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
|
||||
const isDefault = map.type === "default";
|
||||
const mapSource = useDataSource(
|
||||
isDefault
|
||||
? map
|
||||
: map.resolutions && map.resolutions.low
|
||||
? map.resolutions.low
|
||||
: map,
|
||||
defaultMapSources,
|
||||
unknownSource
|
||||
);
|
||||
|
||||
const hasMapState =
|
||||
mapState &&
|
||||
(Object.values(mapState.tokens).length > 0 ||
|
||||
|
@ -45,15 +45,15 @@ function MapToken({
|
||||
}, [tokenSourceImage]);
|
||||
|
||||
function handleDragStart(event) {
|
||||
const tokenImage = event.target;
|
||||
const tokenImageRect = tokenImage.getClientRect();
|
||||
const tokenGroup = event.target;
|
||||
const tokenImage = imageRef.current;
|
||||
|
||||
if (token && token.isVehicle) {
|
||||
// Find all other tokens on the map
|
||||
const layer = tokenImage.getLayer();
|
||||
const layer = tokenGroup.getLayer();
|
||||
const tokens = layer.find(".token");
|
||||
for (let other of tokens) {
|
||||
if (other === tokenImage) {
|
||||
if (other === tokenGroup) {
|
||||
continue;
|
||||
}
|
||||
const otherRect = other.getClientRect();
|
||||
@ -61,16 +61,10 @@ function MapToken({
|
||||
x: otherRect.x + otherRect.width / 2,
|
||||
y: otherRect.y + otherRect.height / 2,
|
||||
};
|
||||
// Check the other tokens center overlaps this tokens bounding box
|
||||
if (
|
||||
otherCenter.x > tokenImageRect.x &&
|
||||
otherCenter.x < tokenImageRect.x + tokenImageRect.width &&
|
||||
otherCenter.y > tokenImageRect.y &&
|
||||
otherCenter.y < tokenImageRect.y + tokenImageRect.height
|
||||
) {
|
||||
if (tokenImage.intersects(otherCenter)) {
|
||||
// Save and restore token position after moving layer
|
||||
const position = other.absolutePosition();
|
||||
other.moveTo(tokenImage);
|
||||
other.moveTo(tokenGroup);
|
||||
other.absolutePosition(position);
|
||||
}
|
||||
}
|
||||
@ -80,16 +74,16 @@ function MapToken({
|
||||
}
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const tokenImage = event.target;
|
||||
const tokenGroup = event.target;
|
||||
|
||||
const mountChanges = {};
|
||||
if (token && token.isVehicle) {
|
||||
const layer = tokenImage.getLayer();
|
||||
const mountedTokens = tokenImage.find(".token");
|
||||
const parent = tokenGroup.getParent();
|
||||
const mountedTokens = tokenGroup.find(".token");
|
||||
for (let mountedToken of mountedTokens) {
|
||||
// Save and restore token position after moving layer
|
||||
const position = mountedToken.absolutePosition();
|
||||
mountedToken.moveTo(layer);
|
||||
mountedToken.moveTo(parent);
|
||||
mountedToken.absolutePosition(position);
|
||||
mountChanges[mountedToken.id()] = {
|
||||
...mapState.tokens[mountedToken.id()],
|
||||
@ -105,8 +99,8 @@ function MapToken({
|
||||
...mountChanges,
|
||||
[tokenState.id]: {
|
||||
...tokenState,
|
||||
x: tokenImage.x() / mapWidth,
|
||||
y: tokenImage.y() / mapHeight,
|
||||
x: tokenGroup.x() / mapWidth,
|
||||
y: tokenGroup.y() / mapHeight,
|
||||
lastEditedBy: userId,
|
||||
},
|
||||
});
|
||||
|
@ -11,6 +11,7 @@ function SelectMapButton({
|
||||
onMapStateChange,
|
||||
currentMap,
|
||||
currentMapState,
|
||||
disabled,
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
@ -30,6 +31,7 @@ function SelectMapButton({
|
||||
aria-label="Select Map"
|
||||
title="Select Map"
|
||||
onClick={openModal}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectMapIcon />
|
||||
</IconButton>
|
||||
|
@ -13,8 +13,9 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
||||
if (selectedTool) {
|
||||
setCollapsedTool(selectedTool);
|
||||
} else {
|
||||
setCollapsedTool(
|
||||
(prevTool) => prevTool && { ...prevTool, isSelected: false }
|
||||
// No selected tool, deselect if we have a tool or get the first tool if not
|
||||
setCollapsedTool((prevTool) =>
|
||||
prevTool ? { ...prevTool, isSelected: false } : tools[0]
|
||||
);
|
||||
}
|
||||
}, [tools]);
|
||||
|
@ -1,21 +1,23 @@
|
||||
import React from "react";
|
||||
import { Text } from "theme-ui";
|
||||
import { Text, Flex } from "theme-ui";
|
||||
|
||||
import Stream from "./Stream";
|
||||
|
||||
function Nickname({ nickname, stream }) {
|
||||
return (
|
||||
<Text
|
||||
as="p"
|
||||
my={1}
|
||||
variant="body2"
|
||||
sx={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{nickname}
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text
|
||||
as="p"
|
||||
my={1}
|
||||
variant="body2"
|
||||
sx={{
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{nickname}
|
||||
</Text>
|
||||
{stream && <Stream stream={stream} nickname={nickname} />}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Text, IconButton, Box } from "theme-ui";
|
||||
import { Text, IconButton, Box, Slider, Flex } from "theme-ui";
|
||||
|
||||
import StreamMuteIcon from "../../icons/StreamMuteIcon";
|
||||
|
||||
import Banner from "../Banner";
|
||||
|
||||
function Stream({ stream, nickname }) {
|
||||
const [streamMuted, setStreamMuted] = useState(false);
|
||||
const [streamVolume, setStreamVolume] = useState(1);
|
||||
const [showStreamInteractBanner, setShowStreamInteractBanner] = useState(
|
||||
false
|
||||
);
|
||||
const audioRef = useRef();
|
||||
const streamMuted = streamVolume === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
@ -21,7 +24,7 @@ function Stream({ stream, nickname }) {
|
||||
})
|
||||
.catch(() => {
|
||||
// Unable to autoplay
|
||||
setStreamMuted(true);
|
||||
setStreamVolume(0);
|
||||
setShowStreamInteractBanner(true);
|
||||
});
|
||||
}
|
||||
@ -31,49 +34,85 @@ function Stream({ stream, nickname }) {
|
||||
if (audioRef.current) {
|
||||
if (streamMuted) {
|
||||
audioRef.current.play().then(() => {
|
||||
setStreamMuted(false);
|
||||
setStreamVolume(1);
|
||||
setShowStreamInteractBanner(false);
|
||||
});
|
||||
} else {
|
||||
setStreamMuted(true);
|
||||
setStreamVolume(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleVolumeChange(event) {
|
||||
const volume = parseFloat(event.target.value);
|
||||
setStreamVolume(volume);
|
||||
}
|
||||
|
||||
// Use an audio context gain node to control volume to go past 100%
|
||||
const audioGainRef = useRef();
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
let audioContext = new AudioContext();
|
||||
let source = audioContext.createMediaStreamSource(stream);
|
||||
let gainNode = audioContext.createGain();
|
||||
gainNode.gain.value = 0;
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
audioGainRef.current = gainNode;
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
// Platforms like iOS don't allow you to control audio volume
|
||||
// Detect this by trying to change the audio volume
|
||||
const [isVolumeControlAvailable, setIsVolumeControlAvailable] = useState(
|
||||
true
|
||||
);
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
const prevVolume = audioRef.current.volume;
|
||||
audioRef.current.volume = 0.5;
|
||||
setIsVolumeControlAvailable(audioRef.current.volume !== 0.5);
|
||||
audioRef.current.volume = prevVolume;
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioGainRef.current && audioRef.current) {
|
||||
if (streamVolume <= 1) {
|
||||
audioGainRef.current.gain.value = 0;
|
||||
audioRef.current.volume = streamVolume;
|
||||
} else {
|
||||
audioRef.current.volume = 1;
|
||||
audioGainRef.current.gain.value = (streamVolume - 1) * 2;
|
||||
}
|
||||
}
|
||||
}, [streamVolume]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
sx={{
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
padding: 0,
|
||||
marginLeft: "2px",
|
||||
position: "absolute",
|
||||
bottom: "-2px",
|
||||
}}
|
||||
aria-label={streamMuted ? "Unmute Player" : "Mute Player"}
|
||||
title={streamMuted ? "Unmute Player" : "Mute Player"}
|
||||
onClick={() => {
|
||||
if (stream) {
|
||||
toggleMute();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
fill="currentcolor"
|
||||
<Flex sx={{ alignItems: "center", height: "24px" }}>
|
||||
<IconButton
|
||||
aria-label={streamMuted ? "Unmute Player" : "Mute Player"}
|
||||
title={streamMuted ? "Unmute Player" : "Mute Player"}
|
||||
onClick={() => {
|
||||
if (stream) {
|
||||
toggleMute();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{streamMuted ? (
|
||||
<path d="M3.63 3.63c-.39.39-.39 1.02 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" />
|
||||
) : (
|
||||
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z" />
|
||||
)}
|
||||
</svg>
|
||||
</IconButton>
|
||||
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
|
||||
<StreamMuteIcon muted={streamMuted} />
|
||||
</IconButton>
|
||||
{isVolumeControlAvailable && (
|
||||
<Slider
|
||||
value={streamVolume}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={handleVolumeChange}
|
||||
/>
|
||||
)}
|
||||
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
|
||||
</Flex>
|
||||
<Banner
|
||||
isOpen={showStreamInteractBanner}
|
||||
onRequestClose={() => setShowStreamInteractBanner(false)}
|
||||
|
@ -11,7 +11,8 @@ function TokenDragOverlay({
|
||||
onTokenStateChange,
|
||||
token,
|
||||
tokenState,
|
||||
tokenImage,
|
||||
tokenGroup,
|
||||
dragging,
|
||||
mapState,
|
||||
}) {
|
||||
const { userId } = useContext(AuthContext);
|
||||
@ -28,7 +29,7 @@ function TokenDragOverlay({
|
||||
const mapRect = map.getBoundingClientRect();
|
||||
|
||||
function detectRemoveHover() {
|
||||
const pointerPosition = tokenImage.getStage().getPointerPosition();
|
||||
const pointerPosition = tokenGroup.getStage().getPointerPosition();
|
||||
const screenSpacePointerPosition = {
|
||||
x: pointerPosition.x + mapRect.left,
|
||||
y: pointerPosition.y + mapRect.top,
|
||||
@ -50,7 +51,7 @@ function TokenDragOverlay({
|
||||
}
|
||||
|
||||
let handler;
|
||||
if (tokenState && tokenImage) {
|
||||
if (tokenState && tokenGroup && dragging) {
|
||||
handler = setInterval(detectRemoveHover, 100);
|
||||
}
|
||||
|
||||
@ -59,74 +60,62 @@ function TokenDragOverlay({
|
||||
clearInterval(handler);
|
||||
}
|
||||
};
|
||||
}, [tokenState, tokenImage, isRemoveHovered]);
|
||||
}, [tokenState, tokenGroup, isRemoveHovered, dragging]);
|
||||
|
||||
// Detect drag end of token image and remove it if it is over the remove icon
|
||||
useEffect(() => {
|
||||
function handleTokenDragEnd() {
|
||||
if (isRemoveHovered) {
|
||||
// Handle other tokens when a vehicle gets deleted
|
||||
if (token && token.isVehicle) {
|
||||
const layer = tokenImage.getLayer();
|
||||
const mountedTokens = tokenImage.find(".token");
|
||||
for (let mountedToken of mountedTokens) {
|
||||
// Save and restore token position after moving layer
|
||||
const position = mountedToken.absolutePosition();
|
||||
mountedToken.moveTo(layer);
|
||||
mountedToken.absolutePosition(position);
|
||||
onTokenStateChange({
|
||||
[mountedToken.id()]: {
|
||||
...mapState.tokens[mountedToken.id()],
|
||||
x: mountedToken.x() / mapWidth,
|
||||
y: mountedToken.y() / mapHeight,
|
||||
lastEditedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Handle other tokens when a vehicle gets deleted
|
||||
if (token && token.isVehicle) {
|
||||
const layer = tokenGroup.getLayer();
|
||||
const mountedTokens = tokenGroup.find(".token");
|
||||
for (let mountedToken of mountedTokens) {
|
||||
// Save and restore token position after moving layer
|
||||
const position = mountedToken.absolutePosition();
|
||||
mountedToken.moveTo(layer);
|
||||
mountedToken.absolutePosition(position);
|
||||
onTokenStateChange({
|
||||
[mountedToken.id()]: {
|
||||
...mapState.tokens[mountedToken.id()],
|
||||
x: mountedToken.x() / mapWidth,
|
||||
y: mountedToken.y() / mapHeight,
|
||||
lastEditedBy: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
onTokenStateRemove(tokenState);
|
||||
setPreventMapInteraction(false);
|
||||
}
|
||||
onTokenStateRemove(tokenState);
|
||||
setPreventMapInteraction(false);
|
||||
}
|
||||
tokenImage.on("dragend", handleTokenDragEnd);
|
||||
return () => {
|
||||
tokenImage.off("dragend", handleTokenDragEnd);
|
||||
};
|
||||
}, [
|
||||
tokenImage,
|
||||
token,
|
||||
tokenState,
|
||||
isRemoveHovered,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
userId,
|
||||
onTokenStateChange,
|
||||
onTokenStateRemove,
|
||||
setPreventMapInteraction,
|
||||
mapState.tokens,
|
||||
]);
|
||||
|
||||
if (!dragging && tokenState && isRemoveHovered) {
|
||||
handleTokenDragEnd();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "32px",
|
||||
left: "50%",
|
||||
borderRadius: "50%",
|
||||
transform: isRemoveHovered
|
||||
? "translateX(-50%) scale(2.0)"
|
||||
: "translateX(-50%) scale(1.5)",
|
||||
transition: "transform 250ms ease",
|
||||
color: isRemoveHovered ? "primary" : "text",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
bg="overlay"
|
||||
ref={removeTokenRef}
|
||||
>
|
||||
<IconButton>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
dragging && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "32px",
|
||||
left: "50%",
|
||||
borderRadius: "50%",
|
||||
transform: isRemoveHovered
|
||||
? "translateX(-50%) scale(2.0)"
|
||||
: "translateX(-50%) scale(1.5)",
|
||||
transition: "transform 250ms ease",
|
||||
color: isRemoveHovered ? "primary" : "text",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
bg="overlay"
|
||||
ref={removeTokenRef}
|
||||
>
|
||||
<IconButton>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,15 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Rect, Text, Group } from "react-konva";
|
||||
|
||||
const maxTokenSize = 3;
|
||||
|
||||
function TokenLabel({ tokenState, width, height }) {
|
||||
const fontSize = height / 6 / tokenState.size;
|
||||
const paddingY = height / 16 / tokenState.size;
|
||||
const paddingX = height / 8 / tokenState.size;
|
||||
const fontSize =
|
||||
(height / 6 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
||||
const paddingY =
|
||||
(height / 16 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
||||
const paddingX =
|
||||
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
||||
|
||||
const [rectWidth, setRectWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
|
@ -105,13 +105,12 @@ export function MapDataProvider({ children }) {
|
||||
}
|
||||
|
||||
async function updateMap(id, update) {
|
||||
const change = { ...update, lastModified: Date.now() };
|
||||
await database.table("maps").update(id, change);
|
||||
await database.table("maps").update(id, update);
|
||||
setMaps((prevMaps) => {
|
||||
const newMaps = [...prevMaps];
|
||||
const i = newMaps.findIndex((map) => map.id === id);
|
||||
if (i > -1) {
|
||||
newMaps[i] = { ...newMaps[i], ...change };
|
||||
newMaps[i] = { ...newMaps[i], ...update };
|
||||
}
|
||||
return newMaps;
|
||||
});
|
||||
|
@ -117,6 +117,18 @@ function loadVersions(db) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// v1.4.2 - Added map resolutions
|
||||
db.version(6)
|
||||
.stores({})
|
||||
.upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.resolutions = {};
|
||||
map.quality = "original";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get the dexie database used in DatabaseContext
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 109 KiB |
@ -24,7 +24,7 @@ To do this there is the Column and Row properties. Columns represents how many g
|
||||
|
||||
`Tip: Owlbear Rodeo can automatically fill the Column and Row properties for you if you include them in the file name of the uploaded map. E.g. River [10x15] will create a map named River with 10 columns and 15 rows`
|
||||
|
||||
`Note: When uploading a custom map keep the file size in mind. Maps are shared between users in Owlbear Rodeo so if a map is taking too long to load for other party members consider lowering the file size.`
|
||||
`Note: When uploading a custom map keep the file size in mind. Maps are shared between users in Owlbear Rodeo so if a map is taking too long to load for other party members consider changing the Quality option in the advanced map settings.`
|
||||
|
||||
## Custom Maps (Advanced)
|
||||
|
||||
@ -38,6 +38,7 @@ A brief summary of these settings is listed below.
|
||||
- Name: The name of the map shown in the Map Select Screen.
|
||||
- Grid Type: Change the type of grid to use for the map. Currently only the Square type is supported however Hex will be added in a future release.
|
||||
- Show Grid: When enabled Owlbear Rodeo will draw a grid on top of your map, this is useful if a custom map you have uploaded does't include a grid.
|
||||
- Quality: When uploading a map Owlbear Rodeo will automatically generate various quality options, selecting a lower quality may help speed up map sending in resource constrained environments.
|
||||
- Allow others to edit: These properties control what other party members can edit when viewing your map.
|
||||
- Fog: Controls whether others can edit the maps fog (default disabled).
|
||||
- Drawings: Controls whether others can add drawings to the map (default enabled).
|
||||
|
25
src/docs/releaseNotes/v1.4.2.md
Normal file
25
src/docs/releaseNotes/v1.4.2.md
Normal file
@ -0,0 +1,25 @@
|
||||
## Minor Changes
|
||||
|
||||
This release we're hoping to help with map load times to do this we added 2 new features:
|
||||
|
||||
1. Map and token sending now supports multiplexing which means you can now use the app while these elements load. This means that the old map and token loading screen that took up the whole map is now pushed to the bottom left of the map and doesn't prevent interaction.
|
||||
2. For any new map uploaded multiple resolutions are automatically generated. This helps in two ways. First, before sending the full quality map to each player a preview of the map is sent. This should hopefully lower the time in which players are waiting to see any part of the map. Second, in the advanced map settings you can now select from one of these generated resolutions with the Quality option. This should allow you to easily change the quality of the map if sending is taking too long.
|
||||
|
||||
A lot of the underlying network code was changed to support this so there may still be a few bugs that we haven't come across yet, so if there are any issues let us know.
|
||||
|
||||
Along with this there are a few bug fixes and small enhancements as well:
|
||||
|
||||
- Fixed a bug that caused the drawing tools to dissapear on smaller screens when changing tools.
|
||||
- Fixed keyboard shortcuts not working when interacting with elements other than the map.
|
||||
- Added a volume slider for audio sharing on platforms that support controlling audio.
|
||||
- Added a better function for determining which tokens are sitting on a token with the Vehicle / Mount option set.
|
||||
- Fixed a bug when double clicking a map to select it while also pressing the remove map button.
|
||||
- Fixed a bug where tokens couldn't get deleted when the cursor was on top of a fog shape.
|
||||
- Changed token labels to scale with the token. This is limited so that the label doesn't get too large on huge creatures.
|
||||
|
||||
[Reddit]()
|
||||
[Twitter]()
|
||||
|
||||
---
|
||||
|
||||
July 20 2020
|
132
src/helpers/Connection.js
Normal file
132
src/helpers/Connection.js
Normal file
@ -0,0 +1,132 @@
|
||||
import SimplePeer from "simple-peer";
|
||||
import { encode, decode } from "@msgpack/msgpack";
|
||||
import shortid from "shortid";
|
||||
|
||||
import blobToBuffer from "./blobToBuffer";
|
||||
|
||||
// Limit buffer size to 16kb to avoid issues with chrome packet size
|
||||
// http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
|
||||
const MAX_BUFFER_SIZE = 16000;
|
||||
|
||||
class Connection extends SimplePeer {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.currentChunks = {};
|
||||
this.dataChannels = {};
|
||||
this.on("data", this.handleData);
|
||||
this.on("datachannel", this.handleDataChannel);
|
||||
}
|
||||
|
||||
// Intercept the data event with decoding and chunking support
|
||||
handleData(packed) {
|
||||
const unpacked = decode(packed);
|
||||
// If the special property __chunked is set and true
|
||||
// The data is a partial chunk of the a larger file
|
||||
// So wait until all chunks are collected and assembled
|
||||
// before emitting the dataComplete event
|
||||
if (unpacked.__chunked) {
|
||||
let chunk = this.currentChunks[unpacked.id] || {
|
||||
data: [],
|
||||
count: 0,
|
||||
total: unpacked.total,
|
||||
};
|
||||
chunk.data[unpacked.index] = unpacked.data;
|
||||
chunk.count++;
|
||||
this.currentChunks[unpacked.id] = chunk;
|
||||
|
||||
this.emit("dataProgress", {
|
||||
id: unpacked.id,
|
||||
count: chunk.count,
|
||||
total: chunk.total,
|
||||
});
|
||||
|
||||
// All chunks have been loaded
|
||||
if (chunk.count === chunk.total) {
|
||||
// Merge chunks with a blob
|
||||
// TODO: Look at a more efficient way to recombine buffer data
|
||||
const merged = new Blob(chunk.data);
|
||||
blobToBuffer(merged).then((buffer) => {
|
||||
this.emit("dataComplete", decode(buffer));
|
||||
delete this.currentChunks[unpacked.id];
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.emit("dataComplete", unpacked);
|
||||
}
|
||||
}
|
||||
|
||||
// Override the send function with encoding, chunking and data channel support
|
||||
send(data, channel) {
|
||||
try {
|
||||
const packedData = encode(data);
|
||||
if (packedData.byteLength > MAX_BUFFER_SIZE) {
|
||||
const chunks = this.chunk(packedData);
|
||||
for (let chunk of chunks) {
|
||||
if (this.dataChannels[channel]) {
|
||||
this.dataChannels[channel].send(encode(chunk));
|
||||
} else {
|
||||
super.send(encode(chunk));
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (this.dataChannels[channel]) {
|
||||
this.dataChannels[channel].send(packedData);
|
||||
} else {
|
||||
super.send(packedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Override the create data channel function to store our own named reference to it
|
||||
// and to use our custom data handler
|
||||
createDataChannel(channelName, channelConfig, opts) {
|
||||
const channel = super.createDataChannel(channelName, channelConfig, opts);
|
||||
this.handleDataChannel(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
handleDataChannel(channel) {
|
||||
const channelName = channel.channelName;
|
||||
this.dataChannels[channelName] = channel;
|
||||
channel.on("data", this.handleData.bind(this));
|
||||
channel.on("error", (error) => {
|
||||
this.emit("error", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Converted from https://github.com/peers/peerjs/
|
||||
chunk(data) {
|
||||
const chunks = [];
|
||||
const size = data.byteLength;
|
||||
const total = Math.ceil(size / MAX_BUFFER_SIZE);
|
||||
const id = shortid.generate();
|
||||
|
||||
let index = 0;
|
||||
let start = 0;
|
||||
|
||||
while (start < size) {
|
||||
const end = Math.min(size, start + MAX_BUFFER_SIZE);
|
||||
const slice = data.slice(start, end);
|
||||
|
||||
const chunk = {
|
||||
__chunked: true,
|
||||
data: slice,
|
||||
id,
|
||||
index,
|
||||
total,
|
||||
};
|
||||
|
||||
chunks.push(chunk);
|
||||
start = end;
|
||||
index++;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
export default Connection;
|
@ -1,102 +0,0 @@
|
||||
import SimplePeer from "simple-peer";
|
||||
import { encode, decode } from "@msgpack/msgpack";
|
||||
import shortid from "shortid";
|
||||
|
||||
import blobToBuffer from "./blobToBuffer";
|
||||
|
||||
// Limit buffer size to 16kb to avoid issues with chrome packet size
|
||||
// http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
|
||||
const MAX_BUFFER_SIZE = 16000;
|
||||
|
||||
class Peer extends SimplePeer {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.currentChunks = {};
|
||||
|
||||
this.on("data", (packed) => {
|
||||
const unpacked = decode(packed);
|
||||
// If the special property __chunked is set and true
|
||||
// The data is a partial chunk of the a larger file
|
||||
// So wait until all chunks are collected and assembled
|
||||
// before emitting the dataComplete event
|
||||
if (unpacked.__chunked) {
|
||||
let chunk = this.currentChunks[unpacked.id] || {
|
||||
data: [],
|
||||
count: 0,
|
||||
total: unpacked.total,
|
||||
};
|
||||
chunk.data[unpacked.index] = unpacked.data;
|
||||
chunk.count++;
|
||||
this.currentChunks[unpacked.id] = chunk;
|
||||
|
||||
this.emit("dataProgress", {
|
||||
id: unpacked.id,
|
||||
count: chunk.count,
|
||||
total: chunk.total,
|
||||
});
|
||||
|
||||
// All chunks have been loaded
|
||||
if (chunk.count === chunk.total) {
|
||||
// Merge chunks with a blob
|
||||
// TODO: Look at a more efficient way to recombine buffer data
|
||||
const merged = new Blob(chunk.data);
|
||||
blobToBuffer(merged).then((buffer) => {
|
||||
this.emit("dataComplete", decode(buffer));
|
||||
delete this.currentChunks[unpacked.id];
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.emit("dataComplete", unpacked);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
send(data) {
|
||||
try {
|
||||
const packedData = encode(data);
|
||||
if (packedData.byteLength > MAX_BUFFER_SIZE) {
|
||||
const chunks = this.chunk(packedData);
|
||||
for (let chunk of chunks) {
|
||||
super.send(encode(chunk));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
super.send(packedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Converted from https://github.com/peers/peerjs/
|
||||
chunk(data) {
|
||||
const chunks = [];
|
||||
const size = data.byteLength;
|
||||
const total = Math.ceil(size / MAX_BUFFER_SIZE);
|
||||
const id = shortid.generate();
|
||||
|
||||
let index = 0;
|
||||
let start = 0;
|
||||
|
||||
while (start < size) {
|
||||
const end = Math.min(size, start + MAX_BUFFER_SIZE);
|
||||
const slice = data.slice(start, end);
|
||||
|
||||
const chunk = {
|
||||
__chunked: true,
|
||||
data: slice,
|
||||
id,
|
||||
index,
|
||||
total,
|
||||
};
|
||||
|
||||
chunks.push(chunk);
|
||||
start = end;
|
||||
index++;
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
export default Peer;
|
245
src/helpers/Session.js
Normal file
245
src/helpers/Session.js
Normal file
@ -0,0 +1,245 @@
|
||||
import io from "socket.io-client";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import Connection from "./Connection";
|
||||
|
||||
import { omit } from "./shared";
|
||||
|
||||
/**
|
||||
* @typedef {object} SessionPeer
|
||||
* @property {string} id - The socket id of the peer
|
||||
* @property {Connection} connection - The actual peer connection
|
||||
* @property {boolean} initiator - Is this peer the initiator of the connection
|
||||
* @property {boolean} sync - Should this connection sync other connections
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* Handles connections to multiple peers
|
||||
*
|
||||
* Events:
|
||||
* - connect: A party member has connected
|
||||
* - data
|
||||
* - trackAdded
|
||||
* - trackRemoved
|
||||
* - disconnect: A party member has disconnected
|
||||
* - error
|
||||
* - authenticationSuccess
|
||||
* - authenticationError
|
||||
* - connected: You have connected
|
||||
* - disconnected: You have disconnected
|
||||
*/
|
||||
class Session extends EventEmitter {
|
||||
/**
|
||||
* The socket io connection
|
||||
*
|
||||
* @type {SocketIOClient.Socket}
|
||||
*/
|
||||
socket;
|
||||
|
||||
/**
|
||||
* A mapping of socket ids to session peers
|
||||
*
|
||||
* @type {Object.<string, SessionPeer>}
|
||||
*/
|
||||
peers;
|
||||
|
||||
get id() {
|
||||
return this.socket.id;
|
||||
}
|
||||
|
||||
_iceServers;
|
||||
|
||||
// Store party id and password for reconnect
|
||||
_partyId;
|
||||
_password;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.socket = io(process.env.REACT_APP_BROKER_URL);
|
||||
|
||||
this.socket.on(
|
||||
"party member joined",
|
||||
this._handlePartyMemberJoined.bind(this)
|
||||
);
|
||||
this.socket.on("party member left", this._handlePartyMemberLeft.bind(this));
|
||||
this.socket.on("joined party", this._handleJoinedParty.bind(this));
|
||||
this.socket.on("signal", this._handleSignal.bind(this));
|
||||
this.socket.on("auth error", this._handleAuthError.bind(this));
|
||||
this.socket.on("disconnect", this._handleSocketDisconnect.bind(this));
|
||||
this.socket.on("reconnect", this._handleSocketReconnect.bind(this));
|
||||
|
||||
this.peers = {};
|
||||
|
||||
// Signal connected peers of a closure on refresh
|
||||
window.addEventListener("beforeunload", this._handleUnload.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data to all connected peers
|
||||
*
|
||||
* @param {string} id - the id of the event to send
|
||||
* @param {object} data
|
||||
* @param {string} channel
|
||||
*/
|
||||
send(id, data, channel) {
|
||||
for (let peer of Object.values(this.peers)) {
|
||||
peer.connection.send({ id, data }, channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a party
|
||||
*
|
||||
* @param {string} partyId - the id of the party to join
|
||||
* @param {string} password - the password of the party
|
||||
*/
|
||||
async joinParty(partyId, password) {
|
||||
if (typeof partyId !== "string" || typeof password !== "string") {
|
||||
console.error(
|
||||
"Unable to join party: invalid party ID or password",
|
||||
partyId,
|
||||
password
|
||||
);
|
||||
this.emit("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
this._partyId = partyId;
|
||||
this._password = password;
|
||||
try {
|
||||
const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL);
|
||||
const data = await response.json();
|
||||
this._iceServers = data.iceServers;
|
||||
this.socket.emit("join party", partyId, password);
|
||||
} catch (e) {
|
||||
console.error("Unable to join party:", e.message);
|
||||
this.emit("disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
_addPeer(id, initiator, sync) {
|
||||
try {
|
||||
const connection = new Connection({
|
||||
initiator,
|
||||
trickle: true,
|
||||
config: { iceServers: this._iceServers },
|
||||
});
|
||||
if (initiator) {
|
||||
connection.createDataChannel("map", { iceServers: this._iceServers });
|
||||
connection.createDataChannel("token", { iceServers: this._iceServers });
|
||||
}
|
||||
const peer = { id, connection, initiator, sync };
|
||||
|
||||
function sendPeer(id, data, channel) {
|
||||
peer.connection.send({ id, data }, channel);
|
||||
}
|
||||
|
||||
function handleSignal(signal) {
|
||||
this.socket.emit("signal", JSON.stringify({ to: peer.id, signal }));
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
this.emit("connect", { peer, reply: sendPeer });
|
||||
if (peer.sync) {
|
||||
peer.connection.send({ id: "sync" });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataComplete(data) {
|
||||
if (data.id === "close") {
|
||||
// Close connection when signaled to close
|
||||
peer.connection.destroy();
|
||||
}
|
||||
this.emit("data", {
|
||||
peer,
|
||||
id: data.id,
|
||||
data: data.data,
|
||||
reply: sendPeer,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDataProgress({ id, count, total }) {
|
||||
this.emit("dataProgress", { peer, id, count, total, reply: sendPeer });
|
||||
}
|
||||
|
||||
function handleTrack(track, stream) {
|
||||
this.emit("trackAdded", { peer, track, stream });
|
||||
track.addEventListener("mute", () => {
|
||||
this.emit("trackRemoved", { peer, track, stream });
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
this.emit("disconnect", { peer });
|
||||
peer.connection.destroy();
|
||||
this.peers = omit(this.peers, [peer.id]);
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
this.emit("error", { peer, error });
|
||||
}
|
||||
|
||||
peer.connection.on("signal", handleSignal.bind(this));
|
||||
peer.connection.on("connect", handleConnect.bind(this));
|
||||
peer.connection.on("dataComplete", handleDataComplete.bind(this));
|
||||
peer.connection.on("dataProgress", handleDataProgress.bind(this));
|
||||
peer.connection.on("track", handleTrack.bind(this));
|
||||
peer.connection.on("close", handleClose.bind(this));
|
||||
peer.connection.on("error", handleError.bind(this));
|
||||
|
||||
this.peers[id] = peer;
|
||||
} catch (error) {
|
||||
this.emit("error", { error });
|
||||
}
|
||||
}
|
||||
|
||||
_handlePartyMemberJoined(id) {
|
||||
this._addPeer(id, false, false);
|
||||
}
|
||||
|
||||
_handlePartyMemberLeft(id) {
|
||||
if (id in this.peers) {
|
||||
this.peers[id].connection.destroy();
|
||||
delete this.peers[id];
|
||||
}
|
||||
}
|
||||
|
||||
_handleJoinedParty(otherIds) {
|
||||
for (let [index, id] of otherIds.entries()) {
|
||||
// Send a sync request to the first member of the party
|
||||
const sync = index === 0;
|
||||
this._addPeer(id, true, sync);
|
||||
}
|
||||
this.emit("authenticationSuccess");
|
||||
this.emit("connected");
|
||||
}
|
||||
|
||||
_handleSignal(data) {
|
||||
const { from, signal } = JSON.parse(data);
|
||||
if (from in this.peers) {
|
||||
this.peers[from].connection.signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
_handleAuthError() {
|
||||
this.emit("authenticationError");
|
||||
}
|
||||
|
||||
_handleUnload() {
|
||||
for (let peer of Object.values(this.peers)) {
|
||||
peer.connection.send({ id: "close" });
|
||||
}
|
||||
}
|
||||
|
||||
_handleSocketDisconnect() {
|
||||
this.emit("disconnected");
|
||||
}
|
||||
|
||||
_handleSocketReconnect() {
|
||||
this.emit("connected");
|
||||
this.joinParty(this._partyId, this._password);
|
||||
}
|
||||
}
|
||||
|
||||
export default Session;
|
68
src/helpers/image.js
Normal file
68
src/helpers/image.js
Normal file
@ -0,0 +1,68 @@
|
||||
const lightnessDetectionOffset = 0.1;
|
||||
|
||||
/**
|
||||
* @param {HTMLImageElement} image
|
||||
* @returns {boolean} True is the image is light
|
||||
*/
|
||||
export function getImageLightness(image) {
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
let canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
let context = canvas.getContext("2d");
|
||||
context.drawImage(image, 0, 0);
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
|
||||
const data = imageData.data;
|
||||
let lightPixels = 0;
|
||||
let darkPixels = 0;
|
||||
// Loop over every pixels rgba values
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
const max = Math.max(Math.max(r, g), b);
|
||||
if (max < 128) {
|
||||
darkPixels++;
|
||||
} else {
|
||||
lightPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (lightPixels - darkPixels) / (width * height);
|
||||
return norm + lightnessDetectionOffset >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLImageElement} image the image to resize
|
||||
* @param {number} size the size of the longest edge of the new image
|
||||
* @param {string} type the mime type of the image
|
||||
* @param {number} quality if image is a jpeg or webp this is the quality setting
|
||||
*/
|
||||
export async function resizeImage(image, size, type, quality) {
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
const ratio = width / height;
|
||||
let canvas = document.createElement("canvas");
|
||||
if (ratio > 1) {
|
||||
canvas.width = size;
|
||||
canvas.height = Math.round(size / ratio);
|
||||
} else {
|
||||
canvas.width = Math.round(size * ratio);
|
||||
canvas.height = size;
|
||||
}
|
||||
let context = canvas.getContext("2d");
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve({ blob, width: canvas.width, height: canvas.height });
|
||||
},
|
||||
type,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
@ -1,235 +0,0 @@
|
||||
import { useEffect, useState, useContext, useCallback } from "react";
|
||||
import io from "socket.io-client";
|
||||
|
||||
import { omit } from "../helpers/shared";
|
||||
import Peer from "../helpers/Peer";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
const socket = io(process.env.REACT_APP_BROKER_URL);
|
||||
|
||||
function useSession(
|
||||
partyId,
|
||||
onPeerConnected,
|
||||
onPeerDisconnected,
|
||||
onPeerData,
|
||||
onPeerDataProgress,
|
||||
onPeerTrackAdded,
|
||||
onPeerTrackRemoved,
|
||||
onPeerError
|
||||
) {
|
||||
const { password, setAuthenticationStatus } = useContext(AuthContext);
|
||||
const [iceServers, setIceServers] = useState([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const joinParty = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL);
|
||||
const data = await response.json();
|
||||
setIceServers(data.iceServers);
|
||||
socket.emit("join party", partyId, password);
|
||||
} catch (e) {
|
||||
console.error("Unable to join party:", e.message);
|
||||
setConnected(false);
|
||||
}
|
||||
}, [partyId, password]);
|
||||
|
||||
useEffect(() => {
|
||||
joinParty();
|
||||
}, [partyId, password, joinParty]);
|
||||
|
||||
const [peers, setPeers] = useState({});
|
||||
|
||||
// Signal connected peers of a closure on refresh
|
||||
useEffect(() => {
|
||||
function handleUnload() {
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "close" });
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handleUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleUnload);
|
||||
};
|
||||
}, [peers]);
|
||||
|
||||
// Setup event listeners for peers
|
||||
useEffect(() => {
|
||||
let peerEvents = [];
|
||||
for (let peer of Object.values(peers)) {
|
||||
function handleSignal(signal) {
|
||||
socket.emit("signal", JSON.stringify({ to: peer.id, signal }));
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
onPeerConnected && onPeerConnected(peer);
|
||||
if (peer.sync) {
|
||||
peer.connection.send({ id: "sync" });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDataComplete(data) {
|
||||
if (data.id === "close") {
|
||||
// Close connection when signaled to close
|
||||
peer.connection.destroy();
|
||||
}
|
||||
onPeerData && onPeerData({ peer, data });
|
||||
}
|
||||
|
||||
function handleDataProgress({ id, count, total }) {
|
||||
onPeerDataProgress && onPeerDataProgress({ id, count, total });
|
||||
}
|
||||
|
||||
function handleTrack(track, stream) {
|
||||
onPeerTrackAdded && onPeerTrackAdded({ peer, track, stream });
|
||||
track.addEventListener("mute", () => {
|
||||
onPeerTrackRemoved && onPeerTrackRemoved({ peer, track, stream });
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onPeerDisconnected && onPeerDisconnected(peer);
|
||||
peer.connection.destroy();
|
||||
setPeers((prevPeers) => omit(prevPeers, [peer.id]));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
onPeerError && onPeerError({ peer, error });
|
||||
}
|
||||
|
||||
peer.connection.on("signal", handleSignal);
|
||||
peer.connection.on("connect", handleConnect);
|
||||
peer.connection.on("dataComplete", handleDataComplete);
|
||||
peer.connection.on("dataProgress", handleDataProgress);
|
||||
peer.connection.on("track", handleTrack);
|
||||
peer.connection.on("close", handleClose);
|
||||
peer.connection.on("error", handleError);
|
||||
// Save events for cleanup
|
||||
peerEvents.push({
|
||||
peer,
|
||||
handleSignal,
|
||||
handleConnect,
|
||||
handleDataComplete,
|
||||
handleDataProgress,
|
||||
handleTrack,
|
||||
handleClose,
|
||||
handleError,
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup events
|
||||
return () => {
|
||||
for (let {
|
||||
peer,
|
||||
handleSignal,
|
||||
handleConnect,
|
||||
handleDataComplete,
|
||||
handleDataProgress,
|
||||
handleTrack,
|
||||
handleClose,
|
||||
handleError,
|
||||
} of peerEvents) {
|
||||
peer.connection.off("signal", handleSignal);
|
||||
peer.connection.off("connect", handleConnect);
|
||||
peer.connection.off("dataComplete", handleDataComplete);
|
||||
peer.connection.off("dataProgress", handleDataProgress);
|
||||
peer.connection.off("track", handleTrack);
|
||||
peer.connection.off("close", handleClose);
|
||||
peer.connection.off("error", handleError);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
peers,
|
||||
onPeerConnected,
|
||||
onPeerDisconnected,
|
||||
onPeerData,
|
||||
onPeerDataProgress,
|
||||
onPeerTrackAdded,
|
||||
onPeerTrackRemoved,
|
||||
onPeerError,
|
||||
]);
|
||||
|
||||
// Setup event listeners for the socket
|
||||
useEffect(() => {
|
||||
function addPeer(id, initiator, sync) {
|
||||
try {
|
||||
const connection = new Peer({
|
||||
initiator,
|
||||
trickle: true,
|
||||
config: { iceServers },
|
||||
});
|
||||
setPeers((prevPeers) => ({
|
||||
...prevPeers,
|
||||
[id]: { id, connection, initiator, sync },
|
||||
}));
|
||||
} catch (error) {
|
||||
onPeerError && onPeerError({ error });
|
||||
}
|
||||
}
|
||||
|
||||
function handlePartyMemberJoined(id) {
|
||||
addPeer(id, false, false);
|
||||
}
|
||||
|
||||
function handlePartyMemberLeft(id) {
|
||||
if (id in peers) {
|
||||
peers[id].connection.destroy();
|
||||
setPeers((prevPeers) => omit(prevPeers, [id]));
|
||||
}
|
||||
}
|
||||
|
||||
function handleJoinedParty(otherIds) {
|
||||
for (let [index, id] of otherIds.entries()) {
|
||||
// Send a sync request to the first member of the party
|
||||
const sync = index === 0;
|
||||
addPeer(id, true, sync);
|
||||
}
|
||||
setAuthenticationStatus("authenticated");
|
||||
setConnected(true);
|
||||
}
|
||||
|
||||
function handleSignal(data) {
|
||||
const { from, signal } = JSON.parse(data);
|
||||
if (from in peers) {
|
||||
peers[from].connection.signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthError() {
|
||||
setAuthenticationStatus("unauthenticated");
|
||||
}
|
||||
|
||||
function handleSocketDisconnect() {
|
||||
setConnected(false);
|
||||
}
|
||||
|
||||
function handleSocketReconnect() {
|
||||
setConnected(true);
|
||||
joinParty();
|
||||
}
|
||||
|
||||
socket.on("disconnect", handleSocketDisconnect);
|
||||
socket.on("reconnect", handleSocketReconnect);
|
||||
|
||||
socket.on("party member joined", handlePartyMemberJoined);
|
||||
socket.on("party member left", handlePartyMemberLeft);
|
||||
socket.on("joined party", handleJoinedParty);
|
||||
socket.on("signal", handleSignal);
|
||||
socket.on("auth error", handleAuthError);
|
||||
return () => {
|
||||
socket.off("disconnect", handleSocketDisconnect);
|
||||
socket.off("reconnect", handleSocketReconnect);
|
||||
|
||||
socket.off("party member joined", handlePartyMemberJoined);
|
||||
socket.off("party member left", handlePartyMemberLeft);
|
||||
socket.off("joined party", handleJoinedParty);
|
||||
socket.off("signal", handleSignal);
|
||||
socket.off("auth error", handleAuthError);
|
||||
};
|
||||
}, [peers, setAuthenticationStatus, iceServers, joinParty, onPeerError]);
|
||||
|
||||
return { peers, socket, connected };
|
||||
}
|
||||
|
||||
export default useSession;
|
21
src/icons/StreamMuteIcon.js
Normal file
21
src/icons/StreamMuteIcon.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
function StreamMuteIcon({ muted }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
{muted ? (
|
||||
<path d="M3.63 3.63c-.39.39-.39 1.02 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12c0-1.77-1.02-3.29-2.5-4.03v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" />
|
||||
) : (
|
||||
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default StreamMuteIcon;
|
@ -6,6 +6,7 @@ import Modal from "../components/Modal";
|
||||
import MapTiles from "../components/map/MapTiles";
|
||||
import MapSettings from "../components/map/MapSettings";
|
||||
import ImageDrop from "../components/ImageDrop";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import blobToBuffer from "../helpers/blobToBuffer";
|
||||
|
||||
@ -13,6 +14,7 @@ import MapDataContext from "../contexts/MapDataContext";
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
import { resizeImage } from "../helpers/image";
|
||||
|
||||
const defaultMapSize = 22;
|
||||
const defaultMapProps = {
|
||||
@ -20,8 +22,16 @@ const defaultMapProps = {
|
||||
// TODO: add support for hex horizontal and hex vertical
|
||||
gridType: "grid",
|
||||
showGrid: false,
|
||||
quality: "original",
|
||||
};
|
||||
|
||||
const mapResolutions = [
|
||||
{ size: 512, quality: 0.5, id: "low" },
|
||||
{ size: 1024, quality: 0.6, id: "medium" },
|
||||
{ size: 2048, quality: 0.7, id: "high" },
|
||||
{ size: 4096, quality: 0.8, id: "ultra" },
|
||||
];
|
||||
|
||||
function SelectMapModal({
|
||||
isOpen,
|
||||
onDone,
|
||||
@ -103,10 +113,32 @@ function SelectMapModal({
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = function () {
|
||||
image.onload = async function () {
|
||||
// Create resolutions
|
||||
const resolutions = {};
|
||||
for (let resolution of mapResolutions) {
|
||||
if (Math.max(image.width, image.height) > resolution.size) {
|
||||
const resized = await resizeImage(
|
||||
image,
|
||||
resolution.size,
|
||||
file.type,
|
||||
resolution.quality
|
||||
);
|
||||
const resizedBuffer = await blobToBuffer(resized.blob);
|
||||
resolutions[resolution.id] = {
|
||||
file: resizedBuffer,
|
||||
width: resized.width,
|
||||
height: resized.height,
|
||||
type: "file",
|
||||
id: resolution.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
handleMapAdd({
|
||||
// Save as a buffer to send with msgpack
|
||||
file: buffer,
|
||||
resolutions,
|
||||
name,
|
||||
type: "file",
|
||||
gridX: fileGridX,
|
||||
@ -164,6 +196,9 @@ function SelectMapModal({
|
||||
}
|
||||
|
||||
async function handleDone() {
|
||||
if (imageLoading) {
|
||||
return;
|
||||
}
|
||||
if (selectedMapId) {
|
||||
await applyMapChanges();
|
||||
onMapChange(selectedMapWithChanges, selectedMapStateWithChanges);
|
||||
@ -181,7 +216,11 @@ function SelectMapModal({
|
||||
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
|
||||
|
||||
function handleMapSettingsChange(key, value) {
|
||||
setMapSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
|
||||
setMapSettingChanges((prevChanges) => ({
|
||||
...prevChanges,
|
||||
[key]: value,
|
||||
lastModified: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleMapStateSettingsChange(key, value) {
|
||||
@ -256,6 +295,7 @@ function SelectMapModal({
|
||||
</Button>
|
||||
</Flex>
|
||||
</ImageDrop>
|
||||
{imageLoading && <LoadingOverlay bg="overlay" />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
436
src/network/NetworkedMapAndTokens.js
Normal file
436
src/network/NetworkedMapAndTokens.js
Normal file
@ -0,0 +1,436 @@
|
||||
import React, { useState, useContext, useEffect, useCallback } from "react";
|
||||
|
||||
import TokenDataContext from "../contexts/TokenDataContext";
|
||||
import MapDataContext from "../contexts/MapDataContext";
|
||||
import MapLoadingContext from "../contexts/MapLoadingContext";
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
import DatabaseContext from "../contexts/DatabaseContext";
|
||||
|
||||
import { omit } from "../helpers/shared";
|
||||
import useDebounce from "../helpers/useDebounce";
|
||||
// Load session for auto complete
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import Session from "../helpers/Session";
|
||||
|
||||
import Map from "../components/map/Map";
|
||||
import Tokens from "../components/token/Tokens";
|
||||
|
||||
/**
|
||||
* @typedef {object} NetworkedMapProps
|
||||
* @property {Session} session
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {NetworkedMapProps} props
|
||||
*/
|
||||
function NetworkedMapAndTokens({ session }) {
|
||||
const { userId } = useContext(AuthContext);
|
||||
const {
|
||||
assetLoadStart,
|
||||
assetLoadFinish,
|
||||
assetProgressUpdate,
|
||||
isLoading,
|
||||
} = useContext(MapLoadingContext);
|
||||
|
||||
const { putToken, getToken } = useContext(TokenDataContext);
|
||||
const { putMap, getMap } = useContext(MapDataContext);
|
||||
|
||||
const [currentMap, setCurrentMap] = useState(null);
|
||||
const [currentMapState, setCurrentMapState] = useState(null);
|
||||
|
||||
/**
|
||||
* Map state
|
||||
*/
|
||||
|
||||
const { database } = useContext(DatabaseContext);
|
||||
// Sync the map state to the database after 500ms of inactivity
|
||||
const debouncedMapState = useDebounce(currentMapState, 500);
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedMapState &&
|
||||
debouncedMapState.mapId &&
|
||||
currentMap &&
|
||||
currentMap.owner === userId &&
|
||||
database
|
||||
) {
|
||||
// Update the database directly to avoid re-renders
|
||||
database
|
||||
.table("states")
|
||||
.update(debouncedMapState.mapId, debouncedMapState);
|
||||
}
|
||||
}, [currentMap, debouncedMapState, userId, database]);
|
||||
|
||||
function handleMapChange(newMap, newMapState) {
|
||||
setCurrentMapState(newMapState);
|
||||
setCurrentMap(newMap);
|
||||
session.send("map", null, "map");
|
||||
|
||||
if (!newMap || !newMapState) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.send("mapState", newMapState);
|
||||
session.send("map", getMapDataToSend(newMap), "map");
|
||||
const tokensToSend = getMapTokensToSend(newMapState);
|
||||
for (let token of tokensToSend) {
|
||||
session.send("token", token, "token");
|
||||
}
|
||||
}
|
||||
|
||||
function getMapDataToSend(mapData) {
|
||||
// Omit file from map change, receiver will request the file if
|
||||
// they have an outdated version
|
||||
if (mapData.type === "file") {
|
||||
const { file, resolutions, ...rest } = mapData;
|
||||
return rest;
|
||||
} else {
|
||||
return mapData;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapStateChange(newMapState) {
|
||||
setCurrentMapState(newMapState);
|
||||
session.send("mapState", newMapState);
|
||||
}
|
||||
|
||||
function addMapDrawActions(actions, indexKey, actionsKey) {
|
||||
setCurrentMapState((prevMapState) => {
|
||||
const newActions = [
|
||||
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
|
||||
...actions,
|
||||
];
|
||||
const newIndex = newActions.length - 1;
|
||||
return {
|
||||
...prevMapState,
|
||||
[actionsKey]: newActions,
|
||||
[indexKey]: newIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateDrawActionIndex(change, indexKey, actionsKey) {
|
||||
const newIndex = Math.min(
|
||||
Math.max(currentMapState[indexKey] + change, -1),
|
||||
currentMapState[actionsKey].length - 1
|
||||
);
|
||||
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
[indexKey]: newIndex,
|
||||
}));
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
function handleMapDraw(action) {
|
||||
addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions");
|
||||
session.send("mapDraw", [action]);
|
||||
}
|
||||
|
||||
function handleMapDrawUndo() {
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
session.send("mapDrawIndex", index);
|
||||
}
|
||||
|
||||
function handleMapDrawRedo() {
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
session.send("mapDrawIndex", index);
|
||||
}
|
||||
|
||||
function handleFogDraw(action) {
|
||||
addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions");
|
||||
session.send("mapFog", [action]);
|
||||
}
|
||||
|
||||
function handleFogDrawUndo() {
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
session.send("mapFogIndex", index);
|
||||
}
|
||||
|
||||
function handleFogDrawRedo() {
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
session.send("mapFogIndex", index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token state
|
||||
*/
|
||||
|
||||
// Get all tokens from a token state
|
||||
const getMapTokensToSend = useCallback(
|
||||
(state) => {
|
||||
let sentTokens = {};
|
||||
const tokens = [];
|
||||
for (let tokenState of Object.values(state.tokens)) {
|
||||
const token = getToken(tokenState.tokenId);
|
||||
if (
|
||||
token &&
|
||||
token.type === "file" &&
|
||||
!(tokenState.tokenId in sentTokens)
|
||||
) {
|
||||
sentTokens[tokenState.tokenId] = true;
|
||||
// Omit file from token peer will request file if needed
|
||||
const { file, ...rest } = token;
|
||||
tokens.push(rest);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
[getToken]
|
||||
);
|
||||
|
||||
async function handleMapTokenStateCreate(tokenState) {
|
||||
// If file type token send the token to the other peers
|
||||
const token = getToken(tokenState.tokenId);
|
||||
if (token && token.type === "file") {
|
||||
const { file, ...rest } = token;
|
||||
session.send("token", rest);
|
||||
}
|
||||
handleMapTokenStateChange({ [tokenState.id]: tokenState });
|
||||
}
|
||||
|
||||
function handleMapTokenStateChange(change) {
|
||||
if (currentMapState === null) {
|
||||
return;
|
||||
}
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: {
|
||||
...prevMapState.tokens,
|
||||
...change,
|
||||
},
|
||||
}));
|
||||
session.send("tokenStateEdit", change);
|
||||
}
|
||||
|
||||
function handleMapTokenStateRemove(tokenState) {
|
||||
setCurrentMapState((prevMapState) => {
|
||||
const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
|
||||
return { ...prevMapState, tokens: rest };
|
||||
});
|
||||
session.send("tokenStateRemove", { [tokenState.id]: tokenState });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function handlePeerData({ id, data, reply }) {
|
||||
if (id === "sync") {
|
||||
if (currentMapState) {
|
||||
reply("mapState", currentMapState);
|
||||
const tokensToSend = getMapTokensToSend(currentMapState);
|
||||
for (let token of tokensToSend) {
|
||||
reply("token", token, "token");
|
||||
}
|
||||
}
|
||||
if (currentMap) {
|
||||
reply("map", getMapDataToSend(currentMap), "map");
|
||||
}
|
||||
}
|
||||
if (id === "map") {
|
||||
const newMap = data;
|
||||
if (newMap && newMap.type === "file") {
|
||||
const cachedMap = getMap(newMap.id);
|
||||
if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
|
||||
setCurrentMap(cachedMap);
|
||||
} else {
|
||||
await putMap(newMap);
|
||||
reply("mapRequest", newMap.id, "map");
|
||||
}
|
||||
} else {
|
||||
setCurrentMap(newMap);
|
||||
}
|
||||
}
|
||||
if (id === "mapRequest") {
|
||||
const map = getMap(data);
|
||||
|
||||
function replyWithFile(file, preview) {
|
||||
reply(
|
||||
"mapResponse",
|
||||
{
|
||||
...map,
|
||||
file,
|
||||
resolutions: {},
|
||||
// If preview don't send the last modified so that it will not be cached
|
||||
lastModified: preview ? 0 : map.lastModified,
|
||||
},
|
||||
"map"
|
||||
);
|
||||
}
|
||||
|
||||
switch (map.quality) {
|
||||
case "low":
|
||||
replyWithFile(map.resolutions.low.file, false);
|
||||
break;
|
||||
case "medium":
|
||||
replyWithFile(map.resolutions.low.file, true);
|
||||
replyWithFile(map.resolutions.medium.file, false);
|
||||
break;
|
||||
case "high":
|
||||
replyWithFile(map.resolutions.medium.file, true);
|
||||
replyWithFile(map.resolutions.high.file, false);
|
||||
break;
|
||||
case "ultra":
|
||||
replyWithFile(map.resolutions.medium.file, true);
|
||||
replyWithFile(map.resolutions.ultra.file, false);
|
||||
break;
|
||||
case "original":
|
||||
if (map.resolutions.medium) {
|
||||
replyWithFile(map.resolutions.medium.file, true);
|
||||
} else if (map.resolutions.low) {
|
||||
replyWithFile(map.resolutions.low.file, true);
|
||||
}
|
||||
replyWithFile(map.file, false);
|
||||
break;
|
||||
default:
|
||||
replyWithFile(map.file, false);
|
||||
}
|
||||
}
|
||||
if (id === "mapResponse") {
|
||||
await putMap(data);
|
||||
setCurrentMap(data);
|
||||
}
|
||||
if (id === "mapState") {
|
||||
setCurrentMapState(data);
|
||||
}
|
||||
if (id === "token") {
|
||||
const newToken = data;
|
||||
if (newToken && newToken.type === "file") {
|
||||
const cachedToken = getToken(newToken.id);
|
||||
if (
|
||||
!cachedToken ||
|
||||
cachedToken.lastModified !== newToken.lastModified
|
||||
) {
|
||||
reply("tokenRequest", newToken.id, "token");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (id === "tokenRequest") {
|
||||
const token = getToken(data);
|
||||
reply("tokenResponse", token, "token");
|
||||
}
|
||||
if (id === "tokenResponse") {
|
||||
const newToken = data;
|
||||
if (newToken && newToken.type === "file") {
|
||||
putToken(newToken);
|
||||
}
|
||||
}
|
||||
if (id === "tokenStateEdit") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: { ...prevMapState.tokens, ...data },
|
||||
}));
|
||||
}
|
||||
if (id === "tokenStateRemove") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: omit(prevMapState.tokens, Object.keys(data)),
|
||||
}));
|
||||
}
|
||||
if (id === "mapDraw") {
|
||||
addMapDrawActions(data, "mapDrawActionIndex", "mapDrawActions");
|
||||
}
|
||||
if (id === "mapDrawIndex") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
mapDrawActionIndex: data,
|
||||
}));
|
||||
}
|
||||
if (id === "mapFog") {
|
||||
addMapDrawActions(data, "fogDrawActionIndex", "fogDrawActions");
|
||||
}
|
||||
if (id === "mapFogIndex") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
fogDrawActionIndex: data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeerDataProgress({ id, total, count }) {
|
||||
if (count === 1) {
|
||||
assetLoadStart();
|
||||
}
|
||||
if (total === count) {
|
||||
assetLoadFinish();
|
||||
}
|
||||
assetProgressUpdate({ id, total, count });
|
||||
}
|
||||
|
||||
session.on("data", handlePeerData);
|
||||
session.on("dataProgress", handlePeerDataProgress);
|
||||
|
||||
return () => {
|
||||
session.off("data", handlePeerData);
|
||||
session.off("dataProgress", handlePeerDataProgress);
|
||||
};
|
||||
});
|
||||
|
||||
const canChangeMap = !isLoading;
|
||||
|
||||
const canEditMapDrawing =
|
||||
currentMap !== null &&
|
||||
currentMapState !== null &&
|
||||
(currentMapState.editFlags.includes("drawing") ||
|
||||
currentMap.owner === userId);
|
||||
|
||||
const canEditFogDrawing =
|
||||
currentMap !== null &&
|
||||
currentMapState !== null &&
|
||||
(currentMapState.editFlags.includes("fog") || currentMap.owner === userId);
|
||||
|
||||
const disabledMapTokens = {};
|
||||
// If we have a map and state and have the token permission disabled
|
||||
// and are not the map owner
|
||||
if (
|
||||
currentMapState !== null &&
|
||||
currentMap !== null &&
|
||||
!currentMapState.editFlags.includes("tokens") &&
|
||||
currentMap.owner !== userId
|
||||
) {
|
||||
for (let token of Object.values(currentMapState.tokens)) {
|
||||
if (token.owner !== userId) {
|
||||
disabledMapTokens[token.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Map
|
||||
map={currentMap}
|
||||
mapState={currentMapState}
|
||||
onMapTokenStateChange={handleMapTokenStateChange}
|
||||
onMapTokenStateRemove={handleMapTokenStateRemove}
|
||||
onMapChange={handleMapChange}
|
||||
onMapStateChange={handleMapStateChange}
|
||||
onMapDraw={handleMapDraw}
|
||||
onMapDrawUndo={handleMapDrawUndo}
|
||||
onMapDrawRedo={handleMapDrawRedo}
|
||||
onFogDraw={handleFogDraw}
|
||||
onFogDrawUndo={handleFogDrawUndo}
|
||||
onFogDrawRedo={handleFogDrawRedo}
|
||||
allowMapDrawing={canEditMapDrawing}
|
||||
allowFogDrawing={canEditFogDrawing}
|
||||
allowMapChange={canChangeMap}
|
||||
disabledTokens={disabledMapTokens}
|
||||
/>
|
||||
<Tokens onMapTokenStateCreate={handleMapTokenStateCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkedMapAndTokens;
|
146
src/network/NetworkedParty.js
Normal file
146
src/network/NetworkedParty.js
Normal file
@ -0,0 +1,146 @@
|
||||
import React, { useContext, useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Load session for auto complete
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import Session from "../helpers/Session";
|
||||
import { isStreamStopped, omit } from "../helpers/shared";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import Party from "../components/party/Party";
|
||||
|
||||
/**
|
||||
* @typedef {object} NetworkedPartyProps
|
||||
* @property {string} gameId
|
||||
* @property {Session} session
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {NetworkedPartyProps} props
|
||||
*/
|
||||
function NetworkedParty({ gameId, session }) {
|
||||
const { nickname, setNickname } = useContext(AuthContext);
|
||||
const [partyNicknames, setPartyNicknames] = useState({});
|
||||
const [stream, setStream] = useState(null);
|
||||
const [partyStreams, setPartyStreams] = useState({});
|
||||
|
||||
function handleNicknameChange(nickname) {
|
||||
setNickname(nickname);
|
||||
session.send("nickname", { [session.id]: nickname });
|
||||
}
|
||||
|
||||
function handleStreamStart(localStream) {
|
||||
setStream(localStream);
|
||||
const tracks = localStream.getTracks();
|
||||
for (let track of tracks) {
|
||||
// Only add the audio track of the stream to the remote peer
|
||||
if (track.kind === "audio") {
|
||||
for (let peer of Object.values(session.peers)) {
|
||||
peer.connection.addTrack(track, localStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamEnd = useCallback(
|
||||
(localStream) => {
|
||||
setStream(null);
|
||||
const tracks = localStream.getTracks();
|
||||
for (let track of tracks) {
|
||||
track.stop();
|
||||
// Only sending audio so only remove the audio track
|
||||
if (track.kind === "audio") {
|
||||
for (let peer of Object.values(session.peers)) {
|
||||
peer.connection.removeTrack(track, localStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[session]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePeerConnect({ peer, reply }) {
|
||||
reply("nickname", { [session.id]: nickname });
|
||||
if (stream) {
|
||||
peer.connection.addStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeerDisconnect({ peer }) {
|
||||
setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id]));
|
||||
}
|
||||
|
||||
function handlePeerData({ id, data }) {
|
||||
if (id === "nickname") {
|
||||
setPartyNicknames((prevNicknames) => ({
|
||||
...prevNicknames,
|
||||
...data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeerTrackAdded({ peer, stream: remoteStream }) {
|
||||
setPartyStreams((prevStreams) => ({
|
||||
...prevStreams,
|
||||
[peer.id]: remoteStream,
|
||||
}));
|
||||
}
|
||||
|
||||
function handlePeerTrackRemoved({ peer, stream: remoteStream }) {
|
||||
if (isStreamStopped(remoteStream)) {
|
||||
setPartyStreams((prevStreams) => omit(prevStreams, [peer.id]));
|
||||
} else {
|
||||
setPartyStreams((prevStreams) => ({
|
||||
...prevStreams,
|
||||
[peer.id]: remoteStream,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
session.on("connect", handlePeerConnect);
|
||||
session.on("disconnect", handlePeerDisconnect);
|
||||
session.on("data", handlePeerData);
|
||||
session.on("trackAdded", handlePeerTrackAdded);
|
||||
session.on("trackRemoved", handlePeerTrackRemoved);
|
||||
|
||||
return () => {
|
||||
session.off("connect", handlePeerConnect);
|
||||
session.off("disconnect", handlePeerDisconnect);
|
||||
session.off("data", handlePeerData);
|
||||
session.off("trackAdded", handlePeerTrackAdded);
|
||||
session.off("trackRemoved", handlePeerTrackRemoved);
|
||||
};
|
||||
}, [session, nickname, stream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
const tracks = stream.getTracks();
|
||||
// Detect when someone has ended the screen sharing
|
||||
// by looking at the streams video track onended
|
||||
// the audio track doesn't seem to trigger this event
|
||||
for (let track of tracks) {
|
||||
if (track.kind === "video") {
|
||||
track.onended = function () {
|
||||
handleStreamEnd(stream);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [stream, handleStreamEnd]);
|
||||
|
||||
return (
|
||||
<Party
|
||||
gameId={gameId}
|
||||
onNicknameChange={handleNicknameChange}
|
||||
onStreamStart={handleStreamStart}
|
||||
onStreamEnd={handleStreamEnd}
|
||||
nickname={nickname}
|
||||
partyNicknames={partyNicknames}
|
||||
stream={stream}
|
||||
partyStreams={partyStreams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkedParty;
|
@ -1,20 +1,7 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
} from "react";
|
||||
import React, { useState, useEffect, useContext, useRef } from "react";
|
||||
import { Flex, Box, Text } from "theme-ui";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { omit, isStreamStopped } from "../helpers/shared";
|
||||
import useSession from "../helpers/useSession";
|
||||
import useDebounce from "../helpers/useDebounce";
|
||||
|
||||
import Party from "../components/party/Party";
|
||||
import Tokens from "../components/token/Tokens";
|
||||
import Map from "../components/map/Map";
|
||||
import Banner from "../components/Banner";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
import Link from "../components/Link";
|
||||
@ -22,488 +9,81 @@ import Link from "../components/Link";
|
||||
import AuthModal from "../modals/AuthModal";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
import DatabaseContext from "../contexts/DatabaseContext";
|
||||
import TokenDataContext from "../contexts/TokenDataContext";
|
||||
import MapDataContext from "../contexts/MapDataContext";
|
||||
import MapLoadingContext from "../contexts/MapLoadingContext";
|
||||
import { MapStageProvider } from "../contexts/MapStageContext";
|
||||
|
||||
import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens";
|
||||
import NetworkedParty from "../network/NetworkedParty";
|
||||
|
||||
import Session from "../helpers/Session";
|
||||
|
||||
const session = new Session();
|
||||
|
||||
function Game() {
|
||||
const { id: gameId } = useParams();
|
||||
const { authenticationStatus, userId, nickname, setNickname } = useContext(
|
||||
AuthContext
|
||||
);
|
||||
const { assetLoadStart, assetLoadFinish, assetProgressUpdate } = useContext(
|
||||
MapLoadingContext
|
||||
);
|
||||
const {
|
||||
authenticationStatus,
|
||||
password,
|
||||
setAuthenticationStatus,
|
||||
} = useContext(AuthContext);
|
||||
|
||||
const { peers, socket, connected } = useSession(
|
||||
gameId,
|
||||
handlePeerConnected,
|
||||
handlePeerDisconnected,
|
||||
handlePeerData,
|
||||
handlePeerDataProgress,
|
||||
handlePeerTrackAdded,
|
||||
handlePeerTrackRemoved,
|
||||
handlePeerError
|
||||
);
|
||||
|
||||
const { putToken, getToken } = useContext(TokenDataContext);
|
||||
const { putMap, getMap } = useContext(MapDataContext);
|
||||
|
||||
/**
|
||||
* Map state
|
||||
*/
|
||||
|
||||
const [currentMap, setCurrentMap] = useState(null);
|
||||
const [currentMapState, setCurrentMapState] = useState(null);
|
||||
|
||||
const canEditMapDrawing =
|
||||
currentMap !== null &&
|
||||
currentMapState !== null &&
|
||||
(currentMapState.editFlags.includes("drawing") ||
|
||||
currentMap.owner === userId);
|
||||
|
||||
const canEditFogDrawing =
|
||||
currentMap !== null &&
|
||||
currentMapState !== null &&
|
||||
(currentMapState.editFlags.includes("fog") || currentMap.owner === userId);
|
||||
|
||||
const disabledMapTokens = {};
|
||||
// If we have a map and state and have the token permission disabled
|
||||
// and are not the map owner
|
||||
if (
|
||||
currentMapState !== null &&
|
||||
currentMap !== null &&
|
||||
!currentMapState.editFlags.includes("tokens") &&
|
||||
currentMap.owner !== userId
|
||||
) {
|
||||
for (let token of Object.values(currentMapState.tokens)) {
|
||||
if (token.owner !== userId) {
|
||||
disabledMapTokens[token.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { database } = useContext(DatabaseContext);
|
||||
// Sync the map state to the database after 500ms of inactivity
|
||||
const debouncedMapState = useDebounce(currentMapState, 500);
|
||||
// Handle authentication status
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedMapState &&
|
||||
debouncedMapState.mapId &&
|
||||
currentMap &&
|
||||
currentMap.owner === userId &&
|
||||
database
|
||||
) {
|
||||
// Update the database directly to avoid re-renders
|
||||
database
|
||||
.table("states")
|
||||
.update(debouncedMapState.mapId, debouncedMapState);
|
||||
function handleAuthSuccess() {
|
||||
setAuthenticationStatus("authenticated");
|
||||
}
|
||||
}, [currentMap, debouncedMapState, userId, database]);
|
||||
function handleAuthError() {
|
||||
setAuthenticationStatus("unauthenticated");
|
||||
}
|
||||
session.on("authenticationSuccess", handleAuthSuccess);
|
||||
session.on("authenticationError", handleAuthError);
|
||||
|
||||
function handleMapChange(newMap, newMapState) {
|
||||
setCurrentMapState(newMapState);
|
||||
setCurrentMap(newMap);
|
||||
for (let peer of Object.values(peers)) {
|
||||
// Clear the map so the new map state isn't shown on an old map
|
||||
peer.connection.send({ id: "map", data: null });
|
||||
peer.connection.send({ id: "mapState", data: newMapState });
|
||||
sendMapDataToPeer(peer, newMap);
|
||||
sendTokensToPeer(peer, newMapState);
|
||||
}
|
||||
}
|
||||
|
||||
function sendMapDataToPeer(peer, mapData) {
|
||||
// Omit file from map change, receiver will request the file if
|
||||
// they have an outdated version
|
||||
if (mapData.type === "file") {
|
||||
const { file, ...rest } = mapData;
|
||||
peer.connection.send({ id: "map", data: rest });
|
||||
} else {
|
||||
peer.connection.send({ id: "map", data: mapData });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapStateChange(newMapState) {
|
||||
setCurrentMapState(newMapState);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapState", data: newMapState });
|
||||
}
|
||||
}
|
||||
|
||||
function addMapDrawActions(actions, indexKey, actionsKey) {
|
||||
setCurrentMapState((prevMapState) => {
|
||||
const newActions = [
|
||||
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
|
||||
...actions,
|
||||
];
|
||||
const newIndex = newActions.length - 1;
|
||||
return {
|
||||
...prevMapState,
|
||||
[actionsKey]: newActions,
|
||||
[indexKey]: newIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateDrawActionIndex(change, indexKey, actionsKey) {
|
||||
const newIndex = Math.min(
|
||||
Math.max(currentMapState[indexKey] + change, -1),
|
||||
currentMapState[actionsKey].length - 1
|
||||
);
|
||||
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
[indexKey]: newIndex,
|
||||
}));
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
function handleMapDraw(action) {
|
||||
addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions");
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDraw", data: [action] });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapDrawUndo() {
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapDrawRedo() {
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDraw(action) {
|
||||
addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions");
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapFog", data: [action] });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDrawUndo() {
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapFogIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDrawRedo() {
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapFogIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token state
|
||||
*/
|
||||
|
||||
// Get all tokens from a token state and send it to a peer
|
||||
function sendTokensToPeer(peer, state) {
|
||||
let sentTokens = {};
|
||||
for (let tokenState of Object.values(state.tokens)) {
|
||||
const token = getToken(tokenState.tokenId);
|
||||
if (
|
||||
token &&
|
||||
token.type === "file" &&
|
||||
!(tokenState.tokenId in sentTokens)
|
||||
) {
|
||||
sentTokens[tokenState.tokenId] = true;
|
||||
// Omit file from token peer will request file if needed
|
||||
const { file, ...rest } = token;
|
||||
peer.connection.send({ id: "token", data: rest });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapTokenStateCreate(tokenState) {
|
||||
// If file type token send the token to the other peers
|
||||
const token = getToken(tokenState.tokenId);
|
||||
if (token && token.type === "file") {
|
||||
const { file, ...rest } = token;
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "token", data: rest });
|
||||
}
|
||||
}
|
||||
handleMapTokenStateChange({ [tokenState.id]: tokenState });
|
||||
}
|
||||
|
||||
function handleMapTokenStateChange(change) {
|
||||
if (currentMapState === null) {
|
||||
return;
|
||||
}
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: {
|
||||
...prevMapState.tokens,
|
||||
...change,
|
||||
},
|
||||
}));
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "tokenStateEdit", data: change });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapTokenStateRemove(tokenState) {
|
||||
setCurrentMapState((prevMapState) => {
|
||||
const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
|
||||
return { ...prevMapState, tokens: rest };
|
||||
});
|
||||
for (let peer of Object.values(peers)) {
|
||||
const data = { [tokenState.id]: tokenState };
|
||||
peer.connection.send({ id: "tokenStateRemove", data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Party state
|
||||
*/
|
||||
|
||||
const [partyNicknames, setPartyNicknames] = useState({});
|
||||
|
||||
function handleNicknameChange(nickname) {
|
||||
setNickname(nickname);
|
||||
for (let peer of Object.values(peers)) {
|
||||
const data = { [socket.id]: nickname };
|
||||
peer.connection.send({ id: "nickname", data });
|
||||
}
|
||||
}
|
||||
|
||||
const [stream, setStream] = useState(null);
|
||||
const [partyStreams, setPartyStreams] = useState({});
|
||||
function handlePeerConnected(peer) {
|
||||
peer.connection.send({ id: "nickname", data: { [socket.id]: nickname } });
|
||||
if (stream) {
|
||||
peer.connection.addStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer handlers
|
||||
*/
|
||||
|
||||
function handlePeerData({ data, peer }) {
|
||||
if (data.id === "sync") {
|
||||
if (currentMapState) {
|
||||
peer.connection.send({ id: "mapState", data: currentMapState });
|
||||
sendTokensToPeer(peer, currentMapState);
|
||||
}
|
||||
if (currentMap) {
|
||||
sendMapDataToPeer(peer, currentMap);
|
||||
}
|
||||
}
|
||||
if (data.id === "map") {
|
||||
const newMap = data.data;
|
||||
// If is a file map check cache and request the full file if outdated
|
||||
if (newMap && newMap.type === "file") {
|
||||
const cachedMap = getMap(newMap.id);
|
||||
if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
|
||||
setCurrentMap(cachedMap);
|
||||
} else {
|
||||
peer.connection.send({ id: "mapRequest", data: newMap.id });
|
||||
}
|
||||
} else {
|
||||
setCurrentMap(newMap);
|
||||
}
|
||||
}
|
||||
// Send full map data including file
|
||||
if (data.id === "mapRequest") {
|
||||
const map = getMap(data.data);
|
||||
peer.connection.send({ id: "mapResponse", data: map });
|
||||
}
|
||||
// A new map response with a file attached
|
||||
if (data.id === "mapResponse") {
|
||||
if (data.data && data.data.type === "file") {
|
||||
const newMap = { ...data.data, file: data.data.file };
|
||||
putMap(newMap).then(() => {
|
||||
setCurrentMap(newMap);
|
||||
});
|
||||
} else {
|
||||
setCurrentMap(data.data);
|
||||
}
|
||||
}
|
||||
if (data.id === "mapState") {
|
||||
setCurrentMapState(data.data);
|
||||
}
|
||||
if (data.id === "token") {
|
||||
const newToken = data.data;
|
||||
if (newToken && newToken.type === "file") {
|
||||
const cachedToken = getToken(newToken.id);
|
||||
if (
|
||||
!cachedToken ||
|
||||
cachedToken.lastModified !== newToken.lastModified
|
||||
) {
|
||||
peer.connection.send({
|
||||
id: "tokenRequest",
|
||||
data: newToken.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.id === "tokenRequest") {
|
||||
const token = getToken(data.data);
|
||||
peer.connection.send({ id: "tokenResponse", data: token });
|
||||
}
|
||||
if (data.id === "tokenResponse") {
|
||||
const newToken = data.data;
|
||||
if (newToken && newToken.type === "file") {
|
||||
putToken(newToken);
|
||||
}
|
||||
}
|
||||
if (data.id === "tokenStateEdit") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: { ...prevMapState.tokens, ...data.data },
|
||||
}));
|
||||
}
|
||||
if (data.id === "tokenStateRemove") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: omit(prevMapState.tokens, Object.keys(data.data)),
|
||||
}));
|
||||
}
|
||||
if (data.id === "nickname") {
|
||||
setPartyNicknames((prevNicknames) => ({
|
||||
...prevNicknames,
|
||||
...data.data,
|
||||
}));
|
||||
}
|
||||
if (data.id === "mapDraw") {
|
||||
addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions");
|
||||
}
|
||||
if (data.id === "mapDrawIndex") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
mapDrawActionIndex: data.data,
|
||||
}));
|
||||
}
|
||||
if (data.id === "mapFog") {
|
||||
addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions");
|
||||
}
|
||||
if (data.id === "mapFogIndex") {
|
||||
setCurrentMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
fogDrawActionIndex: data.data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeerDataProgress({ id, total, count }) {
|
||||
if (count === 1) {
|
||||
assetLoadStart();
|
||||
}
|
||||
if (total === count) {
|
||||
assetLoadFinish();
|
||||
}
|
||||
assetProgressUpdate({ id, total, count });
|
||||
}
|
||||
|
||||
function handlePeerDisconnected(peer) {
|
||||
setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id]));
|
||||
}
|
||||
return () => {
|
||||
session.off("authenticationSuccess", handleAuthSuccess);
|
||||
session.off("authenticationError", handleAuthError);
|
||||
};
|
||||
}, [setAuthenticationStatus]);
|
||||
|
||||
// Handle session errors
|
||||
const [peerError, setPeerError] = useState(null);
|
||||
function handlePeerError({ error, peer }) {
|
||||
console.error(error.code);
|
||||
if (error.code === "ERR_WEBRTC_SUPPORT") {
|
||||
setPeerError("WebRTC not supported.");
|
||||
} else if (error.code === "ERR_CREATE_OFFER") {
|
||||
setPeerError("Unable to connect to party.");
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeerTrackAdded({ peer, stream: remoteStream }) {
|
||||
setPartyStreams((prevStreams) => ({
|
||||
...prevStreams,
|
||||
[peer.id]: remoteStream,
|
||||
}));
|
||||
}
|
||||
|
||||
function handlePeerTrackRemoved({ peer, stream: remoteStream }) {
|
||||
if (isStreamStopped(remoteStream)) {
|
||||
setPartyStreams((prevStreams) => omit(prevStreams, [peer.id]));
|
||||
} else {
|
||||
setPartyStreams((prevStreams) => ({
|
||||
...prevStreams,
|
||||
[peer.id]: remoteStream,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream handler
|
||||
*/
|
||||
|
||||
function handleStreamStart(localStream) {
|
||||
setStream(localStream);
|
||||
const tracks = localStream.getTracks();
|
||||
for (let track of tracks) {
|
||||
// Only add the audio track of the stream to the remote peer
|
||||
if (track.kind === "audio") {
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.addTrack(track, localStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamEnd = useCallback(
|
||||
(localStream) => {
|
||||
setStream(null);
|
||||
const tracks = localStream.getTracks();
|
||||
for (let track of tracks) {
|
||||
track.stop();
|
||||
// Only sending audio so only remove the audio track
|
||||
if (track.kind === "audio") {
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.removeTrack(track, localStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[peers]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
const tracks = stream.getTracks();
|
||||
// Detect when someone has ended the screen sharing
|
||||
// by looking at the streams video track onended
|
||||
// the audio track doesn't seem to trigger this event
|
||||
for (let track of tracks) {
|
||||
if (track.kind === "video") {
|
||||
track.onended = function () {
|
||||
handleStreamEnd(stream);
|
||||
};
|
||||
}
|
||||
function handlePeerError({ error }) {
|
||||
console.error(error.code);
|
||||
if (error.code === "ERR_WEBRTC_SUPPORT") {
|
||||
setPeerError("WebRTC not supported.");
|
||||
} else if (error.code === "ERR_CREATE_OFFER") {
|
||||
setPeerError("Unable to connect to party.");
|
||||
}
|
||||
}
|
||||
}, [stream, peers, handleStreamEnd]);
|
||||
session.on("error", handlePeerError);
|
||||
return () => {
|
||||
session.off("error", handlePeerError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle connection
|
||||
const [connected, setConnected] = useState(false);
|
||||
useEffect(() => {
|
||||
function handleConnected() {
|
||||
setConnected(true);
|
||||
}
|
||||
|
||||
function handleDisconnected() {
|
||||
setConnected(false);
|
||||
}
|
||||
|
||||
session.on("connected", handleConnected);
|
||||
session.on("disconnected", handleDisconnected);
|
||||
|
||||
return () => {
|
||||
session.off("connected", handleConnected);
|
||||
session.off("disconnected", handleDisconnected);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Join game
|
||||
useEffect(() => {
|
||||
session.joinParty(gameId, password);
|
||||
}, [gameId, password]);
|
||||
|
||||
// A ref to the Konva stage
|
||||
// the ref will be assigned in the MapInteraction component
|
||||
@ -519,34 +99,8 @@ function Game() {
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Party
|
||||
nickname={nickname}
|
||||
partyNicknames={partyNicknames}
|
||||
gameId={gameId}
|
||||
onNicknameChange={handleNicknameChange}
|
||||
stream={stream}
|
||||
partyStreams={partyStreams}
|
||||
onStreamStart={handleStreamStart}
|
||||
onStreamEnd={handleStreamEnd}
|
||||
/>
|
||||
<Map
|
||||
map={currentMap}
|
||||
mapState={currentMapState}
|
||||
onMapTokenStateChange={handleMapTokenStateChange}
|
||||
onMapTokenStateRemove={handleMapTokenStateRemove}
|
||||
onMapChange={handleMapChange}
|
||||
onMapStateChange={handleMapStateChange}
|
||||
onMapDraw={handleMapDraw}
|
||||
onMapDrawUndo={handleMapDrawUndo}
|
||||
onMapDrawRedo={handleMapDrawRedo}
|
||||
onFogDraw={handleFogDraw}
|
||||
onFogDrawUndo={handleFogDrawUndo}
|
||||
onFogDrawRedo={handleFogDrawRedo}
|
||||
allowMapDrawing={canEditMapDrawing}
|
||||
allowFogDrawing={canEditFogDrawing}
|
||||
disabledTokens={disabledMapTokens}
|
||||
/>
|
||||
<Tokens onMapTokenStateCreate={handleMapTokenStateCreate} />
|
||||
<NetworkedParty session={session} gameId={gameId} />
|
||||
<NetworkedMapAndTokens session={session} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>
|
||||
|
@ -55,7 +55,7 @@ function Home() {
|
||||
Join Game
|
||||
</Button>
|
||||
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
|
||||
Beta v1.4.1
|
||||
Beta v1.4.2
|
||||
</Text>
|
||||
<Button
|
||||
m={2}
|
||||
|
@ -15,6 +15,7 @@ const v132 = raw("../docs/releaseNotes/v1.3.2.md");
|
||||
const v133 = raw("../docs/releaseNotes/v1.3.3.md");
|
||||
const v140 = raw("../docs/releaseNotes/v1.4.0.md");
|
||||
const v141 = raw("../docs/releaseNotes/v1.4.1.md");
|
||||
const v142 = raw("../docs/releaseNotes/v1.4.2.md");
|
||||
|
||||
function ReleaseNotes() {
|
||||
return (
|
||||
@ -38,6 +39,11 @@ function ReleaseNotes() {
|
||||
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
|
||||
Release Notes
|
||||
</Text>
|
||||
<div id="v141">
|
||||
<Accordion heading="v1.4.2" defaultOpen>
|
||||
<Markdown source={v142} />
|
||||
</Accordion>
|
||||
</div>
|
||||
<div id="v141">
|
||||
<Accordion heading="v1.4.1" defaultOpen>
|
||||
<Markdown source={v141} />
|
||||
|
@ -10213,10 +10213,9 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
|
||||
integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
|
||||
|
||||
simple-peer@^9.6.2:
|
||||
version "9.6.2"
|
||||
resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.6.2.tgz#42418e77cf8f9184e4fa22ef1017b195c2bf84d7"
|
||||
integrity sha512-EOKoImCaqtNvXIntxT1CBBK/3pVi7tMAoJ3shdyd9qk3zLm3QPiRLb/sPC1G2xvKJkJc5fkQjCXqRZ0AknwTig==
|
||||
simple-peer@feross/simple-peer#694/head:
|
||||
version "9.7.2"
|
||||
resolved "https://codeload.github.com/feross/simple-peer/tar.gz/b8a4ec0210547414c52857343f10089e80710f03"
|
||||
dependencies:
|
||||
debug "^4.0.1"
|
||||
get-browser-rtc "^1.0.0"
|
||||
|
Loading…
Reference in New Issue
Block a user