Merge pull request #197 from UniversityRadioYork/mstratford/pfl-player

This commit is contained in:
Marks Polakovs 2021-01-30 19:40:48 +00:00 committed by GitHub
commit 6ed6929984
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 589 additions and 417 deletions

View file

@ -26,22 +26,34 @@ $number-of-channels: 3;
.hover-menu {
.hover-label {
display: none;
&.always-show {
display: block;
}
}
}
@media (max-height: 900px) {
.hide-low-height {
display: none;
}
@media (max-height: 900px) {
.hover-label {
font-size: 10px;
margin-top: -5px;
display: block;
width: 100%;
.item {
font-size: 0.92em;
}
.hover-menu {
.btn {
padding-top: 0.1em;
padding-bottom: 0.1em;
font-size: 0.75em;
}
&:hover {
height: 100%;
.hover-label-hide {
display: none;
}
.hover-label {
font-size: 11px;
margin-top: -4px;
display: block;
width: 100%;
}
&:not(:hover) {

View file

@ -8,7 +8,17 @@ import NewsEndCountdown from "../assets/audio/NewsEndCountdown.wav";
import NewsIntro from "../assets/audio/NewsIntro.wav";
import StereoAnalyserNode from "stereo-analyser-node";
import { DEFAULT_TRIM_DB, OFF_LEVEL_DB } from "./state";
export const DEFAULT_TRIM_DB = -6; // The default trim applied to channel players.
export const OFF_LEVEL_DB = -40;
export const BED_LEVEL_DB = -13;
export const FULL_LEVEL_DB = 0;
export const PLAYER_COUNT = 4; // (3 channels + PFL Preview)
export const PLAYER_ID_PREVIEW = 3; // Player 3 (zero index) is the Preview Player
export const INTERNAL_OUTPUT_ID = "internal";
interface PlayerEvents {
loadComplete: (duration: number) => void;
@ -178,19 +188,22 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) {
}
_connectPFL() {
if (this.pfl) {
// In this case, we just want to route the player output to the headphones direct.
// Tap it from analyser to avoid the player volume.
(this.wavesurfer as any).backend.analyser.connect(
this.engine.headphonesNode
);
} else {
try {
(this.wavesurfer as any).backend.analyser.disconnect(
// We check the existence of the analyser here, because if we're using a custom output, this won't exist.
if ((this.wavesurfer as any).backend.analyser) {
if (this.pfl) {
// In this case, we just want to route the player output to the headphones direct.
// Tap it from analyser to avoid the player volume.
(this.wavesurfer as any).backend.analyser.connect(
this.engine.headphonesNode
);
} catch (e) {
// This connection wasn't connected anyway, ignore.
} else {
try {
(this.wavesurfer as any).backend.analyser.disconnect(
this.engine.headphonesNode
);
} catch (e) {
// This connection wasn't connected anyway, ignore.
}
}
}
}
@ -203,7 +216,7 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) {
url: string
) {
// If we want to output to a custom audio device, we're gonna need to do things differently.
const customOutput = outputId !== "internal";
const customOutput = outputId !== INTERNAL_OUTPUT_ID;
let waveform = document.getElementById("waveform-" + player.toString());
if (waveform == null) {
@ -271,7 +284,12 @@ class Player extends ((PlayerEmitter as unknown) as { new (): EventEmitter }) {
}
} else {
(wavesurfer as any).backend.gainNode.disconnect();
(wavesurfer as any).backend.gainNode.connect(engine.finalCompressor);
// Don't let the PFL player reach on air.
if (player !== PLAYER_ID_PREVIEW) {
(wavesurfer as any).backend.gainNode.connect(engine.finalCompressor);
}
(wavesurfer as any).backend.gainNode.connect(
engine.playerAnalysers[player]
);
@ -340,7 +358,7 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
// Player Inputs
public players: (Player | undefined)[] = [];
playerAnalysers: typeof StereoAnalyserNode[];
playerAnalysers: typeof StereoAnalyserNode[] = Array(PLAYER_COUNT);
// Final Processing
finalCompressor: DynamicsCompressorNode;
@ -397,10 +415,10 @@ export class AudioEngine extends ((EngineEmitter as unknown) as {
// Player Input
this.playerAnalysers = [];
for (let i = 0; i < 3; i++) {
for (let i = 0; i < PLAYER_COUNT; i++) {
let analyser = new StereoAnalyserNode(this.audioContext);
analyser.fftSize = ANALYSIS_FFT_SIZE;
this.playerAnalysers.push(analyser);
this.playerAnalysers[i] = analyser;
}
// Final Processing

View file

@ -11,7 +11,6 @@ const DB_CONST = 20.0;
declare const sampleRate: number;
type StereoModeEnum = "M3" | "M6" | "AB";
// @ts-ignore
class DBFSPeakProcessor extends AudioWorkletProcessor {
process(
inputs: Float32Array[][],

View file

@ -12,9 +12,25 @@ import Keys from "keymaster";
import { Track, MYRADIO_NON_API_BASE, AuxItem } from "../api";
import { AppThunk } from "../store";
import { RootState } from "../rootReducer";
import { audioEngine, ChannelMapping } from "./audio";
import {
audioEngine,
ChannelMapping,
INTERNAL_OUTPUT_ID,
PLAYER_COUNT,
PLAYER_ID_PREVIEW,
} from "./audio";
import * as TheNews from "./the_news";
import { changeSetting } from "../optionsMenu/settingsState";
import {
DEFAULT_TRIM_DB,
OFF_LEVEL_DB,
BED_LEVEL_DB,
FULL_LEVEL_DB,
} from "./audio";
import { PLAYER_COUNTER_UPDATE_PERIOD_MS } from "../showplanner/Player";
const playerGainTweens: Array<{
target: VolumePresetEnum;
tweens: Between[];
@ -27,12 +43,6 @@ type PlayerRepeatEnum = "none" | "one" | "all";
type VolumePresetEnum = "off" | "bed" | "full";
type MicVolumePresetEnum = "off" | "full";
export type MicErrorEnum = "NO_PERMISSION" | "NOT_SECURE_CONTEXT" | "UNKNOWN";
export const DEFAULT_TRIM_DB = -6; // The default trim applied to channel players.
export const OFF_LEVEL_DB = -40;
export const BED_LEVEL_DB = -13;
export const FULL_LEVEL_DB = 0;
interface PlayerState {
loadedItem: PlanItem | Track | AuxItem | null;
loading: number;
@ -90,7 +100,8 @@ const BasePlayerState: PlayerState = {
const mixerState = createSlice({
name: "Player",
initialState: {
players: [BasePlayerState, BasePlayerState, BasePlayerState],
// Fill the players with channel and preview players.
players: Array(PLAYER_COUNT).fill(BasePlayerState),
mic: {
open: false,
volume: 1,
@ -389,7 +400,7 @@ export const load = (
const shouldResetTrim = getState().settings.resetTrimOnLoad;
const customOutput =
getState().settings.channelOutputIds[player] !== "internal";
getState().settings.channelOutputIds[player] !== INTERNAL_OUTPUT_ID;
const isPFL = getState().mixer.players[player].pfl;
dispatch(
@ -454,7 +465,17 @@ export const load = (
const blob = new Blob([rawData]);
const objectUrl = URL.createObjectURL(blob);
const channelOutputId = getState().settings.channelOutputIds[player];
let channelOutputId = getState().settings.channelOutputIds[player];
// If the player setting doesn't exist, reset the settings. (Happens after player count is increased)
if (!channelOutputId) {
channelOutputId = INTERNAL_OUTPUT_ID;
dispatch(
changeSetting({
key: "channelOutputIds",
val: Array(PLAYER_COUNT).fill(channelOutputId),
})
);
}
const playerInstance = await audioEngine.createPlayer(
player,
@ -504,7 +525,8 @@ export const load = (
dispatch(mixerState.actions.setPlayerState({ player, state: "playing" }));
const state = getState().mixer.players[player];
if (state.loadedItem != null) {
// Don't set played on Preview Channel
if (state.loadedItem != null && player !== PLAYER_ID_PREVIEW) {
dispatch(
setItemPlayed({ itemId: itemId(state.loadedItem), played: true })
);
@ -519,7 +541,10 @@ export const load = (
);
});
playerInstance.on("timeChange", (time) => {
if (Math.abs(time - getState().mixer.players[player].timeCurrent) > 0.5) {
if (
Math.abs(time - getState().mixer.players[player].timeCurrent) >
PLAYER_COUNTER_UPDATE_PERIOD_MS / 1000
) {
dispatch(
mixerState.actions.setTimeCurrent({
player,
@ -529,6 +554,10 @@ export const load = (
}
});
playerInstance.on("finish", () => {
// If the Preview Player finishes playing, turn off PFL in the UI.
if (player === PLAYER_ID_PREVIEW) {
dispatch(setChannelPFL(player, false));
}
dispatch(mixerState.actions.setPlayerState({ player, state: "stopped" }));
const state = getState().mixer.players[player];
if (state.tracklistItemID !== -1) {
@ -602,6 +631,11 @@ export const play = (player: number): AppThunk => async (
console.log("not ready");
return;
}
// If it's the Preview player starting, turn on PFL automatically.
if (player === PLAYER_ID_PREVIEW) {
dispatch(setChannelPFL(player, true));
}
audioEngine.players[player]?.play();
// If we're starting off audible, try and tracklist.
@ -618,7 +652,8 @@ const attemptTracklist = (player: number): AppThunk => async (
if (
state.loadedItem &&
state.loadedItem.type === "central" &&
audioEngine.players[player]?.isPlaying
audioEngine.players[player]?.isPlaying &&
player !== PLAYER_ID_PREVIEW
) {
//track
console.log("potentially tracklisting", state.loadedItem);
@ -642,6 +677,10 @@ export const pause = (player: number): AppThunk => (dispatch, getState) => {
if (audioEngine.players[player]?.isPlaying) {
audioEngine.players[player]?.pause();
} else {
// If it's the Preview player starting, turn on PFL automatically.
if (player === PLAYER_ID_PREVIEW) {
dispatch(setChannelPFL(player, true));
}
audioEngine.players[player]?.play();
}
};
@ -670,6 +709,11 @@ export const stop = (player: number): AppThunk => (dispatch, getState) => {
playerInstance.stop();
// If we're stoping the Preview player, turn off PFL in the UI.
if (player === PLAYER_ID_PREVIEW) {
dispatch(setChannelPFL(player, false));
}
dispatch(mixerState.actions.setTimeCurrent({ player, time: cueTime }));
playerInstance.setCurrentTime(cueTime);
@ -818,13 +862,17 @@ export const setChannelPFL = (
if (
enabled &&
typeof audioEngine.players[player] !== "undefined" &&
!audioEngine.players[player]?.isPlaying
!audioEngine.players[player]?.isPlaying &&
player !== PLAYER_ID_PREVIEW // The Preview player is setting PFL itself when it plays.
) {
dispatch(setVolume(player, "off", false));
dispatch(setVolume(player, "off", false)); // This does nothing for Preview player (it's not routed.)
dispatch(play(player));
}
// If the player number is -1, do all channels.
if (player === -1) {
if (!enabled) {
dispatch(stop(PLAYER_ID_PREVIEW)); // Stop the Preview player!
}
for (let i = 0; i < audioEngine.players.length; i++) {
dispatch(mixerState.actions.setPlayerPFL({ player: i, enabled: false }));
audioEngine.setPFL(i, false);

View file

@ -307,7 +307,6 @@ function OptionsButton() {
function MeterBridge() {
const dispatch = useDispatch();
const proMode = useSelector((state: RootState) => state.settings.proMode);
const playerPFLs = useSelector(
(state: RootState) => state.mixer.players.map((x) => x.pfl),
shallowEqual
@ -316,7 +315,7 @@ function MeterBridge() {
return (
<>
{proMode && isPFL && (
{isPFL && (
<li
className="btn btn-danger rounded-0 pt-2 pb-1 nav-item nav-link clear-pfl"
onClick={() => dispatch(setChannelPFL(-1, false))}

View file

@ -3,6 +3,11 @@ import { RootState } from "../rootReducer";
import { useSelector, useDispatch } from "react-redux";
import { changeSetting } from "./settingsState";
import { changeBroadcastSetting } from "../broadcast/state";
import {
INTERNAL_OUTPUT_ID,
PLAYER_COUNT,
PLAYER_ID_PREVIEW,
} from "../mixer/audio";
type ErrorEnum =
| "NO_PERMISSION"
@ -34,7 +39,11 @@ function ChannelOutputSelect({
const dispatch = useDispatch();
return (
<div className="form-group">
<label>Channel {channel + 1}</label>
<label>
{channel === PLAYER_ID_PREVIEW
? "Preview Channel"
: "Channel " + (channel + 1)}
</label>
<select
className="form-control"
id="broadcastSourceSelect"
@ -45,19 +54,20 @@ function ChannelOutputSelect({
dispatch(
changeSetting({
key: "channelOutputIds",
// @ts-ignore
val: channelOutputIds,
})
);
}}
>
{outputId !== "internal" &&
{outputId !== INTERNAL_OUTPUT_ID &&
!outputList?.some((id) => id.deviceId === outputId) && (
<option value={outputId} disabled>
Missing Device ({outputId})
</option>
)}
<option value="internal">Internal (Direct to Stream/Headphones)</option>
<option value={INTERNAL_OUTPUT_ID}>
Internal (Direct to Stream/Headphones)
</option>
{(outputList || []).map(function(e, i) {
return (
<option value={e.deviceId} key={i}>
@ -111,7 +121,6 @@ export function AdvancedTab() {
fetchOutputNames();
}, []);
// @ts-ignore
return (
<>
<h2>Selector Options</h2>
@ -193,9 +202,12 @@ export function AdvancedTab() {
: "An error occurred when opening the output devices. Please try again."}
</div>
)}
<ChannelOutputSelect outputList={outputList} channel={0} />
<ChannelOutputSelect outputList={outputList} channel={1} />
<ChannelOutputSelect outputList={outputList} channel={2} />
{[...Array(PLAYER_COUNT)].map(function(object, i) {
return (
<ChannelOutputSelect key={i} outputList={outputList} channel={i} />
);
})}
<hr />
<h2>Misc</h2>

View file

@ -1,4 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { INTERNAL_OUTPUT_ID, PLAYER_COUNT } from "../mixer/audio";
interface Settings {
showDebugInfo: boolean;
@ -23,7 +24,7 @@ const settingsState = createSlice({
proMode: false,
channelVUs: true,
channelVUsStereo: true,
channelOutputIds: ["internal", "internal", "internal"],
channelOutputIds: Array(PLAYER_COUNT).fill(INTERNAL_OUTPUT_ID),
resetTrimOnLoad: true,
saveShowPlanChanges: false,
} as Settings,

View file

@ -8,6 +8,7 @@ import * as MixerState from "../mixer/state";
import { Draggable } from "react-beautiful-dnd";
import { contextMenu } from "react-contexify";
import "./item.scss";
import { PLAYER_ID_PREVIEW } from "../mixer/audio";
export const TS_ITEM_MENU_ID = "SongMenu";
export const TS_ITEM_AUX_ID = "AuxMenu";
@ -47,6 +48,7 @@ export const Item = memo(function Item({
function openContextMenu(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
e.preventDefault();
if (column === PLAYER_ID_PREVIEW) return; // Don't let people right click in the library.
if (isTrack(x)) {
contextMenu.show({
id: TS_ITEM_MENU_ID,
@ -56,6 +58,7 @@ export const Item = memo(function Item({
trackid: x.trackid,
title: x.title,
artist: x.artist,
item: x,
},
});
} else if (isAux(x)) {
@ -65,6 +68,7 @@ export const Item = memo(function Item({
props: {
id,
title: x.title,
item: x,
},
});
}
@ -86,11 +90,7 @@ export const Item = memo(function Item({
}
return (
<Draggable
draggableId={id}
index={index}
isDragDisabled={isGhost || isLoaded}
>
<Draggable draggableId={id} index={index} isDragDisabled={isGhost}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}

View file

@ -18,10 +18,18 @@ import ProModeButtons from "./ProModeButtons";
import { VUMeter } from "../optionsMenu/helpers/VUMeter";
import * as api from "../api";
import { AppThunk } from "../store";
import {
INTERNAL_OUTPUT_ID,
LevelsSource,
PLAYER_COUNT,
PLAYER_ID_PREVIEW,
} from "../mixer/audio";
export const USE_REAL_GAIN_VALUE = false;
function PlayerNumbers({ id }: { id: number }) {
export const PLAYER_COUNTER_UPDATE_PERIOD_MS = 200;
function PlayerNumbers({ id, pfl }: { id: number; pfl: boolean }) {
const store = useStore<RootState, any>();
const [
[timeCurrent, timeLength, timeRemaining, endTime],
@ -39,7 +47,7 @@ function PlayerNumbers({ id }: { id: number }) {
state.timeRemaining,
now.valueOf() / 1000 + state.timeRemaining,
]);
}, 1000);
}, PLAYER_COUNTER_UPDATE_PERIOD_MS);
return () => window.clearInterval(tickerRef.current);
});
@ -54,9 +62,11 @@ function PlayerNumbers({ id }: { id: number }) {
<span id={"remaining-" + id} className="remaining bypass-click">
{secToHHMM(timeRemaining)}
</span>
<span id={"ends-" + id} className="outro bypass-click">
End - {timestampToHHMM(endTime)}
</span>
{!pfl && (
<span id={"ends-" + id} className="outro bypass-click">
End - {timestampToHHMM(endTime)}
</span>
)}
</>
);
}
@ -236,7 +246,7 @@ function LoadedTrackInfo({ id }: { id: number }) {
);
}
export function Player({ id }: { id: number }) {
export function Player({ id, pfl }: { id: number; pfl: boolean }) {
// Define time remaining (secs) when the play icon should flash.
const SECS_REMAINING_WARNING = 20;
@ -254,20 +264,14 @@ export function Player({ id }: { id: number }) {
)
);
const settings = useSelector((state: RootState) => state.settings);
const customOutput = settings.channelOutputIds[id] !== "internal";
const customOutput = settings.channelOutputIds[id] !== INTERNAL_OUTPUT_ID;
const dispatch = useDispatch();
const VUsource = (id: number) => {
switch (id) {
case 0:
return "player-0";
case 1:
return "player-1";
case 2:
return "player-2";
default:
throw new Error("Unknown Player VUMeter source: " + id);
if (id < PLAYER_COUNT) {
return ("player-" + id) as LevelsSource;
}
throw new Error("Unknown Player VUMeter source: " + id);
};
let channelDuration = 0;
@ -291,65 +295,81 @@ export function Player({ id }: { id: number }) {
}
>
<div className="card text-center">
<div className="d-inline mx-1">
<span className="float-left">
Total: {secToHHMM(channelDuration)}
</span>
<span className="float-right">
Unplayed: {secToHHMM(channelUnplayed)}
</span>
</div>
<div className="row m-0 p-1 card-header channelButtons hover-menu">
<span className="hover-label">Channel Controls</span>
<button
className={
(playerState.autoAdvance
? "btn-primary"
: "btn-outline-secondary") + " btn btn-sm col-4 sp-play-on-load"
}
onClick={() =>
dispatch(MixerState.toggleAutoAdvance({ player: id }))
}
>
<FaLevelDownAlt />
&nbsp; Auto Advance
</button>
<button
className={
(playerState.playOnLoad
? "btn-primary"
: "btn-outline-secondary") + " btn btn-sm col-4 sp-play-on-load"
}
onClick={() =>
dispatch(MixerState.togglePlayOnLoad({ player: id }))
}
>
<FaPlayCircle />
&nbsp; Play on Load
</button>
<button
className={
(playerState.repeat !== "none"
? "btn-primary"
: "btn-outline-secondary") + " btn btn-sm col-4 sp-play-on-load"
}
onClick={() => dispatch(MixerState.toggleRepeat({ player: id }))}
>
<FaRedo />
&nbsp; Repeat {playerState.repeat}
</button>
</div>
{settings.proMode && !customOutput && <ProModeButtons channel={id} />}
{!pfl && (
<>
<div className="d-inline mx-1">
<span className="float-left">
Total: {secToHHMM(channelDuration)}
</span>
<span className="float-right">
Unplayed: {secToHHMM(channelUnplayed)}
</span>
</div>
<div className="row m-0 p-1 card-header channelButtons hover-menu">
<span className="hover-label">Channel Controls</span>
<button
className={
(playerState.autoAdvance
? "btn-primary"
: "btn-outline-secondary") +
" btn btn-sm col-4 sp-play-on-load"
}
onClick={() =>
dispatch(MixerState.toggleAutoAdvance({ player: id }))
}
>
<FaLevelDownAlt />
&nbsp; Auto Advance
</button>
<button
className={
(playerState.playOnLoad
? "btn-primary"
: "btn-outline-secondary") +
" btn btn-sm col-4 sp-play-on-load"
}
onClick={() =>
dispatch(MixerState.togglePlayOnLoad({ player: id }))
}
>
<FaPlayCircle />
&nbsp; Play on Load
</button>
<button
className={
(playerState.repeat !== "none"
? "btn-primary"
: "btn-outline-secondary") +
" btn btn-sm col-4 sp-play-on-load"
}
onClick={() =>
dispatch(MixerState.toggleRepeat({ player: id }))
}
>
<FaRedo />
&nbsp; Repeat {playerState.repeat}
</button>
</div>
</>
)}
{!pfl && settings.proMode && !customOutput && (
<ProModeButtons channel={id} />
)}
<div className="card-body p-0">
<LoadedTrackInfo id={id} />
<br />
<span className="text-muted">
{playerState.loadedItem !== null && playerState.loading === -1
? "artist" in playerState.loadedItem &&
playerState.loadedItem.artist
: ""}
&nbsp;
</span>
{!pfl && (
<>
<LoadedTrackInfo id={id} />
<br />
<span className="text-muted">
{playerState.loadedItem !== null && playerState.loading === -1
? "artist" in playerState.loadedItem &&
playerState.loadedItem.artist
: ""}
&nbsp;
</span>
</>
)}
<div className="mediaButtons">
<button
onClick={() => dispatch(MixerState.play(id))}
@ -383,10 +403,11 @@ export function Player({ id }: { id: number }) {
</div>
<div className="p-0 card-footer">
<TimingButtons id={id} />
{!pfl && <TimingButtons id={id} />}
<div className="waveform">
<PlayerNumbers id={id} />
{playerState.loadedItem !== null &&
<PlayerNumbers id={id} pfl={pfl} />
{!pfl &&
playerState.loadedItem !== null &&
"intro" in playerState.loadedItem && (
<span className="m-0 intro bypass-click">
{playerState.loadedItem !== null
@ -415,35 +436,36 @@ export function Player({ id }: { id: number }) {
</div>
</div>
</div>
<div
className={
"mixer-buttons " +
(playerState.state === "playing" && playerState.volume === 0
? "error-animation"
: "")
}
>
{!pfl && (
<div
className="mixer-buttons-backdrop"
style={{
width:
(USE_REAL_GAIN_VALUE ? playerState.gain : playerState.volume) *
100 +
"%",
}}
></div>
<button onClick={() => dispatch(MixerState.setVolume(id, "off"))}>
Off
</button>
<button onClick={() => dispatch(MixerState.setVolume(id, "bed"))}>
Bed
</button>
<button onClick={() => dispatch(MixerState.setVolume(id, "full"))}>
Full
</button>
</div>
{settings.proMode && settings.channelVUs && (
className={
"mixer-buttons " +
(playerState.state === "playing" && playerState.volume === 0
? "error-animation"
: "")
}
>
<div
className="mixer-buttons-backdrop"
style={{
width:
(USE_REAL_GAIN_VALUE ? playerState.gain : playerState.volume) *
100 +
"%",
}}
></div>
<button onClick={() => dispatch(MixerState.setVolume(id, "off"))}>
Off
</button>
<button onClick={() => dispatch(MixerState.setVolume(id, "bed"))}>
Bed
</button>
<button onClick={() => dispatch(MixerState.setVolume(id, "full"))}>
Full
</button>
</div>
)}
{!pfl && settings.proMode && settings.channelVUs && (
<div className="channel-vu">
{customOutput ? (
<span className="text-muted">
@ -463,3 +485,14 @@ export function Player({ id }: { id: number }) {
</div>
);
}
export function PflPlayer() {
return (
<div id="pfl-player" className="hover-menu">
<span className="mx-1 hover-label always-show">
Preview Player (Headphones Only)
</span>
<Player id={PLAYER_ID_PREVIEW} pfl={true} />
</div>
);
}

View file

@ -3,17 +3,12 @@ import { Menu, Item as CtxMenuItem } from "react-contexify";
import "react-contexify/dist/ReactContexify.min.css";
import { useBeforeunload } from "react-beforeunload";
import {
FaBookOpen,
FaFileImport,
FaBars,
FaMicrophone,
FaTrash,
FaUpload,
FaPlayCircle,
FaCircleNotch,
FaPencilAlt,
FaHeadphonesAlt,
} from "react-icons/fa";
import { VUMeter } from "../optionsMenu/helpers/VUMeter";
import { MYRADIO_NON_API_BASE, TimeslotItem } from "../api";
import appLogo from "../assets/images/webstudio.svg";
@ -36,33 +31,23 @@ import {
addItem,
removeItem,
setItemPlayed,
getPlaylists,
PlanItemBase,
} from "./state";
import * as MixerState from "../mixer/state";
import * as OptionsMenuState from "../optionsMenu/state";
import { Item, TS_ITEM_MENU_ID } from "./Item";
import {
CentralMusicLibrary,
CML_CACHE,
AuxLibrary,
AUX_CACHE,
ManagedPlaylistLibrary,
} from "./libraries";
import { CML_CACHE, AUX_CACHE } from "./libraries";
import { Player } from "./Player";
import { CombinedNavAlertBar } from "../navbar";
import { OptionsMenu } from "../optionsMenu";
import { WelcomeModal } from "./WelcomeModal";
import { PisModal } from "./PISModal";
import { AutoPlayoutModal } from "./AutoPlayoutModal";
import { LibraryUploadModal } from "./LibraryUploadModal";
import { ImporterModal } from "./ImporterModal";
import "./channel.scss";
import Modal from "react-modal";
import { Button } from "reactstrap";
import { secToHHMM, useInterval } from "../lib/utils";
import { Sidebar } from "./sidebar";
import { PLAYER_ID_PREVIEW } from "../mixer/audio";
function Channel({ id, data }: { id: number; data: PlanItem[] }) {
return (
@ -84,217 +69,7 @@ function Channel({ id, data }: { id: number; data: PlanItem[] }) {
</div>
)}
</Droppable>
<Player id={id} />
</div>
);
}
function LibraryColumn() {
const [sauce, setSauce] = useState("None");
const dispatch = useDispatch();
const { auxPlaylists, managedPlaylists, userPlaylists } = useSelector(
(state: RootState) => state.showplan
);
const [autoPlayoutModal, setAutoPlayoutModal] = useState(false);
const [showLibraryUploadModal, setShowLibraryModal] = useState(false);
const [showImporterModal, setShowImporterModal] = useState(false);
useEffect(() => {
dispatch(getPlaylists());
}, [dispatch]);
return (
<>
<AutoPlayoutModal
isOpen={autoPlayoutModal}
close={() => setAutoPlayoutModal(false)}
/>
<LibraryUploadModal
isOpen={showLibraryUploadModal}
close={() => setShowLibraryModal(false)}
/>
<ImporterModal
close={() => setShowImporterModal(false)}
isOpen={showImporterModal}
/>
<div className="library-column">
<div className="mx-2 mb-2">
<h2>
<FaBookOpen className="mx-2" size={28} />
Libraries
</h2>
<Button
className="mr-1"
color="primary"
title="Auto Playout"
size="sm"
outline={true}
onClick={() => setAutoPlayoutModal(true)}
>
<FaPlayCircle /> Auto Playout
</Button>
<Button
className="mr-1"
color="primary"
title="Import From Showplan"
size="sm"
outline={true}
onClick={() => setShowImporterModal(true)}
>
<FaFileImport /> Import
</Button>
<Button
className="mr-1"
color="primary"
title="Upload to Library"
size="sm"
outline={true}
onClick={() => setShowLibraryModal(true)}
>
<FaUpload /> Upload
</Button>
</div>
<div className="px-2">
<select
className="form-control form-control-sm"
style={{ flex: "none" }}
value={sauce}
onChange={(e) => setSauce(e.target.value)}
>
<option value={"None"} disabled>
Choose a library
</option>
<option value={"CentralMusicLibrary"}>Central Music Library</option>
<option disabled>Personal Resources</option>
{userPlaylists.map((playlist) => (
<option key={playlist.managedid} value={playlist.managedid}>
{playlist.title}
</option>
))}
<option disabled>Shared Resources</option>
{auxPlaylists.map((playlist) => (
<option
key={"aux-" + playlist.managedid}
value={"aux-" + playlist.managedid}
>
{playlist.title}
</option>
))}
<option disabled>Playlists</option>
{managedPlaylists.map((playlist) => (
<option
key={"managed-" + playlist.playlistid}
value={"managed-" + playlist.playlistid}
>
{playlist.title}
</option>
))}
</select>
</div>
<div className="border-top my-2"></div>
{sauce === "CentralMusicLibrary" && <CentralMusicLibrary />}
{(sauce.startsWith("aux-") || sauce.match(/^\d/)) && (
<AuxLibrary libraryId={sauce} />
)}
{sauce.startsWith("managed-") && (
<ManagedPlaylistLibrary libraryId={sauce.substr(8)} />
)}
<span
className={
sauce === "None" ? "mt-5 text-center text-muted" : "d-none"
}
>
<FaBookOpen size={56} />
<br />
Select a library to search.
</span>
</div>
</>
);
}
function MicControl() {
const state = useSelector((state: RootState) => state.mixer.mic);
const proMode = useSelector((state: RootState) => state.settings.proMode);
const stereo = useSelector(
(state: RootState) => state.settings.channelVUsStereo
);
const dispatch = useDispatch();
const [count, setCount] = useState(0);
// Make a persistant mic counter.
useInterval(() => {
if (state.volume === 0 || !state.open) {
setCount(0);
} else {
setCount((c) => c + 1);
}
}, 1000);
return (
<div className="mic-control">
<div data-toggle="collapse" data-target="#mic-control-menu">
<h2>
<FaMicrophone className="mx-1" size={28} />
Microphone
</h2>
<FaBars
className="toggle mx-0 mt-2 text-muted"
title="Toggle Microphone Menu"
size={20}
/>
</div>
<div id="mic-control-menu" className="collapse show">
{!state.open && (
<p className="alert-info p-2 mb-0">
The microphone has not been setup. Go to{" "}
<button
className="btn btn-link m-0 mb-1 p-0"
onClick={() => dispatch(OptionsMenuState.open())}
>
{" "}
options
</button>
.
</p>
)}
{state.open && proMode && (
<span id="micLiveTimer" className={state.volume > 0 ? "live" : ""}>
<span className="text">Mic Live: </span>
{state.volume > 0 ? secToHHMM(count) : "00:00:00"}
</span>
)}
{state.open && (
<>
<div id="micMeter">
<VUMeter
width={250}
height={40}
source="mic-final"
range={[-40, 3]}
greenRange={[-16, -6]}
stereo={proMode && stereo}
/>
</div>
<div className={`mixer-buttons ${!state.open && "disabled"}`}>
<div
className="mixer-buttons-backdrop"
style={{
width: state.volume * 100 + "%",
}}
></div>
<button onClick={() => dispatch(MixerState.setMicVolume("off"))}>
Off
</button>
<button onClick={() => dispatch(MixerState.setMicVolume("full"))}>
Full
</button>
</div>
</>
)}
</div>
<Player id={id} pfl={false} />
</div>
);
}
@ -439,11 +214,7 @@ const Showplanner: React.FC<{ timeslotId: number }> = function({ timeslotId }) {
<FaBars style={{ verticalAlign: "text-bottom" }} />
&nbsp; Toggle Sidebar
</span>
<div id="sidebar">
<LibraryColumn />
<div className="border-top"></div>
<MicControl />
</div>
<Sidebar />
</DragDropContext>
</div>
<Menu id={TS_ITEM_MENU_ID}>
@ -463,6 +234,15 @@ const Showplanner: React.FC<{ timeslotId: number }> = function({ timeslotId }) {
>
<FaCircleNotch /> Mark Unplayed
</CtxMenuItem>
<CtxMenuItem
onClick={(args) => {
dispatch(
MixerState.load(PLAYER_ID_PREVIEW, (args.props as any).item)
);
}}
>
<FaHeadphonesAlt /> Preview with PFL
</CtxMenuItem>
<CtxMenuItem
onClick={(args) => {
if ("trackid" in (args.props as any)) {
@ -576,6 +356,10 @@ export function LoadingDialogue({
function ChannelStrips() {
const showplan = useSelector((state: RootState) => state.showplan.plan!);
useEffect(() => {
ReactTooltip.rebuild(); // If the show plan has been re-jiggled, make sure the tooltips are updated.
});
return (
<div className="channels">
<Channel id={0} data={showplan} />

View file

@ -7,11 +7,28 @@ import {
AuxItem,
loadPlaylistLibrary,
} from "../api";
import { itemId } from "./state";
import { getPlaylists, itemId } from "./state";
import { Droppable } from "react-beautiful-dnd";
import { FaCog, FaSearch, FaTimesCircle } from "react-icons/fa";
import {
FaBookOpen,
FaCog,
FaFileImport,
FaPlayCircle,
FaSearch,
FaTimesCircle,
FaUpload,
} from "react-icons/fa";
import { AutoPlayoutModal } from "./AutoPlayoutModal";
import { LibraryUploadModal } from "./LibraryUploadModal";
import { ImporterModal } from "./ImporterModal";
import { Item } from "./Item";
import "./libraries.scss";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../rootReducer";
import { Button } from "reactstrap";
import { PLAYER_ID_PREVIEW } from "../mixer/audio";
import ReactTooltip from "react-tooltip";
export const CML_CACHE: { [recordid_trackid: string]: Track } = {};
@ -21,11 +38,145 @@ type searchingStateEnum =
| "results"
| "no-results";
export function LibraryColumn() {
const [sauce, setSauce] = useState("None");
const dispatch = useDispatch();
const auxPlaylists = useSelector(
(state: RootState) => state.showplan.auxPlaylists
);
const managedPlaylists = useSelector(
(state: RootState) => state.showplan.managedPlaylists
);
const userPlaylists = useSelector(
(state: RootState) => state.showplan.userPlaylists
);
const [autoPlayoutModal, setAutoPlayoutModal] = useState(false);
const [showLibraryUploadModal, setShowLibraryModal] = useState(false);
const [showImporterModal, setShowImporterModal] = useState(false);
useEffect(() => {
dispatch(getPlaylists());
}, [dispatch]);
return (
<>
<AutoPlayoutModal
isOpen={autoPlayoutModal}
close={() => setAutoPlayoutModal(false)}
/>
<LibraryUploadModal
isOpen={showLibraryUploadModal}
close={() => setShowLibraryModal(false)}
/>
<ImporterModal
close={() => setShowImporterModal(false)}
isOpen={showImporterModal}
/>
<div className="library-column">
<div className="mx-2 mb-2">
<h2 className="h3 hide-low-height">
<FaBookOpen className="mx-2" size={25} />
Libraries
</h2>
<div className="row m-0 p-1 card-header hover-menu">
<span className="hover-label">Hover for Import &amp; Tools</span>
<Button
className="mr-1"
color="primary"
title="Import From Showplan"
size="sm"
outline={true}
onClick={() => setShowImporterModal(true)}
>
<FaFileImport /> Import
</Button>
<Button
className="mr-1"
color="primary"
title="Upload to Library"
size="sm"
outline={true}
onClick={() => setShowLibraryModal(true)}
>
<FaUpload /> Upload
</Button>
<Button
className="mr-1"
color="primary"
title="Auto Playout"
size="sm"
outline={true}
onClick={() => setAutoPlayoutModal(true)}
>
<FaPlayCircle /> Auto Playout
</Button>
</div>
</div>
<div className="px-2">
<select
className="form-control form-control-sm"
style={{ flex: "none" }}
value={sauce}
onChange={(e) => setSauce(e.target.value)}
>
<option value={"None"} disabled>
Choose a library
</option>
<option value={"CentralMusicLibrary"}>Central Music Library</option>
<option disabled>Personal Resources</option>
{userPlaylists.map((playlist) => (
<option key={playlist.managedid} value={playlist.managedid}>
{playlist.title}
</option>
))}
<option disabled>Shared Resources</option>
{auxPlaylists.map((playlist) => (
<option
key={"aux-" + playlist.managedid}
value={"aux-" + playlist.managedid}
>
{playlist.title}
</option>
))}
<option disabled>Playlists</option>
{managedPlaylists.map((playlist) => (
<option
key={"managed-" + playlist.playlistid}
value={"managed-" + playlist.playlistid}
>
{playlist.title}
</option>
))}
</select>
</div>
<div className="border-top my-2"></div>
{sauce === "CentralMusicLibrary" && <CentralMusicLibrary />}
{(sauce.startsWith("aux-") || sauce.match(/^\d/)) && (
<AuxLibrary libraryId={sauce} />
)}
{sauce.startsWith("managed-") && (
<ManagedPlaylistLibrary libraryId={sauce.substr(8)} />
)}
<span
className={
sauce === "None" ? "mt-5 text-center text-muted" : "d-none"
}
>
<FaBookOpen size={56} />
<br />
Select a library to search.
</span>
</div>
</>
);
}
export function CentralMusicLibrary() {
const [track, setTrack] = useState("");
const [artist, setArtist] = useState("");
const debouncedTrack = useDebounce(track, 600);
const debouncedArtist = useDebounce(artist, 600);
const debouncedTrack = useDebounce(track, 1000);
const debouncedArtist = useDebounce(artist, 1000);
const [items, setItems] = useState<Track[]>([]);
const [state, setState] = useState<searchingStateEnum>("not-searching");
@ -39,11 +190,6 @@ export function CentralMusicLibrary() {
setItems([]);
setState("searching");
searchForTracks(artist, track).then((tracks) => {
if (tracks.length === 0) {
setState("no-results");
} else {
setState("results");
}
tracks.forEach((track) => {
const id = itemId(track);
if (!(id in CML_CACHE)) {
@ -51,6 +197,12 @@ export function CentralMusicLibrary() {
}
});
setItems(tracks);
if (tracks.length === 0) {
setState("no-results");
} else {
setState("results");
ReactTooltip.rebuild(); // Update tooltips so they appear.
}
});
}, [debouncedTrack, debouncedArtist, artist, track]);
return (
@ -81,7 +233,12 @@ export function CentralMusicLibrary() {
{...provided.droppableProps}
>
{items.map((item, index) => (
<Item key={itemId(item)} item={item} index={index} column={-1} />
<Item
key={itemId(item)}
item={item}
index={index}
column={PLAYER_ID_PREVIEW}
/>
))}
{provided.placeholder}
</div>
@ -94,8 +251,8 @@ export function CentralMusicLibrary() {
export function ManagedPlaylistLibrary({ libraryId }: { libraryId: string }) {
const [track, setTrack] = useState("");
const [artist, setArtist] = useState("");
const debouncedTrack = useDebounce(track, 600);
const debouncedArtist = useDebounce(artist, 600);
const debouncedTrack = useDebounce(track, 1000);
const debouncedArtist = useDebounce(artist, 1000);
const [items, setItems] = useState<Track[]>([]);
const [state, setState] = useState<searchingStateEnum>("not-searching");
@ -116,6 +273,7 @@ export function ManagedPlaylistLibrary({ libraryId }: { libraryId: string }) {
setState("no-results");
} else {
setState("results");
ReactTooltip.rebuild(); // Update tooltips so they appear.
}
}
load();
@ -167,7 +325,7 @@ export function ManagedPlaylistLibrary({ libraryId }: { libraryId: string }) {
key={itemId(item)}
item={item}
index={index}
column={-1}
column={PLAYER_ID_PREVIEW}
/>
))}
{provided.placeholder}
@ -182,7 +340,7 @@ export const AUX_CACHE: { [auxid: string]: AuxItem } = {};
export function AuxLibrary({ libraryId }: { libraryId: string }) {
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 200);
const debouncedQuery = useDebounce(searchQuery, 500);
const [items, setItems] = useState<AuxItem[]>([]);
const [state, setState] = useState<searchingStateEnum>("not-searching");
@ -199,6 +357,7 @@ export function AuxLibrary({ libraryId }: { libraryId: string }) {
}
});
setItems(libItems);
ReactTooltip.rebuild(); // Update tooltips so they appear.
if (libItems.length === 0) {
setState("no-results");
} else {
@ -240,7 +399,7 @@ export function AuxLibrary({ libraryId }: { libraryId: string }) {
key={itemId(item)}
item={item}
index={index}
column={-1}
column={PLAYER_ID_PREVIEW}
/>
))}
{provided.placeholder}

107
src/showplanner/sidebar.tsx Normal file
View file

@ -0,0 +1,107 @@
import React, { useState } from "react";
import { FaMicrophone, FaBars } from "react-icons/fa";
import { useDispatch, useSelector } from "react-redux";
import { secToHHMM, useInterval } from "../lib/utils";
import { VUMeter } from "../optionsMenu/helpers/VUMeter";
import { RootState } from "../rootReducer";
import { LibraryColumn } from "./libraries";
import * as OptionsMenuState from "../optionsMenu/state";
import * as MixerState from "../mixer/state";
import { PflPlayer } from "./Player";
export function Sidebar() {
return (
<div id="sidebar">
<LibraryColumn />
<div className="border-top"></div>
<PflPlayer />
<div className="border-top"></div>
<MicControl />
</div>
);
}
function MicControl() {
const state = useSelector((state: RootState) => state.mixer.mic);
const proMode = useSelector((state: RootState) => state.settings.proMode);
const stereo = useSelector(
(state: RootState) => state.settings.channelVUsStereo
);
const dispatch = useDispatch();
const [count, setCount] = useState(0);
// Make a persistant mic counter.
useInterval(() => {
if (state.volume === 0 || !state.open) {
setCount(0);
} else {
setCount((c) => c + 1);
}
}, 1000);
return (
<div className="mic-control">
<div data-toggle="collapse" data-target="#mic-control-menu">
<h2 className="h3">
<FaMicrophone className="mx-1" size={25} />
Microphone
</h2>
<FaBars
className="toggle mx-0 mt-2 text-muted"
title="Toggle Microphone Menu"
size={20}
/>
</div>
<div id="mic-control-menu" className="collapse show">
{!state.open && (
<p className="alert-info p-2 mb-0">
The microphone has not been setup. Go to{" "}
<button
className="btn btn-link m-0 mb-1 p-0"
onClick={() => dispatch(OptionsMenuState.open())}
>
{" "}
options
</button>
.
</p>
)}
{state.open && proMode && (
<span id="micLiveTimer" className={state.volume > 0 ? "live" : ""}>
<span className="text">Mic Live: </span>
{state.volume > 0 ? secToHHMM(count) : "00:00:00"}
</span>
)}
{state.open && (
<>
<div id="micMeter">
<VUMeter
width={250}
height={40}
source="mic-final"
range={[-40, 3]}
greenRange={[-16, -6]}
stereo={proMode && stereo}
/>
</div>
<div className={`mixer-buttons ${!state.open && "disabled"}`}>
<div
className="mixer-buttons-backdrop"
style={{
width: state.volume * 100 + "%",
}}
></div>
<button onClick={() => dispatch(MixerState.setMicVolume("off"))}>
Off
</button>
<button onClick={() => dispatch(MixerState.setMicVolume("full"))}>
Full
</button>
</div>
</>
)}
</div>
</div>
);
}