diff --git a/meteor/server/Settings.ts b/meteor/server/Settings.ts deleted file mode 100644 index 647be536c25..00000000000 --- a/meteor/server/Settings.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import _ from 'underscore' -import { ISettings, DEFAULT_SETTINGS } from '@sofie-automation/meteor-lib/dist/Settings' - -/** - * This is an object specifying installation-wide, User Interface settings. - * There are default values for these settings that will be used, unless overriden - * through Meteor.settings functionality. - * - * You can use METEOR_SETTING to inject the settings JSON or you can use the - * --settings [filename] to provide a JSON file containing the settings - */ -export let Settings: ISettings = _.clone(DEFAULT_SETTINGS) - -Meteor.startup(() => { - Settings = _.extend(Settings, Meteor.settings.public) -}) diff --git a/meteor/server/__tests__/cronjobs.test.ts b/meteor/server/__tests__/cronjobs.test.ts index 93de8155db4..eaa8abb6157 100644 --- a/meteor/server/__tests__/cronjobs.test.ts +++ b/meteor/server/__tests__/cronjobs.test.ts @@ -73,7 +73,7 @@ import { setupDefaultStudioEnvironment, } from '../../__mocks__/helpers/database' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { Settings } from '../Settings' +import { DEFAULT_MAXIMUM_DATA_AGE } from '@sofie-automation/shared-lib/dist/core/constants' import { SofieIngestCacheType } from '@sofie-automation/corelib/dist/dataModel/SofieIngestDataCache' import { ObjectOverrideSetOp, ObjectWithOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { PartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' @@ -442,7 +442,7 @@ describe('cronjobs', () => { clientAddress: '', context: '', method: '', - timestamp: lib.getCurrentTime() - Settings.maximumDataAge - 1000, + timestamp: lib.getCurrentTime() - DEFAULT_MAXIMUM_DATA_AGE - 1000, }) await runCronjobs() @@ -477,7 +477,7 @@ describe('cronjobs', () => { type: SnapshotType.DEBUG, version: '', // Very old: - created: lib.getCurrentTime() - Settings.maximumDataAge - 1000, + created: lib.getCurrentTime() - DEFAULT_MAXIMUM_DATA_AGE - 1000, }) await runCronjobs() diff --git a/meteor/server/api/cleanup.ts b/meteor/server/api/cleanup.ts index a326e45ad01..d5c82ae5e34 100644 --- a/meteor/server/api/cleanup.ts +++ b/meteor/server/api/cleanup.ts @@ -9,7 +9,9 @@ import { getOrphanedPackageInfos, removePackageInfos, } from './studio/lib' -import { Settings } from '../Settings' +import { getCoreSystemAsync } from '../coreSystem/collection' +import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' +import { DEFAULT_MAXIMUM_DATA_AGE } from '@sofie-automation/shared-lib/dist/core/constants' import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collections' import { BlueprintId, @@ -81,6 +83,18 @@ export async function cleanupOldDataInner(actuallyCleanup = false): Promise 0 + ? configuredMaximumDataAge + : DEFAULT_MAXIMUM_DATA_AGE + const result: CollectionCleanupResult = {} const addToResult = (collectionName: CollectionName, docsToRemove: number) => { if (!result[collectionName]) { @@ -275,7 +289,7 @@ export async function cleanupOldDataInner(actuallyCleanup = false): Promise { enableBuckets: true, enableEvaluationForm: true, shelfAdlibButtonSize: ShelfButtonSize.LARGE, + autoRewindLeavingSegment: true, + disableBlurBorder: false, + allowGrabbingTimeline: true, + useCountdownToFreezeFrame: true, + defaultShelfDisplayOptions: DEFAULT_SHELF_DISPLAY_OPTIONS, + defaultDisplayDuration: DEFAULT_DISPLAY_DURATION, + defaultTimeScale: DEFAULT_TIME_SCALE, + followOnAirSegmentsHistory: 0, }), _rundownVersionHash: '', routeSetsWithOverrides: wrapDefaultObject({}), diff --git a/meteor/server/api/systemTime/ntpTimeChecker.ts b/meteor/server/api/systemTime/ntpTimeChecker.ts deleted file mode 100644 index 5d588eeea5d..00000000000 --- a/meteor/server/api/systemTime/ntpTimeChecker.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Meteor } from 'meteor/meteor' -import { Settings } from '../../Settings' -import { StatusCode } from '@sofie-automation/blueprints-integration' -import { setSystemStatus } from '../../systemStatus/systemStatus' -import { logger } from '../../logging' -import { determineDiffTime } from './systemTime' -import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' - -const CHECK_INTERVAL = 60 * 1000 - -let failCount = 0 - -Meteor.startup(() => { - if (Settings.enableNTPTimeChecker) { - // Periodically checks an NTP-server for the diff in time, and raises a warning if the diff is too large. - - const enableNTPTimeChecker = Settings.enableNTPTimeChecker - Meteor.setInterval(() => { - determineDiffTime({ - host: enableNTPTimeChecker.host, - port: enableNTPTimeChecker.port, - }) - .then((result) => { - failCount = 0 - - // Note: Subtracting `mean` with `stdDev` is not really the right thing to do, but it helps avoiding warnings in bad network conditions... - if (Math.abs(result.mean) - Math.abs(result.stdDev) > enableNTPTimeChecker.maxAllowedDiff) { - logger.warn(`ntpTimeChecker: diff is ${result.mean} ms (stdDev=${result.stdDev})`) - - setSystemStatus('ntpTimeChecker', { - statusCode: StatusCode.WARNING_MINOR, - messages: [ - `Warning: The time of the server differs ${result.mean} ms from the NTP server (check the configuration of the OS that Sofie Core is running on)`, - ], - }) - } else { - setSystemStatus('ntpTimeChecker', { statusCode: StatusCode.GOOD, messages: [] }) - } - }) - .catch((err) => { - logger.error(`Error in determineDiffTime: ${stringifyError(err)}`) - failCount++ - if (failCount > 10) { - setSystemStatus('ntpTimeChecker', { - statusCode: StatusCode.WARNING_MINOR, - messages: [`Warning: Unable to get the NTP time from the server. Error: ${err}`], - }) - } - }) - }, CHECK_INTERVAL) - - setSystemStatus('ntpTimeChecker', { statusCode: StatusCode.GOOD, messages: [] }) - } -}) diff --git a/meteor/server/coreSystem/index.ts b/meteor/server/coreSystem/index.ts index 2348e408c38..b566d743200 100644 --- a/meteor/server/coreSystem/index.ts +++ b/meteor/server/coreSystem/index.ts @@ -2,6 +2,11 @@ import { SYSTEM_ID, GENESIS_SYSTEM_VERSION } from '@sofie-automation/meteor-lib/ import { parseVersion } from '../systemStatus/semverUtils' import { getCurrentTime } from '../lib/lib' import { stringifyError } from '@sofie-automation/shared-lib/dist/lib/stringifyError' +import { + DEFAULT_MAXIMUM_DATA_AGE, + DEFAULT_CONFIRM_KEY_CODE, + DEFAULT_POISON_KEY, +} from '@sofie-automation/shared-lib/dist/core/constants' import { Meteor } from 'meteor/meteor' import { prepareMigration, runMigration } from '../migration/databaseMigration' import { CURRENT_SYSTEM_VERSION } from '../migration/currentSystemVersion' @@ -79,6 +84,9 @@ async function initializeCoreSystem() { heading: '', message: '', }, + maximumDataAge: DEFAULT_MAXIMUM_DATA_AGE, + confirmKeyCode: DEFAULT_CONFIRM_KEY_CODE, + poisonKey: DEFAULT_POISON_KEY, }), lastBlueprintConfig: undefined, }) diff --git a/meteor/server/lib/rest/v1/studios.ts b/meteor/server/lib/rest/v1/studios.ts index 898f5bda180..6b3a2b4280a 100644 --- a/meteor/server/lib/rest/v1/studios.ts +++ b/meteor/server/lib/rest/v1/studios.ts @@ -228,4 +228,11 @@ export interface APIStudioSettings { shelfAdlibButtonSize?: Exclude mockPieceContentStatus?: boolean rundownGlobalPiecesPrepareTime?: number + autoRewindLeavingSegment?: boolean + disableBlurBorder?: boolean + allowGrabbingTimeline?: boolean + useCountdownToFreezeFrame?: boolean + defaultDisplayDuration?: number + defaultTimeScale?: number + followOnAirSegmentsHistory?: number } diff --git a/meteor/server/migration/1_40_0.ts b/meteor/server/migration/1_40_0.ts index c418d34278c..664d4fc0f68 100644 --- a/meteor/server/migration/1_40_0.ts +++ b/meteor/server/migration/1_40_0.ts @@ -1,5 +1,5 @@ +import { Meteor } from 'meteor/meteor' import { addMigrationSteps } from './databaseMigration' -import { Settings } from '../Settings' import { Studios } from '../collections' // Release 40 (Skipped) @@ -36,7 +36,7 @@ interface ISettingsOld { maxAllowedDiff: number } } -const OldSettings = Settings as Partial +const OldSettings = (Meteor.settings.public ?? {}) as Partial const oldFrameRate = OldSettings.frameRate ?? 25 export const addSteps = addMigrationSteps('1.40.0', [ diff --git a/meteor/server/migration/upgrades/system.ts b/meteor/server/migration/upgrades/system.ts index 15ae90bea81..3db08bec2ef 100644 --- a/meteor/server/migration/upgrades/system.ts +++ b/meteor/server/migration/upgrades/system.ts @@ -14,6 +14,11 @@ import { CoreSystemId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DEFAULT_CORE_TRIGGERS } from './defaultSystemActionTriggers' import { protectString } from '@sofie-automation/corelib/dist/protectedString' import { ICoreSystemSettings } from '@sofie-automation/shared-lib/dist/core/model/CoreSystemSettings' +import { + DEFAULT_MAXIMUM_DATA_AGE, + DEFAULT_CONFIRM_KEY_CODE, + DEFAULT_POISON_KEY, +} from '@sofie-automation/shared-lib/dist/core/constants' export async function runUpgradeForCoreSystem(coreSystemId: CoreSystemId): Promise { logger.info(`Running upgrade for CoreSystem`) @@ -102,6 +107,9 @@ function generateDefaultSystemConfig(): BlueprintResultApplySystemConfig { heading: '', message: '', }, + maximumDataAge: DEFAULT_MAXIMUM_DATA_AGE, + confirmKeyCode: DEFAULT_CONFIRM_KEY_CODE, + poisonKey: DEFAULT_POISON_KEY, }, triggeredActions: Object.values(DEFAULT_CORE_TRIGGERS), } diff --git a/meteor/server/security/auth.ts b/meteor/server/security/auth.ts index aa4b62f2b02..29930eb11ec 100644 --- a/meteor/server/security/auth.ts +++ b/meteor/server/security/auth.ts @@ -3,7 +3,6 @@ import { USER_PERMISSIONS_HEADER, UserPermissions, } from '@sofie-automation/meteor-lib/dist/userPermissions' -import { Settings } from '../Settings' import { Meteor } from 'meteor/meteor' import Koa from 'koa' import { triggerWriteAccess } from './securityVerify' @@ -12,8 +11,15 @@ import { CollectionName } from '@sofie-automation/corelib/dist/dataModel/Collect export type RequestCredentials = Meteor.Connection | Koa.ParameterizedContext +/** + * Whether http-header based security measures are enabled. + * Configured via the `SOFIE_ENABLE_HEADER_AUTH` environment variable (`1` or `true` to enable). + */ +export const ENABLE_HEADER_AUTH = + process.env.SOFIE_ENABLE_HEADER_AUTH === '1' || process.env.SOFIE_ENABLE_HEADER_AUTH?.toLowerCase() === 'true' + export function parseConnectionPermissions(conn: RequestCredentials): UserPermissions { - if (!Settings.enableHeaderAuth) { + if (!ENABLE_HEADER_AUTH) { // If auth is disabled, return all permissions return { studio: true, @@ -49,7 +55,7 @@ export function assertConnectionHasOneOfPermissions( if (!conn) throw new Meteor.Error(403, 'Can only be invoked by clients') // Skip if auth is disabled - if (!Settings.enableHeaderAuth) return + if (!ENABLE_HEADER_AUTH) return const permissions = parseConnectionPermissions(conn) for (const permission of allowedPermissions) { @@ -70,7 +76,7 @@ export function checkHasOneOfPermissions( triggerWriteAccess() // Skip if auth is disabled - if (!Settings.enableHeaderAuth) return true + if (!ENABLE_HEADER_AUTH) return true if (!permissions) throw new Meteor.Error(403, 'Permissions is null') diff --git a/packages/documentation/docs/user-guide/configuration/sofie-core-settings.md b/packages/documentation/docs/user-guide/configuration/sofie-core-settings.md index a6d00aa139c..637a7afd694 100644 --- a/packages/documentation/docs/user-guide/configuration/sofie-core-settings.md +++ b/packages/documentation/docs/user-guide/configuration/sofie-core-settings.md @@ -4,7 +4,7 @@ sidebar_position: 1 # Sofie Core: System Configuration -_Sofie Core_ is configured at it's most basic level using a settings file and environment variables. +_Sofie Core_ is configured at its most basic level using environment variables. ### Environment Variables @@ -18,16 +18,6 @@ _Sofie Core_ is configured at it's most basic level using a settings file a - - - METEOR_SETTINGS - - Contents of settings file (see below) - - - $(cat settings.json) - - TZ @@ -61,50 +51,22 @@ _Sofie Core_ is configured at it's most basic level using a settings file a /logs/core/ + + + SOFIE_ENABLE_HEADER_AUTH + + + If set to 1 or true, enables http header based security measures. See{' '} + here for details on using this + + + false + + + 1 + + -### Settings File - -The settings file is an optional JSON file that contains some configuration settings for how the _Sofie Core_ works and behaves. - -To use a settings file: - -- During development: `meteor --settings settings.json` -- During prod: environment variable \(see above\) - -The structure of the file allows for public and private fields. At the moment, Sofie only uses public fields. Below is an example settings file: - -```text -{ - "public": { - "frameRate": 25 - } -} -``` - -There are various settings you can set for an installation. See the list below: - -| **Field name** | Use | Default value | -| :---------------------------- | :---------------------------------------------------------------------------------------------------------------------------- | :------------------------------------- | -| `autoRewindLeavingSegment` | Should segments be automatically rewound after they stop playing | `false` | -| `disableBlurBorder` | Should a border be displayed around the Rundown View when it's not in focus and studio mode is enabled | `false` | -| `defaultTimeScale` | An arbitrary number, defining the default zoom factor of the Timelines | `1` | -| `allowGrabbingTimeline` | Can Segment Timelines be grabbed to scroll them? | `true` | -| `enableHeaderAuth` | If true, enable http header based security measures. See [here](../features/access-levels) for details on using this | `false` | -| `defaultDisplayDuration` | The fallback duration of a Part, when it's expectedDuration is 0. \_\_In milliseconds | `3000` | -| `allowMultiplePlaylistsInGUI` | If true, allows creation of new playlists in the Lobby Gui (rundown list). If false; only pre-existing playlists are allowed. | `false` | -| `followOnAirSegmentsHistory` | How many segments of history to show when scrolling back in time (0 = show current segment only) | `0` | -| `maximumDataAge` | Clean up stuff that are older than this [ms]) | 100 days | -| `poisonKey` | Enable the use of poison key if present and use the key specified. | `'Escape'` | -| `enableNTPTimeChecker` | If set, enables a check to ensure that the system time doesn't differ too much from the specified NTP server time. | `null` | -| `defaultShelfDisplayOptions` | Default value used to toggle Shelf options when the 'display' URL argument is not provided. | `buckets,layout,shelfLayout,inspector` | -| `enableKeyboardPreview` | The KeyboardPreview is a feature that is not implemented in the main Fork, and is kept here for compatibility | `false` | -| `keyboardMapLayout` | Keyboard map layout (what physical layout to use for the keyboard) | STANDARD_102_TKL | -| `customizationClassName` | CSS class applied to the body of the page. Used to include custom implementations that differ from the main Fork. | `undefined` | -| `useCountdownToFreezeFrame` | If true, countdowns of videos will count down to the last freeze-frame of the video instead of to the end of the video | `true` | -| `confirmKeyCode` | Which keyboard key is used as "Confirm" in modal dialogs etc. | `'Enter'` | - -:::info -The exact definition for the settings can be found [in the code here](https://github.com/Sofie-Automation/sofie-core/blob/main/meteor/lib/Settings.ts#L12). -::: +Installation behaviour is otherwise configured through the Studio settings and the System Management page in the UI. diff --git a/packages/documentation/docs/user-guide/features/access-levels.md b/packages/documentation/docs/user-guide/features/access-levels.md index ebf6adfa61d..f35949cfb48 100644 --- a/packages/documentation/docs/user-guide/features/access-levels.md +++ b/packages/documentation/docs/user-guide/features/access-levels.md @@ -54,7 +54,7 @@ It is known that secrets can be leaked to all clients who can connect to Sofie, ::: In this mode, we rely on Sofie being run behind a reverse-proxy which will inform Sofie of the permissions of each connection. This allows you to use your organisations preferred auth provider, and translate that into something that Sofie can understand. -To enable this mode, you need to enable the `enableHeaderAuth` property in the [settings file](../configuration/sofie-core-settings.md) +To enable this mode, set the `SOFIE_ENABLE_HEADER_AUTH` [environment variable](../configuration/sofie-core-settings.md) to `1` or `true`. Sofie expects that for each DDP connection or http request, the `dnt` header will be set containing a comma separated list of the levels from the above table. If the header is not defined or is empty, the connection will have view-only access to Sofie. This header can also contain simply `admin` to grant the connection permission to everything. diff --git a/packages/meteor-lib/src/Settings.ts b/packages/meteor-lib/src/Settings.ts index 347fd04f84a..82e56451494 100644 --- a/packages/meteor-lib/src/Settings.ts +++ b/packages/meteor-lib/src/Settings.ts @@ -1,71 +1,8 @@ /** - * This is an object specifying installation-wide, User Interface settings. - * There are default values for these settings that will be used, unless overriden - * through Meteor.settings functionality. - * - * You can use METEOR_SETTING to inject the settings JSON or you can use the - * --settings [filename] to provide a JSON file containing the settings + * Settings injected into the meteor runtime config, which is available on the client side. */ -export interface ISettings { - /* Should the segment in the Rundown view automatically rewind after it stops being live? Default: false */ - autoRewindLeavingSegment: boolean - /** Disable blur border in RundownView */ - disableBlurBorder: boolean - /** Default time scale zooming for the UI. Default: 1 */ - defaultTimeScale: number - // Allow grabbing the entire timeline - allowGrabbingTimeline: boolean +export interface IExtendedSettings { + sofieVersionExtended: string /** If true, enable http header based security measures */ enableHeaderAuth: boolean - /** Default duration to use to render parts when no duration is provided */ - defaultDisplayDuration: number - /** How many segments of history to show when scrolling back in time (0 = show current segment only) */ - followOnAirSegmentsHistory: number - /** Clean up stuff that are older than this [ms] */ - maximumDataAge: number - /** Enable the use of poison key if present and use the key specified. **/ - poisonKey: string | null - /** If set, enables a check to ensure that the system time doesn't differ too much from the speficied NTP server time. */ - enableNTPTimeChecker: null | { - host: string - port?: number - maxAllowedDiff: number - } - /** Default value used to toggle Shelf options when the 'display' URL argument is not provided. */ - defaultShelfDisplayOptions: string - - /** - * CSS class applied to the body of the page. Used to include custom implementations that differ from the main Fork. - * I.e. custom CSS etc. Leave undefined if no custom implementation is needed - * */ - customizationClassName?: string - - /** If true, countdowns of videos will count down to the last freeze-frame of the video instead of to the end of the video */ - useCountdownToFreezeFrame: boolean - - /** - * Which keyboard key is used as "Confirm" in modal dialogs etc. - * In some installations, the rightmost Enter key (on the numpad) is dedicated for playout, - * in such cases this must be set to 'Enter' to exclude it. - */ - confirmKeyCode: 'Enter' | 'AnyEnter' } - -/** - * Default values for Settings - */ -export const DEFAULT_SETTINGS = Object.freeze({ - autoRewindLeavingSegment: true, - disableBlurBorder: false, - defaultTimeScale: 1, - allowGrabbingTimeline: true, - enableHeaderAuth: false, - defaultDisplayDuration: 3000, - poisonKey: 'Escape', - followOnAirSegmentsHistory: 0, - maximumDataAge: 1000 * 60 * 60 * 24 * 100, // 100 days - enableNTPTimeChecker: null, - defaultShelfDisplayOptions: 'buckets,layout,shelfLayout,inspector', - useCountdownToFreezeFrame: true, - confirmKeyCode: 'Enter', -}) diff --git a/packages/openapi/api/definitions/studios.yaml b/packages/openapi/api/definitions/studios.yaml index 1a0bc78ff30..c1e03212eb7 100644 --- a/packages/openapi/api/definitions/studios.yaml +++ b/packages/openapi/api/definitions/studios.yaml @@ -564,6 +564,27 @@ components: rundownGlobalPiecesPrepareTime: type: number description: How long before their start time a rundown owned piece be added to the timeline + autoRewindLeavingSegment: + type: boolean + description: Should the segment in the Rundown view automatically rewind after it stops being live + disableBlurBorder: + type: boolean + description: Disable the blur border in the RundownView + allowGrabbingTimeline: + type: boolean + description: Allow grabbing the entire timeline to scroll it + useCountdownToFreezeFrame: + type: boolean + description: If true, countdowns of videos will count down to the last freeze-frame of the video instead of to the end of the video + defaultDisplayDuration: + type: number + description: Default duration (in milliseconds) to use to render parts when no duration is provided + defaultTimeScale: + type: number + description: Default time scale zooming for the timelines in the UI + followOnAirSegmentsHistory: + type: number + description: How many segments of history to show when scrolling back in time (0 = show current segment only) required: - frameRate diff --git a/packages/openapi/src/generated/openapi.yaml b/packages/openapi/src/generated/openapi.yaml index aaf2806a2e6..422cd553db8 100644 --- a/packages/openapi/src/generated/openapi.yaml +++ b/packages/openapi/src/generated/openapi.yaml @@ -2839,6 +2839,29 @@ paths: rundownGlobalPiecesPrepareTime: type: number description: How long before their start time a rundown owned piece be added to the timeline + autoRewindLeavingSegment: + type: boolean + description: Should the segment in the Rundown view automatically rewind after it stops being live + disableBlurBorder: + type: boolean + description: Disable the blur border in the RundownView + allowGrabbingTimeline: + type: boolean + description: Allow grabbing the entire timeline to scroll it + useCountdownToFreezeFrame: + type: boolean + description: >- + If true, countdowns of videos will count down to the last freeze-frame of the video instead of + to the end of the video + defaultDisplayDuration: + type: number + description: Default duration (in milliseconds) to use to render parts when no duration is provided + defaultTimeScale: + type: number + description: Default time scale zooming for the timelines in the UI + followOnAirSegmentsHistory: + type: number + description: How many segments of history to show when scrolling back in time (0 = show current segment only) required: - frameRate - mediaPreviewsUrl @@ -3055,6 +3078,31 @@ paths: rundownGlobalPiecesPrepareTime: type: number description: How long before their start time a rundown owned piece be added to the timeline + autoRewindLeavingSegment: + type: boolean + description: Should the segment in the Rundown view automatically rewind after it stops being live + disableBlurBorder: + type: boolean + description: Disable the blur border in the RundownView + allowGrabbingTimeline: + type: boolean + description: Allow grabbing the entire timeline to scroll it + useCountdownToFreezeFrame: + type: boolean + description: >- + If true, countdowns of videos will count down to the last freeze-frame of the video + instead of to the end of the video + defaultDisplayDuration: + type: number + description: Default duration (in milliseconds) to use to render parts when no duration is provided + defaultTimeScale: + type: number + description: Default time scale zooming for the timelines in the UI + followOnAirSegmentsHistory: + type: number + description: >- + How many segments of history to show when scrolling back in time (0 = show current segment + only) required: - frameRate - mediaPreviewsUrl @@ -3227,6 +3275,29 @@ paths: rundownGlobalPiecesPrepareTime: type: number description: How long before their start time a rundown owned piece be added to the timeline + autoRewindLeavingSegment: + type: boolean + description: Should the segment in the Rundown view automatically rewind after it stops being live + disableBlurBorder: + type: boolean + description: Disable the blur border in the RundownView + allowGrabbingTimeline: + type: boolean + description: Allow grabbing the entire timeline to scroll it + useCountdownToFreezeFrame: + type: boolean + description: >- + If true, countdowns of videos will count down to the last freeze-frame of the video instead of + to the end of the video + defaultDisplayDuration: + type: number + description: Default duration (in milliseconds) to use to render parts when no duration is provided + defaultTimeScale: + type: number + description: Default time scale zooming for the timelines in the UI + followOnAirSegmentsHistory: + type: number + description: How many segments of history to show when scrolling back in time (0 = show current segment only) required: - frameRate - mediaPreviewsUrl diff --git a/packages/shared-lib/src/core/constants.ts b/packages/shared-lib/src/core/constants.ts index d36e4132290..e0d088e6aec 100644 --- a/packages/shared-lib/src/core/constants.ts +++ b/packages/shared-lib/src/core/constants.ts @@ -22,5 +22,23 @@ export const DEFAULT_MINIMUM_TAKE_SPAN = 1000 /** The duration to apply on too short Parts Within QuickLoop when ForceQuickLoopAutoNext.ENABLED_FORCING_MIN_DURATION is selected */ export const DEFAULT_FALLBACK_PART_DURATION = 3000 +/** Default duration (in milliseconds) to use to render parts when no duration is provided */ +export const DEFAULT_DISPLAY_DURATION = 3000 + +/** Default value used to toggle Shelf options when the 'display' URL argument is not provided */ +export const DEFAULT_SHELF_DISPLAY_OPTIONS = 'buckets,layout,shelfLayout,inspector' + +/** Clean up data that is older than this (in milliseconds) */ +export const DEFAULT_MAXIMUM_DATA_AGE = 1000 * 60 * 60 * 24 * 100 // 100 days + +/** Default time scale zooming for the UI */ +export const DEFAULT_TIME_SCALE = 1 + +/** Default key to use as the poison key (used to abort/escape hotkey actions) */ +export const DEFAULT_POISON_KEY = 'Escape' + +/** Default keyboard key used as "Confirm" in modal dialogs etc. */ +export const DEFAULT_CONFIRM_KEY_CODE = 'Enter' + /** The expected time it takes from an ingest operation to receiving a new timeline in the playout-gateway */ export const EXPECTED_INGEST_TO_PLAYOUT_TIME = 500 diff --git a/packages/shared-lib/src/core/model/CoreSystemSettings.ts b/packages/shared-lib/src/core/model/CoreSystemSettings.ts index e5392915a58..48ecc549515 100644 --- a/packages/shared-lib/src/core/model/CoreSystemSettings.ts +++ b/packages/shared-lib/src/core/model/CoreSystemSettings.ts @@ -20,4 +20,17 @@ export interface ICoreSystemSettings { heading: string message: string } + + /** Clean up data that is older than this (in milliseconds) */ + maximumDataAge?: number + + /** + * Which keyboard key is used as "Confirm" in modal dialogs etc. + * In some installations, the rightmost Enter key (on the numpad) is dedicated for playout, + * in such cases this must be set to 'Enter' to exclude it. + */ + confirmKeyCode?: 'Enter' | 'AnyEnter' + + /** Key to use as the poison key (aborts hotkey actions). Empty string disables it. Default: 'Escape' */ + poisonKey?: string } diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts index 574dbc84f5d..1fe8f27d92f 100644 --- a/packages/shared-lib/src/core/model/StudioSettings.ts +++ b/packages/shared-lib/src/core/model/StudioSettings.ts @@ -115,4 +115,28 @@ export interface IStudioSettings { * How long before their start time a rundown owned piece be added to the timeline */ rundownGlobalPiecesPrepareTime?: number + + /** Should the segment in the Rundown view automatically rewind after it stops being live? Default: true */ + autoRewindLeavingSegment?: boolean + + /** Disable the blur border in the RundownView. Default: false */ + disableBlurBorder?: boolean + + /** Allow grabbing the entire timeline to scroll it. Default: true */ + allowGrabbingTimeline?: boolean + + /** If true, countdowns of videos will count down to the last freeze-frame of the video instead of to the end of the video. Default: true */ + useCountdownToFreezeFrame?: boolean + + /** Default value used to toggle Shelf options when the 'display' URL argument is not provided. Default: 'buckets,layout,shelfLayout,inspector' */ + defaultShelfDisplayOptions?: string + + /** Default duration (in milliseconds) to use to render parts when no duration is provided. Default: 3000 */ + defaultDisplayDuration?: number + + /** Default time scale zooming for the timelines in the UI. Default: 1 */ + defaultTimeScale?: number + + /** How many segments of history to show when scrolling back in time (0 = show current segment only). Default: 0 */ + followOnAirSegmentsHistory?: number } diff --git a/packages/webui/src/client/collections/index.ts b/packages/webui/src/client/collections/index.ts index ea417b74b83..1024c46645f 100644 --- a/packages/webui/src/client/collections/index.ts +++ b/packages/webui/src/client/collections/index.ts @@ -16,6 +16,8 @@ import type { ExternalMessageQueueObj } from '@sofie-automation/corelib/dist/dat import type { PackageContainerStatusDB } from '@sofie-automation/corelib/dist/dataModel/PackageContainerStatus' import type { Bucket } from '@sofie-automation/corelib/dist/dataModel/Bucket' import { type ICoreSystem, SYSTEM_ID } from '@sofie-automation/meteor-lib/dist/collections/CoreSystem' +import type { ICoreSystemSettings } from '@sofie-automation/shared-lib/dist/core/model/CoreSystemSettings' +import { applyAndValidateOverrides } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import type { Evaluation } from '@sofie-automation/meteor-lib/dist/collections/Evaluations' import type { ExpectedPackageDB } from '@sofie-automation/corelib/dist/dataModel/ExpectedPackages' import { createSyncMongoCollection, createSyncReadOnlyMongoCollection } from './lib.js' @@ -114,3 +116,9 @@ export const UserActionsLog = createSyncReadOnlyMongoCollection, // pieces: Map, - display?: boolean + displayDuration?: number ): number { return parts.reduce((memo, part) => { return ( @@ -135,7 +135,7 @@ export namespace RundownUtils { (part.instance.timings?.duration || calculatePartInstanceExpectedDurationWithTransition(part.instance) || part.renderedDuration || - (display ? Settings.defaultDisplayDuration : 0)) + (displayDuration ?? 0)) ) }, 0) } @@ -342,7 +342,8 @@ export namespace RundownUtils { invalidateAfter: options?.pieceInstanceSimulation ? invalidateAfter : undefined, includeDisabledPieces: options?.includeDisabledPieces ?? false, showHiddenSourceLayers: getShowHiddenSourceLayers(), - defaultDisplayDuration: Settings.defaultDisplayDuration, + defaultDisplayDuration: + segmentContext.studio?.settings.defaultDisplayDuration ?? DEFAULT_DISPLAY_DURATION, }, }) } diff --git a/packages/webui/src/client/lib/rundownTiming.ts b/packages/webui/src/client/lib/rundownTiming.ts index e8a5be4fd8f..2550ff407b4 100644 --- a/packages/webui/src/client/lib/rundownTiming.ts +++ b/packages/webui/src/client/lib/rundownTiming.ts @@ -23,7 +23,6 @@ import { } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' import { objectFromEntries } from '@sofie-automation/shared-lib/dist/lib/lib' import { getCurrentTime } from './systemTime.js' -import { Settings } from '../lib/Settings.js' import type { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import type { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { CountdownType } from '@sofie-automation/blueprints-integration' @@ -108,7 +107,7 @@ export class RundownTimingCalculator { partInstancesMap: Map, segmentsMap: Map, /** Fallback duration for Parts that have no as-played duration of their own. */ - defaultDuration: number = Settings.defaultDisplayDuration, + defaultDuration: number, partsInQuickLoop: Record ): RundownTimingContext { let totalRundownDuration = 0 @@ -797,21 +796,25 @@ export interface RundownTimingContext { * @param {Array} partIds The IDs of parts that are members of the segment * @return number */ +/** + * @param displayDuration When provided, parts with no duration of their own fall back to this duration + * (the Studio's configured `defaultDisplayDuration`). Omit to not apply any fallback (renders as 0). + */ export function computeSegmentDuration( timingDurations: RundownTimingContext, parts: PartExtended[], - display?: boolean + displayDuration?: number ): number { const partDisplayDurations = timingDurations?.partDisplayDurations - if (!partDisplayDurations) return RundownUtils.getSegmentDuration(parts, display) + if (!partDisplayDurations) return RundownUtils.getSegmentDuration(parts, displayDuration) return parts.reduce((memo, partExtended) => { // total += durations.partDurations ? durations.partDurations[item._id] : (item.duration || item.renderedDuration || 1) const partInstanceTimingId = getPartInstanceTimingId(partExtended.instance) const duration = Math.max( partExtended.instance.timings?.duration || partExtended.renderedDuration || 0, - partDisplayDurations?.[partInstanceTimingId] || (display ? Settings.defaultDisplayDuration : 0) + partDisplayDurations?.[partInstanceTimingId] || (displayDuration ?? 0) ) return memo + duration }, 0) diff --git a/packages/webui/src/client/lib/triggers/TriggersHandler.tsx b/packages/webui/src/client/lib/triggers/TriggersHandler.tsx index 652ee15182a..d507e42c546 100644 --- a/packages/webui/src/client/lib/triggers/TriggersHandler.tsx +++ b/packages/webui/src/client/lib/triggers/TriggersHandler.tsx @@ -28,9 +28,9 @@ import { type TriggerActionEvent, } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' import { Tracker } from 'meteor/tracker' -import { Settings } from '../../lib/Settings.js' +import { DEFAULT_POISON_KEY } from '@sofie-automation/shared-lib/dist/core/constants' import { createInMemorySyncMongoCollection } from '../../collections/lib.js' -import { RundownPlaylists } from '../../collections/index.js' +import { RundownPlaylists, getCoreSystemSettings } from '../../collections/index.js' import { UIShowStyleBases, UITriggeredActions } from '../../ui/Collections.js' import type { PartId, @@ -252,6 +252,8 @@ export const TriggersHandler: React.FC = function TriggersHandler( localSorensen.poison() // cancels all pressed keys, poisons all chords, no hotkey trigger will execute } + const poisonKey = useTracker(() => getCoreSystemSettings()?.poisonKey ?? DEFAULT_POISON_KEY, [], DEFAULT_POISON_KEY) + useEffect(() => { const fKeys = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F12'] // not 'F11', because people use that apparently const ctrlDigitKeys = [ @@ -268,8 +270,6 @@ export const TriggersHandler: React.FC = function TriggersHandler( 'Digit0', ] - const poisonKey: string | null = Settings.poisonKey - if (initialized) { if (poisonKey) { localSorensen.bind(poisonKey, poisonHotkeys, { @@ -318,7 +318,7 @@ export const TriggersHandler: React.FC = function TriggersHandler( fKeys.forEach((key) => localSorensen.unbind(key, preventDefault)) ctrlDigitKeys.forEach((key) => localSorensen.unbind(`Control+${key}`, preventDefault)) } - }, [initialized]) // run once once Sorensen is initialized + }, [initialized, poisonKey]) // run once once Sorensen is initialized (and re-bind if the poison key changes) useRundownViewEventBusListener(RundownViewEvents.TRIGGER_ACTION, triggerAction) diff --git a/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx b/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx index 7ffd8183005..0a3865abf14 100644 --- a/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx +++ b/packages/webui/src/client/lib/ui/containers/modals/Modal.tsx @@ -4,7 +4,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import Escape from './../../../Escape.js' import { SorensenContext } from '../../../SorensenContext.js' -import { Settings } from '../../../../lib/Settings.js' +import { DEFAULT_CONFIRM_KEY_CODE } from '@sofie-automation/shared-lib/dist/core/constants' +import { getCoreSystemSettings } from '../../../../collections/index.js' export interface IModalAttributes { show?: boolean @@ -17,6 +18,8 @@ export type SomeEvent = Event | React.SyntheticEvent export class Modal extends React.Component> { boundKeys: Array = [] sorensen: typeof Sorensen | undefined + /** Captured at bind time so that unbind uses the exact same key, even if the setting changes in between */ + private confirmKeyCode: string = DEFAULT_CONFIRM_KEY_CODE constructor(props: IModalAttributes) { super(props) @@ -39,11 +42,12 @@ export class Modal extends React.Component { if (!this.sorensen) return - this.sorensen.unbind(Settings.confirmKeyCode, this.preventDefault) - this.sorensen.unbind(Settings.confirmKeyCode, this.handleKey) + this.sorensen.unbind(this.confirmKeyCode, this.preventDefault) + this.sorensen.unbind(this.confirmKeyCode, this.handleKey) this.sorensen.unbind('Escape', this.preventDefault) this.sorensen.unbind('Escape', this.handleKey) } diff --git a/packages/webui/src/client/lib/viewPort.ts b/packages/webui/src/client/lib/viewPort.ts index 9810b0a7fda..9d9909c2d57 100644 --- a/packages/webui/src/client/lib/viewPort.ts +++ b/packages/webui/src/client/lib/viewPort.ts @@ -1,7 +1,6 @@ import { SEGMENT_TIMELINE_ELEMENT_ID } from '../ui/SegmentTimeline/SegmentTimeline.js' import { isProtectedString } from '@sofie-automation/shared-lib/dist/lib/protectedString' import RundownViewEventBus, { RundownViewEvents } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' -import { Settings } from '../lib/Settings.js' import type { PartId, PartInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { UIPartInstances, UIParts } from '../ui/Collections.js' import { logger } from './logging.js' @@ -45,6 +44,7 @@ export function getViewPortScrollingState(): { export function maintainFocusOnPartInstance( partInstanceId: PartInstanceId, + followOnAirSegmentsHistory: number, timeWindow: number, forceScroll?: boolean, noAnimation?: boolean @@ -57,7 +57,7 @@ export function maintainFocusOnPartInstance( focusState.isScrolling = true try { - await scrollToPartInstance(partInstanceId, forceScroll, noAnimation) + await scrollToPartInstance(partInstanceId, followOnAirSegmentsHistory, forceScroll, noAnimation) } catch (_error) { // Handle error if needed } finally { @@ -128,19 +128,21 @@ export function clearViewportLifecycleState(): void { export async function scrollToPartInstance( partInstanceId: PartInstanceId, + followOnAirSegmentsHistory: number, forceScroll?: boolean, noAnimation?: boolean ): Promise { quitFocusOnPart() const partInstance = UIPartInstances.findOne(partInstanceId) if (partInstance) { - return scrollToSegment(partInstance.segmentId, forceScroll, noAnimation) + return scrollToSegment(partInstance.segmentId, followOnAirSegmentsHistory, forceScroll, noAnimation) } throw new Error('Could not find PartInstance') } export async function scrollToPart( partId: PartId, + followOnAirSegmentsHistory: number, forceScroll?: boolean, noAnimation?: boolean, zoomInToFit?: boolean @@ -148,7 +150,7 @@ export async function scrollToPart( quitFocusOnPart() const part = UIParts.findOne(partId) if (part) { - await scrollToSegment(part.segmentId, forceScroll, noAnimation) + await scrollToSegment(part.segmentId, followOnAirSegmentsHistory, forceScroll, noAnimation) RundownViewEventBus.emit(RundownViewEvents.GO_TO_PART, { segmentId: part.segmentId, @@ -186,13 +188,17 @@ let currentScrollingElement: HTMLElement | undefined export async function scrollToSegment( elementToScrollToOrSegmentId: HTMLElement | SegmentId, + followOnAirSegmentsHistory: number, forceScroll?: boolean, noAnimation?: boolean ): Promise { clearPendingScrollState() - const elementToScrollTo: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, false) - const historyTarget: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, true) + const elementToScrollTo: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, 0) + const historyTarget: HTMLElement | null = getElementToScrollTo( + elementToScrollToOrSegmentId, + followOnAirSegmentsHistory + ) // historyTarget will be === to elementToScrollTo if history is not used / not found if (!elementToScrollTo || !historyTarget) { @@ -209,15 +215,17 @@ export async function scrollToSegment( function getElementToScrollTo( elementToScrollToOrSegmentId: HTMLElement | SegmentId, - showHistory: boolean + followOnAirSegmentsHistory: number ): HTMLElement | null { if (isProtectedString(elementToScrollToOrSegmentId)) { // Get the current segment element let targetElement = document.querySelector( `#${SEGMENT_TIMELINE_ELEMENT_ID}${elementToScrollToOrSegmentId}` ) - if (showHistory && Settings.followOnAirSegmentsHistory && targetElement) { - let i = Settings.followOnAirSegmentsHistory + // Normalize to a non-negative integer, as the value may originate from external sources (eg. the REST API) + const segmentsHistory = Math.max(0, Math.floor(followOnAirSegmentsHistory)) + if (segmentsHistory && targetElement) { + let i = segmentsHistory // Find previous segments while (i > 0 && targetElement) { diff --git a/packages/webui/src/client/ui/App.tsx b/packages/webui/src/client/ui/App.tsx index 3651e927bd1..83dafc98c39 100644 --- a/packages/webui/src/client/ui/App.tsx +++ b/packages/webui/src/client/ui/App.tsx @@ -35,7 +35,6 @@ import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-d import { ErrorBoundary } from '../lib/ErrorBoundary.js' import { PrompterView } from './Prompter/PrompterView.js' import { ModalDialogGlobalContainer, doModalDialog } from '../lib/ModalDialog.js' -import { Settings } from '../lib/Settings.js' import { DocumentTitleProvider } from '../lib/DocumentTitleProvider.js' import { catchError, firstIfArray, isRunningInPWA } from '../lib/lib.js' import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' @@ -110,9 +109,6 @@ export const App: React.FC = function App() { }, []) useEffect(() => { - if (Settings.customizationClassName) { - document.body.classList.add(Settings.customizationClassName) - } const uiZoom = featureFlags.zoom if (uiZoom !== 1) { document.documentElement.style.fontSize = uiZoom * 16 + 'px' diff --git a/packages/webui/src/client/ui/ClockView/ClockView.tsx b/packages/webui/src/client/ui/ClockView/ClockView.tsx index be2ac34d6da..c38408578eb 100644 --- a/packages/webui/src/client/ui/ClockView/ClockView.tsx +++ b/packages/webui/src/client/ui/ClockView/ClockView.tsx @@ -9,15 +9,18 @@ import { DirectorScreen } from './DirectorScreen/DirectorScreen' import { OverlayScreen } from './OverlayScreen.js' import { OverlayScreenSaver } from './OverlayScreenSaver.js' import { RundownPlaylists } from '../../collections/index.js' +import { UIStudios } from '../Collections.js' import type { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { CameraScreen } from './CameraScreen/index.js' import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub' import { useTranslation } from 'react-i18next' import { ClockViewIndex } from './ClockViewIndex.js' import { MultiviewScreen } from './MultiviewScreen.js' +import { DEFAULT_DISPLAY_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.Element { useSubscription(MeteorPubSub.rundownPlaylistForStudio, studioId, true) + useSubscription(MeteorPubSub.uiStudio, studioId) const { t } = useTranslation() const playlist = useTracker( @@ -29,11 +32,17 @@ export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.E [studioId] ) + const defaultDisplayDuration = useTracker( + () => UIStudios.findOne(studioId)?.settings.defaultDisplayDuration ?? DEFAULT_DISPLAY_DURATION, + [studioId], + DEFAULT_DISPLAY_DURATION + ) + return ( {playlist ? ( - + ) : ( @@ -42,7 +51,7 @@ export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.E {playlist ? ( - + ) : ( @@ -51,7 +60,7 @@ export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.E {playlist ? ( - + ) : ( @@ -59,7 +68,7 @@ export function ClockView({ studioId }: Readonly<{ studioId: StudioId }>): JSX.E )} - + diff --git a/packages/webui/src/client/ui/Prompter/PrompterView.tsx b/packages/webui/src/client/ui/Prompter/PrompterView.tsx index 7f289e5a314..2aa14bebf7d 100644 --- a/packages/webui/src/client/ui/Prompter/PrompterView.tsx +++ b/packages/webui/src/client/ui/Prompter/PrompterView.tsx @@ -1,6 +1,7 @@ import React, { createContext, type PropsWithChildren, type ReactNode, useRef } from 'react' import _ from 'underscore' import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' +import { DEFAULT_DISPLAY_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' import ClassNames from 'classnames' import { Meteor } from 'meteor/meteor' import { parse as queryStringParse } from 'query-string' @@ -600,7 +601,10 @@ export class PrompterViewContent extends React.Component ) : this.props.rundownPlaylist ? ( <> - + { + scrollToPartInstance( + this.props.playlist.currentPartInfo?.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0, + true + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } @@ -514,7 +518,10 @@ const RundownViewContent = translateWithTracker { if (this.props.playlist && this.props.playlist.nextPartInfo) { - scrollToPartInstance(this.props.playlist.nextPartInfo.partInstanceId).catch((error) => { + scrollToPartInstance( + this.props.playlist.nextPartInfo.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0 + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } @@ -527,7 +534,11 @@ const RundownViewContent = translateWithTracker { + scrollToPartInstance( + this.props.playlist.currentPartInfo.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0, + true + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } else if ( @@ -538,7 +549,11 @@ const RundownViewContent = translateWithTracker { + scrollToPartInstance( + this.props.playlist.nextPartInfo.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0, + false + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } else if ( @@ -549,7 +564,13 @@ const RundownViewContent = translateWithTracker { this._goToLiveSegmentShortTimeout = undefined if (this.props.playlist && this.props.playlist.nextPartInfo) { - scrollToPartInstance(this.props.playlist.nextPartInfo.partInstanceId, true).catch((error) => { + scrollToPartInstance( + this.props.playlist.nextPartInfo.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0, + true + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } @@ -773,7 +798,11 @@ const RundownViewContent = translateWithTracker { + scrollToPartInstance( + this.props.playlist.currentPartInfo.partInstanceId, + this.props.studio?.settings.followOnAirSegmentsHistory ?? 0, + true + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) this._goToLiveSegmentLongTimeout = setTimeout(() => { @@ -927,7 +956,7 @@ const RundownViewContent = translateWithTracker { RundownViewEventBus.emit(RundownViewEvents.HIGHLIGHT, e.sourceLocator) }) @@ -1480,7 +1509,7 @@ const RundownViewContent = translateWithTracker - {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( + {this.props.userPermissions.studio && !this.props.studio?.settings.disableBlurBorder && (
{ if (!segmentId || !partId) return - scrollToPart(partId, false, false, false).catch(logger.error) - }, [segmentId, partId]) + scrollToPart(partId, followOnAirSegmentsHistory, false, false, false).catch(logger.error) + }, [segmentId, partId, followOnAirSegmentsHistory]) const onSegmentIdentifierClick = useCallback(() => { if (!segmentId) return - scrollToSegment(segmentId, false, false).catch(logger.error) - }, [segmentId]) + scrollToSegment(segmentId, followOnAirSegmentsHistory, false, false).catch(logger.error) + }, [segmentId, followOnAirSegmentsHistory]) return ( diff --git a/packages/webui/src/client/ui/RundownView/MediaStatusPopUp/index.tsx b/packages/webui/src/client/ui/RundownView/MediaStatusPopUp/index.tsx index a3ea073143e..79a4c8c9a0c 100644 --- a/packages/webui/src/client/ui/RundownView/MediaStatusPopUp/index.tsx +++ b/packages/webui/src/client/ui/RundownView/MediaStatusPopUp/index.tsx @@ -17,6 +17,7 @@ import { MediaStatusPopUpItem } from './MediaStatusPopUpItem.js' import { translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { MediaStatusPopUpHeader } from './MediaStatusPopUpHeader.js' import { RundownPlaylists } from '../../../collections/index.js' +import { UIStudios } from '../../Collections.js' import { MediaStatusPopUpSegmentRule } from './MediaStatusPopUpSegmentRule.js' import { mapOrFallback, useDebounce } from '../../../lib/lib.js' import { Spinner } from '../../../lib/Spinner.js' @@ -60,23 +61,27 @@ export function MediaStatusPopUp({ playlistId }: Readonly): JSX.Element const playlistIds = useMemo(() => [playlistId], [playlistId]) - const { currentPartInstanceId, nextPartInstanceId } = useTracker( + const { currentPartInstanceId, nextPartInstanceId, followOnAirSegmentsHistory } = useTracker( () => { const playlist = RundownPlaylists.findOne(playlistId, { projection: { nextPartInfo: 1, currentPartInfo: 1, + studioId: 1, }, }) + const studio = playlist && UIStudios.findOne(playlist.studioId) return { currentPartInstanceId: playlist?.currentPartInfo?.partInstanceId, nextPartInstanceId: playlist?.nextPartInfo?.partInstanceId, + followOnAirSegmentsHistory: studio?.settings.followOnAirSegmentsHistory ?? 0, } }, [playlistId], { currentPartInstanceId: undefined, nextPartInstanceId: undefined, + followOnAirSegmentsHistory: 0, } ) @@ -134,6 +139,7 @@ export function MediaStatusPopUp({ playlistId }: Readonly): JSX.Element partId={item.partId} segmentId={item.segmentId} partInstanceId={item.partInstanceId} + followOnAirSegmentsHistory={followOnAirSegmentsHistory} partIdentifier={item.partIdentifier} segmentIdentifier={item.segmentIdentifier} sourceLayerName={item.sourceLayerName} diff --git a/packages/webui/src/client/ui/RundownView/RundownDetachedShelf.tsx b/packages/webui/src/client/ui/RundownView/RundownDetachedShelf.tsx index 37d09806e7a..0eaecab8dfa 100644 --- a/packages/webui/src/client/ui/RundownView/RundownDetachedShelf.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownDetachedShelf.tsx @@ -8,7 +8,7 @@ import { Shelf } from '../Shelf/Shelf' import { UserPermissionsContext } from '../UserPermissions' import { RundownSorensenContext } from './RundownSorensenContext' import { RundownTimingProvider } from './RundownTiming/RundownTimingProvider' -import { Settings } from '../../lib/Settings' +import { DEFAULT_DISPLAY_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' import type { RundownLayoutShelfBase } from '@sofie-automation/meteor-lib/dist/collections/RundownLayouts' import type { UIShowStyleBase } from '@sofie-automation/corelib/src/dataModel/ShowStyleBase' import type { UIStudio } from '@sofie-automation/corelib/src/dataModel/Studio' @@ -33,7 +33,10 @@ export function RundownDetachedShelf({ const userPermissions = useContext(UserPermissionsContext) return ( - + { + scrollToPartInstance( + this.playlist.nextPartInfo.partInstanceId, + this.studio.settings.followOnAirSegmentsHistory ?? 0 + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } diff --git a/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx b/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx index 970b33298b1..13779739f13 100644 --- a/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownTiming/RundownTimingProvider.tsx @@ -44,8 +44,8 @@ interface IRundownTimingProviderProps { * onto TIMING_DEFAULT_REFRESH_INTERVAL. */ refreshInterval?: number - /** Fallback duration for Parts that have no as-played duration of their own. */ - defaultDuration?: number + /** Fallback duration for Parts that have no as-played duration of their own (the Studio's `defaultDisplayDuration`). */ + defaultDuration: number } interface IRundownTimingProviderState {} diff --git a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx index 4643ee6ef27..3a6025faea7 100644 --- a/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownViewContextProviders.tsx @@ -4,7 +4,7 @@ import { RundownPlaylistOperationsContextProvider } from './RundownHeader/useRun import { PreviewPopUpContextProvider } from '../PreviewPopUp/PreviewPopUpContext' import { SelectedElementProvider } from './SelectedElementsContext' import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' -import { Settings } from '../../lib/Settings' +import { DEFAULT_DISPLAY_DURATION } from '@sofie-automation/shared-lib/dist/core/constants' import type { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import type { UIStudio } from '@sofie-automation/corelib/src/dataModel/Studio' @@ -21,7 +21,10 @@ export function RundownViewContextProviders({ onActivate: () => void }>): React.JSX.Element { return ( - + { - scrollToPart(partId, false, true, true).catch((error) => { + scrollToPart(partId, props.studio.settings.followOnAirSegmentsHistory ?? 0, false, true, true).catch((error) => { if (!error.toString().match(/another scroll/)) logger.error('scrollToPart', error) }) } diff --git a/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx b/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx index 9313d5a2cc4..a70e72a9d20 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Constants.tsx @@ -1,5 +1,3 @@ -import { Settings } from '../../lib/Settings.js' - export const MAGIC_TIME_SCALE_FACTOR = 0.03 export const SIMULATED_PLAYBACK_SOFT_MARGIN = 0 @@ -12,5 +10,8 @@ export const TIMELINE_RIGHT_PADDING = parseInt(localStorage.getItem('EXP_timeline_right_padding')!) || LIVELINE_HISTORY_SIZE + LIVE_LINE_TIME_PADDING export const FALLBACK_ZOOM_FACTOR = MAGIC_TIME_SCALE_FACTOR -export const MINIMUM_ZOOM_FACTOR = // TODO: This is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 - parseInt(localStorage.getItem('EXP_timeline_min_time_scale')!) || MAGIC_TIME_SCALE_FACTOR * Settings.defaultTimeScale +/** The minimum zoom factor for the timelines, derived from the Studio's `defaultTimeScale` setting. */ +export function getMinimumZoomFactor(defaultTimeScale: number): number { + // TODO: The localStorage override is only temporary, for hands-on tweaking -- Jan Starzak, 2021-06-01 + return parseInt(localStorage.getItem('EXP_timeline_min_time_scale')!) || MAGIC_TIME_SCALE_FACTOR * defaultTimeScale +} diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx index 1a52ac55ae2..0664e0ade6d 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx @@ -13,7 +13,6 @@ import { type NoticeLevel, getNoticeLevelForPieceStatus } from '../../../lib/not import { RundownUtils } from '../../../lib/rundown.js' import { FreezeFrameIcon } from '../../../lib/ui/icons/freezeFrame.js' import StudioContext from '../../RundownView/StudioContext.js' -import { Settings } from '../../../lib/Settings.js' import { PieceStatusCode } from '@sofie-automation/corelib/dist/dataModel/Piece' import { HourglassIconSmall } from '../../../lib/ui/icons/notifications.js' import { logger } from '../../../lib/logging.js' @@ -373,7 +372,7 @@ class VTSourceRendererBase extends CustomLayerItemRenderer { - scrollToPart(partId, false, true, true).catch((error) => { - if (!error.toString().match(/another scroll/)) logger.error(error) - }) + scrollToPart(partId, this.props.studio.settings.followOnAirSegmentsHistory ?? 0, false, true, true).catch( + (error) => { + if (!error.toString().match(/another scroll/)) logger.error(error) + } + ) } private onPartTooSmallChanged = (part: PartUi, displayDuration: number | false, actualDuration: number | false) => { @@ -1175,6 +1181,7 @@ export class SegmentTimelineClass extends React.Component
{ + scrollToSegment( + this.props.segmentId, + this.props.studio.settings.followOnAirSegmentsHistory ?? 0, + true + ).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } @@ -258,7 +262,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( // segment is stopping from being live if (this.state.isLiveSegment === true && isLiveSegment === false) { this.setState({ isLiveSegment: false }, () => { - if (Settings.autoRewindLeavingSegment) { + if (this.props.studio.settings.autoRewindLeavingSegment ?? true) { this.onRewindSegment() this.onShowEntireSegment() } @@ -428,7 +432,11 @@ const SegmentTimelineContainerContent = withResolvedSegment( 0, Math.min( scrollLeft, - (computeSegmentDuration(this.context.durations, this.props.parts, true) || 1) - + (computeSegmentDuration( + this.context.durations, + this.props.parts, + this.props.studio.settings.defaultDisplayDuration ?? DEFAULT_DISPLAY_DURATION + ) || 1) - LIVELINE_HISTORY_SIZE / this.state.timeScale ) ), @@ -638,7 +646,10 @@ const SegmentTimelineContainerContent = withResolvedSegment( const livePosition = this.state.isLiveSegment ? this.state.livePosition : 0 let newScale = calculatedTimelineDivWidth / (segmentDisplayDuration - livePosition) - newScale = Math.min(MINIMUM_ZOOM_FACTOR, newScale) + newScale = Math.min( + getMinimumZoomFactor(this.props.studio.settings.defaultTimeScale ?? DEFAULT_TIME_SCALE), + newScale + ) if (!Number.isFinite(newScale) || newScale === 0) { newScale = FALLBACK_ZOOM_FACTOR } diff --git a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx index 69c0421aec5..431bea51e86 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx @@ -43,6 +43,7 @@ interface ITimelineGridProps { isLiveSegment: boolean partInstances: PartUi[] currentPartInstanceId: PartInstanceId | null + defaultDisplayDuration: number onResize: (size: number[]) => void } @@ -330,7 +331,7 @@ export class TimelineGrid extends React.Component { total += duration }) } else { - total = RundownUtils.getSegmentDuration(this.props.partInstances, true) + total = RundownUtils.getSegmentDuration(this.props.partInstances, this.props.defaultDisplayDuration) } return total } @@ -414,7 +415,8 @@ export class TimelineGrid extends React.Component { nextProps.scrollLeft !== this.props.scrollLeft || nextProps.isLiveSegment !== this.props.isLiveSegment || nextProps.partInstances !== this.props.partInstances || - nextProps.currentPartInstanceId !== this.props.currentPartInstanceId + nextProps.currentPartInstanceId !== this.props.currentPartInstanceId || + nextProps.defaultDisplayDuration !== this.props.defaultDisplayDuration ) { return true } @@ -429,7 +431,8 @@ export class TimelineGrid extends React.Component { if ( prevProps.isLiveSegment !== this.props.isLiveSegment || prevProps.partInstances !== this.props.partInstances || - prevProps.currentPartInstanceId !== this.props.currentPartInstanceId + prevProps.currentPartInstanceId !== this.props.currentPartInstanceId || + prevProps.defaultDisplayDuration !== this.props.defaultDisplayDuration ) { this.lastTotalSegmentDuration = null } diff --git a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx index 78627f4f415..93f09f288c4 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx @@ -21,6 +21,7 @@ import { ForceQuickLoopAutoNext, ShelfButtonSize } from '@sofie-automation/share import type { SomeObjectOverrideOp } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' import { useOverrideOpHelperForSimpleObject } from '../util/OverrideOpHelper.js' import { IntInputControl } from '../../../lib/Components/IntInput.js' +import { FloatInputControl } from '../../../lib/Components/FloatInput.js' import { useMemo } from 'react' import { CheckboxControl } from '../../../lib/Components/Checkbox.js' import { TextInputControl } from '../../../lib/Components/TextInput.js' @@ -438,6 +439,109 @@ function StudioSettings({ studio }: { studio: DBStudio }): JSX.Element { /> )} + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => ( + + )} + + + + {(value, handleUpdate) => } + + + + {(value, handleUpdate) => ( + + )} + + + + {(value, handleUpdate) => ( + + )} + ) } diff --git a/packages/webui/src/client/ui/Settings/SystemManagement.tsx b/packages/webui/src/client/ui/Settings/SystemManagement.tsx index f60fb845268..64cc8579071 100644 --- a/packages/webui/src/client/ui/Settings/SystemManagement.tsx +++ b/packages/webui/src/client/ui/Settings/SystemManagement.tsx @@ -17,8 +17,12 @@ import { LabelActual, LabelAndOverrides, LabelAndOverridesForCheckbox, + LabelAndOverridesForDropdown, + LabelAndOverridesForInt, LabelAndOverridesForMultiLineText, } from '../../lib/Components/LabelAndOverrides.js' +import { IntInputControl } from '../../lib/Components/IntInput.js' +import { DropdownInputControl, type DropdownInputOption } from '../../lib/Components/DropdownInput.js' import { catchError } from '../../lib/lib.js' import { SystemManagementBlueprint } from './SystemManagement/Blueprint.js' import type { SomeObjectOverrideOp } from '@sofie-automation/corelib/dist/settings/objectWithOverrides' @@ -61,6 +65,8 @@ export default function SystemManagement(): JSX.Element | null { + +
@@ -294,6 +300,59 @@ function SystemManagementCronJobs({ coreSystem }: Readonly) > {(value, handleUpdate) => } + + + {(value, handleUpdate) => } + +
+ + ) +} + +const CONFIRM_KEY_CODE_OPTIONS: DropdownInputOption<'Enter' | 'AnyEnter'>[] = [ + { name: 'Enter', value: 'Enter', i: 0 }, + { name: 'Any Enter (including Numpad Enter)', value: 'AnyEnter', i: 1 }, +] + +function SystemManagementKeyboard({ coreSystem }: Readonly) { + const { t } = useTranslation() + + const { wrappedItem, overrideHelper } = useCoreSystemSettingsWithOverrides(coreSystem) + + return ( + <> +

{t('Keyboard')}

+
+ + {(value, handleUpdate, options) => ( + + )} + + + + {(value, handleUpdate) => } +
) diff --git a/packages/webui/src/client/ui/Shelf/Shelf.tsx b/packages/webui/src/client/ui/Shelf/Shelf.tsx index 112d2896e07..cdf7ddb3b40 100644 --- a/packages/webui/src/client/ui/Shelf/Shelf.tsx +++ b/packages/webui/src/client/ui/Shelf/Shelf.tsx @@ -42,7 +42,8 @@ import { Buckets } from '../../collections' import { UserPermissionsContext } from '../UserPermissions' import { useLocation } from 'react-router' import type { IStudioSettings, UIStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' -import { Settings } from '../../lib/Settings' +import { DEFAULT_SHELF_DISPLAY_OPTIONS, DEFAULT_POISON_KEY } from '@sofie-automation/shared-lib/dist/core/constants' +import { getCoreSystemSettings } from '../../collections/index.js' import { type ParsedQuery, parse as queryStringParse } from 'query-string' import type { UIShowStyleBase } from '@sofie-automation/corelib/src/dataModel/ShowStyleBase.js' @@ -564,7 +565,7 @@ export function Shelf( [] ) - const poisonKey = Settings.poisonKey + const poisonKey = useTracker(() => getCoreSystemSettings()?.poisonKey ?? DEFAULT_POISON_KEY, [], DEFAULT_POISON_KEY) const hotkeys = [ // Register additional hotkeys or legend entries ...(poisonKey @@ -596,7 +597,11 @@ export function Shelf( } function getShelfDisplayOptions(studioSettings: IStudioSettings | undefined, params: ParsedQuery): ShelfDisplayOptions { - const displayOptions = ((params['display'] as string) || Settings.defaultShelfDisplayOptions).split(',') + const displayOptions = ( + (params['display'] as string) || + studioSettings?.defaultShelfDisplayOptions || + DEFAULT_SHELF_DISPLAY_OPTIONS + ).split(',') return { // If buckets are enabled in Studiosettings, it can also be filtered in the URLs display options. diff --git a/packages/webui/src/client/ui/UserPermissions.tsx b/packages/webui/src/client/ui/UserPermissions.tsx index f6e2654e14a..98b7f7d97e5 100644 --- a/packages/webui/src/client/ui/UserPermissions.tsx +++ b/packages/webui/src/client/ui/UserPermissions.tsx @@ -14,7 +14,7 @@ import { import { parse as queryStringParse } from 'query-string' import { MeteorCall } from '../lib/meteorApi.js' import type { UserPermissions } from '@sofie-automation/meteor-lib/dist/userPermissions' -import { Settings } from '../lib/Settings.js' +import { APP_HEADER_AUTH_ENABLED } from '../lib/Settings.js' import { useTracker } from '../lib/ReactMeteorData/ReactMeteorData.js' import { Meteor } from 'meteor/meteor' @@ -34,10 +34,10 @@ export const UserPermissionsContext = React.createContext( - Settings.enableHeaderAuth + APP_HEADER_AUTH_ENABLED ? NO_PERMISSIONS : { studio: getLocalAllowStudio(), @@ -52,7 +52,7 @@ export function useUserPermissions(): [roles: UserPermissions, ready: boolean] { const isConnected = useTracker(() => Meteor.status().connected, [], false) useEffect(() => { - if (!Settings.enableHeaderAuth) return + if (!APP_HEADER_AUTH_ENABLED) return // Do nothing when not connected. Persist the previous values. if (!isConnected) return @@ -78,10 +78,10 @@ export function useUserPermissions(): [roles: UserPermissions, ready: boolean] { return () => { clearInterval(interval) } - }, [Settings.enableHeaderAuth, isConnected]) + }, [APP_HEADER_AUTH_ENABLED, isConnected]) useEffect(() => { - if (Settings.enableHeaderAuth) return + if (APP_HEADER_AUTH_ENABLED) return if (!location.search) return @@ -110,7 +110,7 @@ export function useUserPermissions(): [roles: UserPermissions, ready: boolean] { service: getLocalAllowService(), gateway: false, }) - }, [location.search, Settings.enableHeaderAuth]) + }, [location.search, APP_HEADER_AUTH_ENABLED]) // A naive memoizing of the value, to avoid reactions when the value is identical return [useMemo(() => permissions, [JSON.stringify(permissions)]), ready]