Completed the audio panel

This commit is contained in:
Davide Passoni
2024-10-15 17:40:39 +02:00
parent 6cc1572b11
commit 065cdf3648
19 changed files with 772 additions and 486 deletions

View File

@@ -1,5 +1,4 @@
import React from "react";
import { ChangeEvent } from "react";
import { OlNumberInput } from "./olnumberinput";
export function OlFrequencyInput(props: { value: number; className?: string; onChange: (value: number) => void }) {
@@ -28,6 +27,7 @@ export function OlFrequencyInput(props: { value: number; className?: string; onC
props.onChange(frequency);
}}
value={Math.floor(frequency / 1000000)}
className="!min-w-28"
></OlNumberInput>
<div className="my-auto">.</div>
<OlNumberInput
@@ -49,8 +49,8 @@ export function OlFrequencyInput(props: { value: number; className?: string; onC
props.onChange(frequency);
}}
value={(frequency - Math.floor(frequency / 1000000) * 1000000) / 1000}
className="!min-w-28"
></OlNumberInput>
<div className="my-auto">MHz</div>
</div>
);
}

View File

@@ -1,19 +1,51 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Menu } from "./components/menu";
import { getApp } from "../../olympusapp";
import { FaQuestionCircle } from "react-icons/fa";
import { FaPlus, FaPlusCircle, FaQuestionCircle } from "react-icons/fa";
import { AudioSourcePanel } from "./components/sourcepanel";
import { AudioSource } from "../../audio/audiosource";
import { FaVolumeHigh, FaX } from "react-icons/fa6";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons";
import { RadioSinkPanel } from "./components/radiosinkpanel";
import { AudioSink } from "../../audio/audiosink";
import { RadioSink } from "../../audio/radiosink";
import { UnitSinkPanel } from "./components/unitsinkpanel";
import { UnitSink } from "../../audio/unitsink";
import { FaMinus, FaVolumeHigh } from "react-icons/fa6";
import { getRandomColor } from "../../other/utils";
let shortcutKeys = ["Z", "X", "C", "V", "B", "N", "M", "K", "L"];
export function AudioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
const [sinks, setSinks] = useState([] as AudioSink[]);
const [sources, setSources] = useState([] as AudioSource[]);
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
const [showTip, setShowTip] = useState(true);
const [activeSource, setActiveSource] = useState(null as AudioSource | null);
const [count, setCount] = useState(0);
/* Preallocate 128 references for the source and sink panels. If the number of references changes, React will give an error */
const sourceRefs = Array(128)
.fill(null)
.map(() => {
return useRef(null);
});
const sinkRefs = Array(128)
.fill(null)
.map(() => {
return useRef(null);
});
useEffect(() => {
/* Force a rerender */
document.addEventListener("audioSinksUpdated", () => {
setSinks(
getApp()
?.getAudioManager()
.getSinks()
.filter((sink) => sink instanceof AudioSink)
.map((radio) => radio)
);
});
/* Force a rerender */
document.addEventListener("audioSourcesUpdated", () => {
setSources(
@@ -29,97 +61,287 @@ export function AudioMenu(props: { open: boolean; onClose: () => void; children?
});
}, []);
/* When the sinks or sources change, use the count state to force a rerender to update the connection lines */
useEffect(() => {
setCount(count + 1);
}, [sinks, sources]);
/* List all the connections between the sinks and the sources */
const connections = [] as any[];
const lineCounters = [] as number[];
const lineColors = [] as string[];
let counter = 0;
sources.forEach((source, idx) => {
counter++;
const color = getRandomColor(counter);
source.getConnectedTo().forEach((sink) => {
if (sinks.indexOf(sink as AudioSink) !== undefined) {
connections.push([sourceRefs[idx], sinkRefs[sinks.indexOf(sink as AudioSink)]]);
lineCounters.push(counter);
lineColors.push(color);
}
});
});
/* Compute the line distance to fit in the available space */
const defaultLineDistance = 8;
const paddingRight = Math.min(lineCounters[lineCounters.length - 1] * defaultLineDistance + 40, 96);
const lineDistance = (paddingRight - 40) / lineCounters[lineCounters.length - 1];
return (
<Menu title="Audio sources" open={props.open} showBackButton={false} onClose={props.onClose}>
<div className="p-4 text-sm text-gray-400">The audio source panel allows you to add and manage audio sources.</div>
<Menu title="Audio menu" open={props.open} showBackButton={false} onClose={props.onClose}>
<div className="p-4 text-sm text-gray-400">
The audio menu allows you to add and manage audio sources, connect them to unit loudspeakers and radios, and to tune radio frequencies.
</div>
<>
{showTip && (
{!audioManagerEnabled && (
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
{audioManagerEnabled ? (
<>
<div className="my-auto">
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">Use the controls to apply effects and start/stop the playback of an audio source.</div>
<div className="text-gray-400">Sources can be connected to your radios, or attached to a unit to be played on loudspeakers.</div>
</div>
<div>
<FontAwesomeIcon
onClick={() => setShowTip(false)}
icon={faClose}
className={`
ml-2 flex cursor-pointer items-center justify-center
rounded-md p-2 text-lg
dark:text-gray-500 dark:hover:bg-gray-700
dark:hover:text-white
hover:bg-gray-200
`}
/>
</div>
</>
) : (
<>
<div className="my-auto">
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">
To enable the audio menu, first start the audio backend with the{" "}
<span
className={`
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
border-[1px] border-white p-1
`}
>
<FaVolumeHigh />
</span>{" "}
button on the navigation header.
</div>
</div>
</>
)}
<div>
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">
To enable the audio menu, first start the audio backend with the{" "}
<span
className={`
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
border-[1px] border-white p-1
`}
>
<FaVolumeHigh />
</span>{" "}
button on the navigation header.
</div>
</div>
</div>
)}
</>
<div
className={`
flex flex-col gap-2 p-5 font-normal text-gray-800
dark:text-white
`}
>
<>
{sources.map((source, idx) => {
return <AudioSourcePanel key={idx} source={source} />;
<div className="flex flex-col gap-3">
<div
className={`
flex flex-col gap-2 px-5 font-normal text-gray-800
dark:text-white
`}
style={{ paddingRight: `${paddingRight}px` }}
>
{audioManagerEnabled && <span>Audio sources</span>}
<>
{sources.map((source, idx) => {
return (
<AudioSourcePanel
key={idx}
source={source}
ref={sourceRefs[idx]}
onExpanded={() => {
setCount(count + 1);
}}
/>
);
})}
</>
{audioManagerEnabled && (
<button
type="button"
className={`
mb-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
text-white
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
`}
onClick={() => {
var input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = (e: Event) => {
let target = e.target as HTMLInputElement;
if (target && target.files) {
var file = target.files[0];
getApp().getAudioManager().addFileSource(file);
}
};
}}
>
Add audio source
</button>
)}
</div>
<div
className={`
flex flex-col gap-2 px-5 font-normal text-gray-800
dark:text-white
`}
style={{ paddingRight: `${paddingRight}px` }}
>
{audioManagerEnabled && <span>Radios</span>}
{sinks.map((sink, idx) => {
if (sink instanceof RadioSink)
return (
<RadioSinkPanel
shortcutKey={shortcutKeys[idx]}
key={sink.getName()}
radio={sink}
onExpanded={() => {
setCount(count + 1);
}}
ref={sinkRefs[idx]}
></RadioSinkPanel>
);
})}
</>
{audioManagerEnabled && (
<button
type="button"
className={`
mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
text-white
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
`}
onClick={() => {
var input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = (e: Event) => {
let target = e.target as HTMLInputElement;
if (target && target.files) {
var file = target.files[0];
getApp().getAudioManager().addFileSource(file);
}
{audioManagerEnabled && sinks.filter((sink) => sink instanceof RadioSink).length < 10 && (
<button
type="button"
className={`
mb-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
text-white
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
`}
onClick={() => getApp().getAudioManager().addRadio()}
>
Add radio
</button>
)}
</div>
<div
className={`
flex flex-col gap-2 px-5 font-normal text-gray-800
dark:text-white
`}
style={{ paddingRight: `${paddingRight}px` }}
>
{audioManagerEnabled && sinks.find((sink) => sink instanceof UnitSink) && <span>Unit loudspeakers</span>}
{sinks.map((sink, idx) => {
if (sink instanceof UnitSink)
return (
<UnitSinkPanel
shortcutKey={shortcutKeys[idx]}
key={sink.getName()}
sink={sink}
ref={sinkRefs[idx]}
onExpanded={() => {
setCount(count + 1);
}}
></UnitSinkPanel>
);
})}
</div>
<div>
{connections
.filter((connection) => connection && connection[0].current && connection[1].current)
.map((connection, idx) => {
const start = connection[0].current;
const end = connection[1].current;
if (start && end) {
const startRect = {
top: (start as HTMLDivElement).offsetTop,
bottom: (start as HTMLDivElement).offsetTop + (start as HTMLDivElement).clientHeight,
height: (start as HTMLDivElement).clientHeight,
right: (start as HTMLDivElement).offsetLeft + (start as HTMLDivElement).clientWidth,
};
const endRect = {
top: (end as HTMLDivElement).offsetTop,
bottom: (end as HTMLDivElement).offsetTop + (end as HTMLDivElement).clientHeight,
height: (end as HTMLDivElement).clientHeight,
right: (end as HTMLDivElement).offsetLeft + (end as HTMLDivElement).clientWidth,
};
return (
<>
<div
className={`
absolute rounded-br-md rounded-tr-md border-2 border-l-0
`}
style={{
top: `${(startRect.bottom + startRect.top) / 2}px`,
left: `${startRect.right}px`,
height: `${endRect.top - startRect.top + (endRect.height - startRect.height) / 2}px`,
width: `${(lineCounters[idx] - 1) * lineDistance + 30}px`,
borderColor: lineColors[idx],
}}
></div>
</>
);
}
})
.reverse()}
</div>
<div>
{sourceRefs.map((sourceRef, idx) => {
const div = sourceRef.current;
if (div) {
const divRect = {
top: (div as HTMLDivElement).offsetTop,
bottom: (div as HTMLDivElement).offsetTop + (div as HTMLDivElement).clientHeight,
height: (div as HTMLDivElement).clientHeight,
right: (div as HTMLDivElement).offsetLeft + (div as HTMLDivElement).clientWidth,
};
}}
>
Add audio source
</button>
)}
return (
<>
<div>
<div
data-active={activeSource === sources[idx]}
className={`
absolute translate-y-[-50%] cursor-pointer rounded-full
bg-blue-600 p-1 text-xs text-white
data-[active='true']:bg-white
data-[active='true']:text-blue-600
hover:bg-blue-800
`}
style={{
top: `${(divRect.bottom + divRect.top) / 2}px`,
left: `${divRect.right - 10}px`,
}}
onClick={() => {
activeSource !== sources[idx] ? setActiveSource(sources[idx]) : setActiveSource(null);
}}
>
<FaPlus></FaPlus>
</div>
</div>
</>
);
}
})}
{activeSource &&
sinkRefs.map((sinkRef, idx) => {
const div = sinkRef.current;
if (div) {
const divRect = {
top: (div as HTMLDivElement).offsetTop,
bottom: (div as HTMLDivElement).offsetTop + (div as HTMLDivElement).clientHeight,
height: (div as HTMLDivElement).clientHeight,
right: (div as HTMLDivElement).offsetLeft + (div as HTMLDivElement).clientWidth,
};
return (
<>
<div>
<div
className={`
absolute translate-y-[-50%] cursor-pointer
rounded-full bg-blue-600 p-1 text-xs text-white
hover:bg-blue-800
`}
style={{
top: `${(divRect.bottom + divRect.top) / 2}px`,
left: `${divRect.right - 10}px`,
}}
onClick={() => {
if (activeSource.getConnectedTo().includes(sinks[idx])) activeSource.disconnect(sinks[idx]);
else activeSource.connect(sinks[idx]);
}}
>
{" "}
{activeSource.getConnectedTo().includes(sinks[idx]) ? <FaMinus></FaMinus> : <FaPlus></FaPlus>}
</div>
</div>
</>
);
}
})}
</div>
</div>
</Menu>
);

View File

@@ -1,75 +0,0 @@
import React, { useEffect, useState } from "react";
import { OlFrequencyInput } from "../../components/olfrequencyinput";
import { FaTrash } from "react-icons/fa6";
import { OlLabelToggle } from "../../components/ollabeltoggle";
import { OlStateButton } from "../../components/olstatebutton";
import { faEarListen, faMicrophoneLines } from "@fortawesome/free-solid-svg-icons";
import { RadioSink } from "../../../audio/radiosink";
import { getApp } from "../../../olympusapp";
export function RadioPanel(props: { radio: RadioSink; shortcutKey: string }) {
return (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 py-3 pl-4 pr-5
`}
>
<div className="flex content-center justify-between">
<span className="my-auto">{props.radio.getName()}</span>
<div
className="cursor-pointer rounded-md bg-red-800 p-2"
onClick={() => {
getApp().getAudioManager().removeSink(props.radio);
}}
>
<FaTrash className={`text-gray-50`}></FaTrash>
</div>
</div>
<OlFrequencyInput
value={props.radio.getFrequency()}
onChange={(value) => {
props.radio.setFrequency(value);
}}
/>
<div className="flex flex-row gap-2">
<OlLabelToggle
leftLabel="AM"
rightLabel="FM"
toggled={props.radio.getModulation() !== 0}
onClick={() => {
props.radio.setModulation(props.radio.getModulation() === 1 ? 0 : 1);
}}
></OlLabelToggle>
<kbd
className={`
my-auto ml-auto rounded-lg border border-gray-200 bg-gray-100 px-2
py-1.5 text-xs font-semibold text-gray-800
dark:border-gray-500 dark:bg-gray-600 dark:text-gray-100
`}
>
{props.shortcutKey}
</kbd>
<OlStateButton
checked={props.radio.getPtt()}
icon={faMicrophoneLines}
onClick={() => {
props.radio.setPtt(!props.radio.getPtt());
}}
tooltip="Talk on frequency"
></OlStateButton>
<OlStateButton
checked={props.radio.getTuned()}
icon={faEarListen}
onClick={() => {
props.radio.setTuned(!props.radio.getTuned());
}}
tooltip="Tune to radio"
></OlStateButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from "react";
import { OlFrequencyInput } from "../../components/olfrequencyinput";
import { FaChevronUp, FaXmark } from "react-icons/fa6";
import { OlLabelToggle } from "../../components/ollabeltoggle";
import { OlStateButton } from "../../components/olstatebutton";
import { faEarListen, faMicrophoneLines } from "@fortawesome/free-solid-svg-icons";
import { RadioSink } from "../../../audio/radiosink";
import { getApp } from "../../../olympusapp";
export const RadioSinkPanel = forwardRef((props: { radio: RadioSink; shortcutKey: string; onExpanded: () => void }, ref: ForwardedRef<HTMLDivElement>) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.onExpanded) props.onExpanded();
}, [expanded])
return (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 px-4 py-3
`}
ref={ref}
>
<div className="flex content-center justify-between gap-2">
<div
className={`h-fit w-fit cursor-pointer rounded-sm py-2`}
onClick={() => {
setExpanded(!expanded);
}}
>
<FaChevronUp
className={`
text-gray-500 transition-transform
data-[expanded='false']:rotate-180
`}
data-expanded={expanded}
/>
</div>
{props.shortcutKey && (<>
<kbd
className={`
my-auto ml-auto rounded-lg border border-gray-200 bg-gray-100 px-2
py-1.5 text-xs font-semibold text-gray-800
dark:border-gray-500 dark:bg-gray-600 dark:text-gray-100
`}
>
{props.shortcutKey}
</kbd>
</>
)}
<span className="my-auto w-full">{props.radio.getName()} {!expanded && `: ${props.radio.getFrequency()/1e6} MHz ${props.radio.getModulation()? "FM": "AM"}`} {} </span>
<div
className={`
mb-auto ml-auto aspect-square cursor-pointer rounded-md p-2
hover:bg-white/10
`}
onClick={() => {
getApp().getAudioManager().removeSink(props.radio);
}}
>
<FaXmark className={`my-auto text-gray-500`}></FaXmark>
</div>
</div>
{expanded && (
<>
<OlFrequencyInput
value={props.radio.getFrequency()}
onChange={(value) => {
props.radio.setFrequency(value);
}}
/>
<div className="flex flex-row gap-2">
<OlLabelToggle
leftLabel="AM"
rightLabel="FM"
toggled={props.radio.getModulation() !== 0}
onClick={() => {
props.radio.setModulation(props.radio.getModulation() === 1 ? 0 : 1);
}}
></OlLabelToggle>
<OlStateButton
className="ml-auto"
checked={props.radio.getPtt()}
icon={faMicrophoneLines}
onClick={() => {
props.radio.setPtt(!props.radio.getPtt());
}}
tooltip="Talk on frequency"
></OlStateButton>
<OlStateButton
checked={props.radio.getTuned()}
icon={faEarListen}
onClick={() => {
props.radio.setTuned(!props.radio.getTuned());
}}
tooltip="Tune to radio"
></OlStateButton>
</div>
</>
)}
</div>
);
});

View File

@@ -1,145 +1,131 @@
import React, { useEffect, useState } from "react";
import React, { ForwardedRef, forwardRef, useEffect, useState } from "react";
import { OlStateButton } from "../../components/olstatebutton";
import { faPause, faPlay, faRepeat, faStop } from "@fortawesome/free-solid-svg-icons";
import { getApp } from "../../../olympusapp";
import { AudioSource } from "../../../audio/audiosource";
import { FaArrowRight, FaTrash, FaVolumeHigh } from "react-icons/fa6";
import { FaChevronUp, FaVolumeHigh, FaXmark } from "react-icons/fa6";
import { OlRangeSlider } from "../../components/olrangeslider";
import { FaUnlink } from "react-icons/fa";
import { OlDropdown, OlDropdownItem } from "../../components/oldropdown";
import { FileSource } from "../../../audio/filesource";
import { MicrophoneSource } from "../../../audio/microphonesource";
export function AudioSourcePanel(props: { source: AudioSource }) {
export const AudioSourcePanel = forwardRef((props: { source: AudioSource; onExpanded: () => void }, ref: ForwardedRef<HTMLDivElement>) => {
const [meterLevel, setMeterLevel] = useState(0);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.onExpanded) props.onExpanded();
}, [expanded])
useEffect(() => {
setInterval(() => {
setMeterLevel(Math.min(100, props.source.getMeter().getPeaks().current[0]));
setMeterLevel(Math.min(1, props.source.getMeter().getPeaks().current[0]));
}, 50);
}, []);
let availabileSinks = getApp()
.getAudioManager()
.getSinks()
.filter((sink) => !props.source.getConnectedTo().includes(sink));
return (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 py-3 pl-4 pr-5
bg-olympus-200/30 px-4 py-3
`}
ref={ref}
>
<div className="flex justify-between gap-2">
<span className="break-all">{props.source.getName()}</span>
<div
className={`h-fit w-fit cursor-pointer rounded-sm py-2`}
onClick={() => {
setExpanded(!expanded);
}}
>
<FaChevronUp
className={`
text-gray-500 transition-transform
data-[expanded='false']:rotate-180
`}
data-expanded={expanded}
/>
</div>
<div className="flex w-full overflow-hidden">
<span
className={`my-auto truncate`}
>
{props.source.getName()}
</span>
</div>
{!(props.source instanceof MicrophoneSource) && (
<div
className={`
mb-auto aspect-square cursor-pointer rounded-md bg-red-800 p-2
mb-auto aspect-square cursor-pointer rounded-md p-2
hover:bg-white/10
`}
onClick={() => {
getApp().getAudioManager().removeSource(props.source);
}}
>
<FaTrash className={`text-gray-50`}></FaTrash>
<FaXmark className={`my-auto text-gray-500`}></FaXmark>
</div>
)}
</div>
<div className="flex flex-col gap-2 rounded-md bg-olympus-400 p-2">
{props.source instanceof FileSource && (
<div className="flex gap-4">
<OlStateButton
checked={false}
icon={props.source.getPlaying() ? faPause : faPlay}
onClick={() => {
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.pause() : props.source.play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={props.source.getDuration() > 0 ? (props.source.getCurrentPosition() / props.source.getDuration()) * 100 : 0}
onChange={(ev) => {
if (props.source instanceof FileSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value));
}}
className="my-auto"
/>
<OlStateButton
checked={props.source.getLooping()}
icon={faRepeat}
onClick={() => {
if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping());
}}
tooltip="Loop"
></OlStateButton>
</div>
)}
<div className="flex gap-4">
<div className="h-[40px] min-w-[40px] p-2">
<FaVolumeHigh className="h-full w-full" />
</div>
<div className="relative flex w-full flex-col gap-3">
<div
className={`
absolute top-[18px] flex h-2 min-w-full translate-y-[-5px]
flex-row border-gray-500
`}
>
<div style={{ minWidth: `${meterLevel * 100}%` }} className={`
rounded-full bg-gray-200
`}></div>
{expanded && (
<>
{props.source instanceof FileSource && (
<div className="flex flex-col gap-2 rounded-md bg-olympus-400 p-2">
<div className="flex gap-4">
<OlStateButton
checked={false}
icon={props.source.getPlaying() ? faPause : faPlay}
onClick={() => {
if (props.source instanceof FileSource) props.source.getPlaying() ? props.source.pause() : props.source.play();
}}
tooltip="Play file"
></OlStateButton>
<OlRangeSlider
value={props.source.getDuration() > 0 ? (props.source.getCurrentPosition() / props.source.getDuration()) * 100 : 0}
onChange={(ev) => {
if (props.source instanceof FileSource) props.source.setCurrentPosition(parseFloat(ev.currentTarget.value));
}}
className="my-auto"
/>
<OlStateButton
checked={props.source.getLooping()}
icon={faRepeat}
onClick={() => {
if (props.source instanceof FileSource) props.source.setLooping(!props.source.getLooping());
}}
tooltip="Loop"
></OlStateButton>
</div>
</div>
<OlRangeSlider
value={props.source.getVolume() * 100}
min={0}
max={200}
onChange={(ev) => {
props.source.setVolume(parseFloat(ev.currentTarget.value) / 100);
}}
className="absolute top-[18px]"
/>
</div>
<div className="h-[40px] min-w-[60px] p-2 text-md">
<span>{Math.round(props.source.getVolume() * 100)}%</span>
</div>
</div>
</div>
)}
<span className="text-sm">Connected to:</span>
<div className="flex flex-col gap-1">
{props.source.getConnectedTo().map((sink, idx) => {
return (
<div
key={idx}
className={`
flex justify-start gap-2 rounded-full bg-olympus-400 px-4 py-1
text-sm
`}
>
<FaArrowRight className="my-auto"></FaArrowRight>
{sink.getName()}
<FaUnlink className="my-auto ml-auto cursor-pointer text-red-400" onClick={() => props.source.disconnect(sink)}></FaUnlink>
<div className="flex gap-4 pr-2">
<div className="h-[40px] min-w-[40px] p-2">
<FaVolumeHigh className="h-full w-full" />
</div>
);
})}
</div>
{availabileSinks.length > 0 && (
<OlDropdown label="Connect to:">
{availabileSinks.map((sink, idx) => {
return (
<OlDropdownItem
key={idx}
onClick={() => {
props.source.connect(sink);
}}
<div className="relative flex w-full flex-col gap-3">
<div
className={`
absolute top-[18px] flex h-2 min-w-full translate-y-[-5px]
flex-row border-gray-500
`}
>
{sink.getName()}
</OlDropdownItem>
);
})}
</OlDropdown>
<div style={{ minWidth: `${meterLevel * 100}%` }} className={`
rounded-full bg-gray-200
`}></div>
</div>
<OlRangeSlider
value={props.source.getVolume() * 100}
min={0}
max={200}
onChange={(ev) => {
props.source.setVolume(parseFloat(ev.currentTarget.value) / 100);
}}
className="absolute top-[18px]"
/>
</div>
</div>
</>
)}
</div>
);
}
});

View File

@@ -0,0 +1,91 @@
import React, { ForwardedRef, forwardRef, useEffect, useState } from "react";
import { FaChevronUp, FaXmark } from "react-icons/fa6";
import { getApp } from "../../../olympusapp";
import { UnitSink } from "../../../audio/unitsink";
import { OlStateButton } from "../../components/olstatebutton";
import { faMicrophoneLines } from "@fortawesome/free-solid-svg-icons";
import { OlRangeSlider } from "../../components/olrangeslider";
export const UnitSinkPanel = forwardRef((props: { sink: UnitSink; shortcutKey: string; onExpanded: () => void }, ref: ForwardedRef<HTMLDivElement>) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (props.onExpanded) props.onExpanded();
}, [expanded]);
return (
<div
className={`
flex flex-col content-center justify-between gap-2 rounded-md
bg-olympus-200/30 px-4 py-3
`}
ref={ref}
>
<div className="flex content-center justify-between gap-2">
<div
className={`h-fit w-fit cursor-pointer rounded-sm py-2`}
onClick={() => {
setExpanded(!expanded);
}}
>
<FaChevronUp
className={`
text-gray-500 transition-transform
data-[expanded='false']:rotate-180
`}
data-expanded={expanded}
/>
</div>
{props.shortcutKey && (<>
<kbd
className={`
my-auto ml-auto rounded-lg border border-gray-200 bg-gray-100 px-2
py-1.5 text-xs font-semibold text-gray-800
dark:border-gray-500 dark:bg-gray-600 dark:text-gray-100
`}
>
{props.shortcutKey}
</kbd>
</>
)}
<div className="flex w-full overflow-hidden">
<span className="my-auto truncate"> {props.sink.getName()}</span>
</div>
<div
className={`
mb-auto aspect-square cursor-pointer rounded-md p-2
hover:bg-white/10
`}
onClick={() => {
getApp().getAudioManager().removeSink(props.sink);
}}
>
<FaXmark className={`my-auto text-gray-500`}></FaXmark>
</div>
</div>
{expanded && (
<div className="flex flex-row gap-2">
<span className="my-auto">Near</span>
<OlRangeSlider
value={((props.sink.getMaxDistance() - 100) / (1852 - 100)) * 100}
min={0}
max={100}
onChange={(ev) => {
props.sink.setMaxDistance((parseFloat(ev.currentTarget.value) / 100) * (1852 - 100) + 100);
}}
className="my-auto h-16"
/>
<span className="my-auto">Far</span>
<OlStateButton
checked={props.sink.getPtt()}
icon={faMicrophoneLines}
onClick={() => {
props.sink.setPtt(!props.sink.getPtt());
}}
tooltip="Talk on frequency"
></OlStateButton>
</div>
)}
</div>
);
});

View File

@@ -1,116 +0,0 @@
import React, { useEffect, useState } from "react";
import { Menu } from "./components/menu";
import { getApp } from "../../olympusapp";
import { RadioPanel } from "./components/radiopanel";
import { FaQuestionCircle } from "react-icons/fa";
import { RadioSink } from "../../audio/radiosink";
import { FaVolumeHigh } from "react-icons/fa6";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons";
let shortcutKeys = ["Z", "X", "C", "V", "B", "N", "M", "K", "L"];
export function RadioMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) {
const [radios, setRadios] = useState([] as RadioSink[]);
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
const [showTip, setShowTip] = useState(true);
useEffect(() => {
/* Force a rerender */
document.addEventListener("audioSinksUpdated", () => {
setRadios(
getApp()
?.getAudioManager()
.getSinks()
.filter((sink) => sink instanceof RadioSink)
.map((radio) => radio)
);
});
document.addEventListener("audioManagerStateChanged", () => {
setAudioManagerEnabled(getApp().getAudioManager().isRunning());
});
}, []);
return (
<Menu title="Radio" open={props.open} showBackButton={false} onClose={props.onClose}>
<div className="p-4 text-sm text-gray-400">The radio menu allows you to talk on radio to the players online using SRS.</div>
<>
{showTip && (
<div className="mx-6 flex rounded-lg bg-olympus-400 p-4 text-sm">
{audioManagerEnabled ? (
<>
<div className="my-auto">
<FaQuestionCircle className="my-auto ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">Use the radio controls to tune to a frequency, then click on the PTT button to talk. </div>
<div className="text-gray-400">You can add up to 10 radios. Use the audio effects menu to play audio tracks or to add background noises.</div>
</div>
<div>
<FontAwesomeIcon
onClick={() => setShowTip(false)}
icon={faClose}
className={`
ml-2 flex cursor-pointer items-center justify-center
rounded-md p-2 text-lg
dark:text-gray-500 dark:hover:bg-gray-700
dark:hover:text-white
hover:bg-gray-200
`}
/>
</div>
</>
) : (
<>
<div>
<FaQuestionCircle className="my-4 ml-2 mr-6 text-gray-400" />
</div>
<div className="flex flex-col gap-1">
<div className="text-gray-100">
To enable the radio menu, first start the audio backend with the{" "}
<span
className={`
mx-1 mt-[-7px] inline-block translate-y-2 rounded-full
border-[1px] border-white p-1
`}
>
<FaVolumeHigh />
</span>{" "}
button on the navigation header.
</div>
</div>
</>
)}
</div>
)}
</>
<div
className={`
flex flex-col gap-2 p-5 font-normal text-gray-800
dark:text-white
`}
>
{radios.map((radio, idx) => {
return <RadioPanel shortcutKey={shortcutKeys[idx]} key={radio.getName()} radio={radio}></RadioPanel>;
})}
{audioManagerEnabled && radios.length < 10 && (
<button
type="button"
className={`
mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium
text-white
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800
focus:outline-none focus:ring-4 focus:ring-blue-300
hover:bg-blue-800
`}
onClick={() => getApp().getAudioManager().addRadio()}
>
Add radio
</button>
)}
</div>
</Menu>
);
}

View File

@@ -52,12 +52,6 @@ export function SideBar() {
icon={faPencil}
tooltip="Hide/show drawing menu"
></OlStateButton>
<OlStateButton
onClick={events.toggleRadioMenuVisible}
checked={appState.radioMenuVisible}
icon={faRadio}
tooltip="Hide/show radio menu"
></OlStateButton>
<OlStateButton
onClick={events.toggleAudioMenuVisible}
checked={appState.audioMenuVisible}

View File

@@ -44,7 +44,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OlSearchBar } from "../components/olsearchbar";
import { OlDropdown, OlDropdownItem } from "../components/oldropdown";
import { UnitBlueprint } from "../../interfaces";
import { FaRadio } from "react-icons/fa6";
import { FaRadio, FaVolumeHigh } from "react-icons/fa6";
import { OlNumberInput } from "../components/olnumberinput";
import { Radio, TACAN } from "../../interfaces";
import { OlStringInput } from "../components/olstringinput";
@@ -105,6 +105,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
const [filterString, setFilterString] = useState("");
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [activeAdvancedSettings, setActiveAdvancedSettings] = useState(null as null | { radio: Radio; TACAN: TACAN });
const [audioManagerEnabled, setAudioManagerEnabled] = useState(false);
var searchBarRef = useRef(null);
@@ -130,6 +131,10 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
document.addEventListener("clearSelection", () => {
setSelectedUnits([]);
});
document.addEventListener("audioManagerStateChanged", () => {
setAudioManagerEnabled(getApp().getAudioManager().isRunning());
});
}, []);
/* Update the current values of the shown data */
@@ -545,7 +550,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex flex-col">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -608,7 +613,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex flex-col">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -664,7 +669,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex flex-col gap-2">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -702,7 +707,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className={`flex flex-col gap-2`}>
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -734,7 +739,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex flex-col gap-2">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -773,7 +778,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -807,7 +812,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -871,7 +876,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -896,7 +901,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -922,7 +927,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className={`flex flex-col gap-2`}>
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -954,7 +959,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex flex-col gap-2">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -987,7 +992,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -1012,7 +1017,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -1036,7 +1041,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
@@ -1062,40 +1067,55 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
<div className="flex content-center justify-between">
<span
className={`
font-normal
my-auto font-normal
dark:text-white
`}
>
Enable loudspeakers
Loudspeakers
</span>
<OlToggle
toggled={selectedUnitsData.isAudioSink}
onClick={() => {
selectedUnits.forEach((unit) => {
if (!selectedUnitsData.isAudioSink) {
getApp()?.getAudioManager().addUnitSink(unit);
setSelectedUnitsData({
...selectedUnitsData,
isAudioSink: true,
});
} else {
let sink = getApp()
?.getAudioManager()
.getSinks()
.find((sink) => {
return sink instanceof UnitSink && sink.getUnit() === unit;
{audioManagerEnabled ? (
<OlToggle
toggled={selectedUnitsData.isAudioSink}
onClick={() => {
selectedUnits.forEach((unit) => {
if (!selectedUnitsData.isAudioSink) {
getApp()?.getAudioManager().addUnitSink(unit);
setSelectedUnitsData({
...selectedUnitsData,
isAudioSink: true,
});
if (sink !== undefined) getApp()?.getAudioManager().removeSink(sink);
} else {
let sink = getApp()
?.getAudioManager()
.getSinks()
.find((sink) => {
return sink instanceof UnitSink && sink.getUnit() === unit;
});
if (sink !== undefined) getApp()?.getAudioManager().removeSink(sink);
setSelectedUnitsData({
...selectedUnitsData,
isAudioSink: false,
});
}
});
}}
/>
setSelectedUnitsData({
...selectedUnitsData,
isAudioSink: false,
});
}
});
}}
/>
) : (
<div className="text-white">
Enable audio with{" "}
<span
className={`
mx-1 mt-[-7px] inline-block translate-y-2
rounded-full border-[1px] border-white p-1
`}
>
<FaVolumeHigh />
</span>{" "}first
</div>
)}
</div>
{/* ============== Audio sink toggle END ============== */}
</div>
)}
@@ -1196,10 +1216,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
value={activeAdvancedSettings ? activeAdvancedSettings.TACAN.channel : 1}
></OlNumberInput>
<OlDropdown
label={activeAdvancedSettings ? activeAdvancedSettings.TACAN.XY : "X"}
className={`my-auto w-20`}
>
<OlDropdown label={activeAdvancedSettings ? activeAdvancedSettings.TACAN.XY : "X"} className={`
my-auto w-20
`}>
<OlDropdownItem
key={"X"}
onClick={() => {
@@ -1318,11 +1337,9 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
className={`
flex content-center gap-2 rounded-full
${selectedUnits[0].getFuel() > 40 && `bg-green-700`}
${
selectedUnits[0].getFuel() > 10 &&
selectedUnits[0].getFuel() <= 40 &&
`bg-yellow-700`
}
${selectedUnits[0].getFuel() > 10 && selectedUnits[0].getFuel() <= 40 && `
bg-yellow-700
`}
${selectedUnits[0].getFuel() <= 10 && `bg-red-700`}
px-2 py-1 text-sm font-bold text-white
`}

View File

@@ -22,7 +22,6 @@ import { ControlsPanel } from "./panels/controlspanel";
import { MapContextMenu } from "./contextmenus/mapcontextmenu";
import { AirbaseMenu } from "./panels/airbasemenu";
import { Airbase } from "../mission/airbase";
import { RadioMenu } from "./panels/radiomenu";
import { AudioMenu } from "./panels/audiomenu";
import { FormationMenu } from "./panels/formationmenu";
import { Unit } from "../unit/unit";
@@ -48,7 +47,6 @@ export function UI() {
const [unitControlMenuVisible, setUnitControlMenuVisible] = useState(false);
const [measureMenuVisible, setMeasureMenuVisible] = useState(false);
const [drawingMenuVisible, setDrawingMenuVisible] = useState(false);
const [radioMenuVisible, setRadioMenuVisible] = useState(false);
const [audioMenuVisible, setAudioMenuVisible] = useState(false);
const [optionsMenuVisible, setOptionsMenuVisible] = useState(false);
const [airbaseMenuVisible, setAirbaseMenuVisible] = useState(false);
@@ -134,7 +132,6 @@ export function UI() {
setDrawingMenuVisible(false);
setOptionsMenuVisible(false);
setAirbaseMenuVisible(false);
setRadioMenuVisible(false);
setAudioMenuVisible(false);
setFormationMenuVisible(false);
setUnitExplosionMenuVisible(false);
@@ -186,7 +183,6 @@ export function UI() {
drawingMenuVisible: drawingMenuVisible,
optionsMenuVisible: optionsMenuVisible,
airbaseMenuVisible: airbaseMenuVisible,
radioMenuVisible: radioMenuVisible,
audioMenuVisible: audioMenuVisible,
mapOptions: mapOptions,
mapHiddenTypes: mapHiddenTypes,
@@ -204,7 +200,6 @@ export function UI() {
setMeasureMenuVisible: setMeasureMenuVisible,
setOptionsMenuVisible: setOptionsMenuVisible,
setAirbaseMenuVisible: setAirbaseMenuVisible,
setRadioMenuVisible: setRadioMenuVisible,
setAudioMenuVisible: setAudioMenuVisible,
toggleMainMenuVisible: () => {
hideAllMenus();
@@ -234,10 +229,6 @@ export function UI() {
hideAllMenus();
setAirbaseMenuVisible(!airbaseMenuVisible);
},
toggleRadioMenuVisible: () => {
hideAllMenus();
setRadioMenuVisible(!radioMenuVisible);
},
toggleAudioMenuVisible: () => {
hideAllMenus();
setAudioMenuVisible(!audioMenuVisible);
@@ -295,7 +286,6 @@ export function UI() {
<UnitControlMenu open={unitControlMenuVisible} onClose={() => setUnitControlMenuVisible(false)} />
<DrawingMenu open={drawingMenuVisible} onClose={() => setDrawingMenuVisible(false)} />
<AirbaseMenu open={airbaseMenuVisible} onClose={() => setAirbaseMenuVisible(false)} airbase={airbase} />
<RadioMenu open={radioMenuVisible} onClose={() => setRadioMenuVisible(false)} />
<AudioMenu open={audioMenuVisible} onClose={() => setAudioMenuVisible(false)} />
<FormationMenu open={formationMenuVisible} leader={formationLeader} wingmen={formationWingmen} onClose={() => setFormationMenuVisible(false)} />
<UnitExplosionMenu open={unitExplosionMenuVisible} units={unitExplosionUnits} onClose={() => setUnitExplosionMenuVisible(false)} />