Merge pull request #197 from UniversityRadioYork/mstratford/pfl-player
This commit is contained in:
commit
6ed6929984
12 changed files with 589 additions and 417 deletions
36
src/App.scss
36
src/App.scss
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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[][],
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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))}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />
|
||||
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 />
|
||||
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 />
|
||||
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 />
|
||||
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 />
|
||||
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 />
|
||||
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
|
||||
: ""}
|
||||
|
||||
</span>
|
||||
{!pfl && (
|
||||
<>
|
||||
<LoadedTrackInfo id={id} />
|
||||
<br />
|
||||
<span className="text-muted">
|
||||
{playerState.loadedItem !== null && playerState.loading === -1
|
||||
? "artist" in playerState.loadedItem &&
|
||||
playerState.loadedItem.artist
|
||||
: ""}
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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" }} />
|
||||
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} />
|
||||
|
|
|
@ -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 & 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
107
src/showplanner/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue