Merge branch 'webv2' into fix/ImplementPasswordRules
This commit is contained in:
118
web/components/admin/EditCustomJavascript.tsx
Normal file
118
web/components/admin/EditCustomJavascript.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect, useContext, FC } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
API_CUSTOM_JAVASCRIPT,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const EditCustomJavascript: FC = () => {
|
||||
const [content, setContent] = useState('/* Enter custom Javascript here */');
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { customJavascript: initialContent } = instanceDetails;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
// Clear out any validation states and messaging
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
setHasChanged(false);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
// posts all the tags at once as an array obj
|
||||
async function handleSave() {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_CUSTOM_JAVASCRIPT,
|
||||
data: { value: content },
|
||||
onSuccess: (message: string) => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'customJavascript',
|
||||
value: content,
|
||||
path: 'instanceDetails',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
},
|
||||
});
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
}, [instanceDetails]);
|
||||
|
||||
const onCSSValueChange = React.useCallback(value => {
|
||||
setContent(value);
|
||||
if (value !== initialContent && !hasChanged) {
|
||||
setHasChanged(true);
|
||||
} else if (value === initialContent && hasChanged) {
|
||||
setHasChanged(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="edit-custom-css">
|
||||
<Title level={3} className="section-title">
|
||||
Customize your page styling with CSS
|
||||
</Title>
|
||||
|
||||
<p className="description">
|
||||
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
|
||||
components on the page. Refer to the{' '}
|
||||
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
|
||||
CSS & Components guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="description">
|
||||
Please input plain CSS text, as this will be directly injected onto your page during load.
|
||||
</p>
|
||||
|
||||
<CodeMirror
|
||||
value={content}
|
||||
placeholder="/* Enter custom Javascript here */"
|
||||
theme={bbedit}
|
||||
height="200px"
|
||||
extensions={[javascript()]}
|
||||
onChange={onCSSValueChange}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div className="page-content-actions">
|
||||
{hasChanged && (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -261,7 +261,7 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
|
||||
},
|
||||
upgradeVersion && {
|
||||
key: 'upgrade',
|
||||
label: <Link href="/upgrade">{upgradeMessage}</Link>,
|
||||
label: <Link href="/admin/upgrade">{upgradeMessage}</Link>,
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
|
||||
@@ -125,7 +125,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
|
||||
|
||||
if (!config?.federation?.enabled) {
|
||||
data.push({
|
||||
icon: <img alt="fediverse" width="20px" src="fediverse-white.png" />,
|
||||
icon: <img alt="fediverse" width="20px" src="/img/fediverse-color.png" />,
|
||||
title: 'Add your Owncast instance to the Fediverse',
|
||||
content: (
|
||||
<div>
|
||||
|
||||
@@ -284,6 +284,8 @@ export const VideoVariantForm: FC<VideoVariantFormProps> = ({
|
||||
onConfirm={handleVideoPassConfirm}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
getPopupContainer={triggerNode => triggerNode}
|
||||
placement="topLeft"
|
||||
>
|
||||
{/* adding an <a> tag to force Popcofirm to register click on toggle */}
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { FC, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Col, Collapse, Row, Slider, Space } from 'antd';
|
||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||
@@ -35,49 +35,39 @@ const chatColorVariables = [
|
||||
{ name: 'theme-color-users-7', description: '' },
|
||||
];
|
||||
|
||||
const paletteVariables = [
|
||||
{ name: 'theme-color-palette-0', description: '' },
|
||||
{ name: 'theme-color-palette-1', description: '' },
|
||||
{ name: 'theme-color-palette-2', description: '' },
|
||||
{ name: 'theme-color-palette-3', description: '' },
|
||||
{ name: 'theme-color-palette-4', description: '' },
|
||||
{ name: 'theme-color-palette-5', description: '' },
|
||||
{ name: 'theme-color-palette-6', description: '' },
|
||||
{ name: 'theme-color-palette-7', description: '' },
|
||||
{ name: 'theme-color-palette-8', description: '' },
|
||||
{ name: 'theme-color-palette-9', description: '' },
|
||||
{ name: 'theme-color-palette-10', description: '' },
|
||||
{ name: 'theme-color-palette-11', description: '' },
|
||||
{ name: 'theme-color-palette-12', description: '' },
|
||||
];
|
||||
|
||||
const componentColorVariables = [
|
||||
{ name: 'theme-color-background-main', description: 'Background' },
|
||||
{ name: 'theme-color-action', description: 'Action' },
|
||||
{ name: 'theme-color-action-hover', description: 'Action Hover' },
|
||||
{ name: 'theme-color-components-primary-button-border', description: 'Primary Button Border' },
|
||||
{ name: 'theme-color-components-primary-button-text', description: 'Primary Button Text' },
|
||||
{ name: 'theme-color-components-chat-background', description: 'Chat Background' },
|
||||
{ name: 'theme-color-components-chat-text', description: 'Text: Chat' },
|
||||
{ name: 'theme-color-components-text-on-dark', description: 'Text: Light' },
|
||||
{ name: 'theme-color-components-text-on-light', description: 'Text: Dark' },
|
||||
{ name: 'theme-color-background-header', description: 'Header/Footer' },
|
||||
{ name: 'theme-color-components-content-background', description: 'Page Content' },
|
||||
{ name: 'theme-color-components-scrollbar-background', description: 'Scrollbar Background' },
|
||||
{ name: 'theme-color-components-scrollbar-thumb', description: 'Scrollbar Thumb' },
|
||||
{
|
||||
name: 'theme-color-components-video-status-bar-background',
|
||||
description: 'Video Status Bar Background',
|
||||
},
|
||||
{
|
||||
name: 'theme-color-components-video-status-bar-foreground',
|
||||
description: 'Video Status Bar Foreground',
|
||||
},
|
||||
];
|
||||
|
||||
const others = [{ name: 'theme-rounded-corners', description: 'Corner radius' }];
|
||||
|
||||
// Create an object so these vars can be indexed by name.
|
||||
const allAvailableValues = [
|
||||
...paletteVariables,
|
||||
...componentColorVariables,
|
||||
...chatColorVariables,
|
||||
...others,
|
||||
].reduce((obj, val) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[val.name] = { name: val.name, description: val.description };
|
||||
return obj;
|
||||
}, {});
|
||||
const allAvailableValues = [...componentColorVariables, ...chatColorVariables, ...others].reduce(
|
||||
(obj, val) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[val.name] = { name: val.name, description: val.description };
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
function ColorPicker({
|
||||
@@ -106,6 +96,7 @@ function ColorPicker({
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Appearance() {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
@@ -113,7 +104,9 @@ export default function Appearance() {
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { appearanceVariables } = instanceDetails;
|
||||
|
||||
const [colors, setColors] = useState<Record<string, AppearanceVariable>>();
|
||||
const [defaultValues, setDefaultValues] = useState<Record<string, AppearanceVariable>>();
|
||||
const [customValues, setCustomValues] = useState<Record<string, AppearanceVariable>>();
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
let resetTimer = null;
|
||||
@@ -123,39 +116,37 @@ export default function Appearance() {
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const setColorDefaults = () => {
|
||||
const setDefaults = () => {
|
||||
const c = {};
|
||||
[...paletteVariables, ...componentColorVariables, ...chatColorVariables, ...others].forEach(
|
||||
color => {
|
||||
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
`--${color.name}`,
|
||||
);
|
||||
c[color.name] = { value: resolvedColor.trim(), description: color.description };
|
||||
},
|
||||
);
|
||||
setColors(c);
|
||||
[...componentColorVariables, ...chatColorVariables, ...others].forEach(color => {
|
||||
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
`--${color.name}`,
|
||||
);
|
||||
c[color.name] = { value: resolvedColor.trim(), description: color.description };
|
||||
});
|
||||
setDefaultValues(c);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setColorDefaults();
|
||||
setDefaults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(appearanceVariables).length === 0) return;
|
||||
|
||||
const c = colors || {};
|
||||
const c = {};
|
||||
Object.keys(appearanceVariables).forEach(key => {
|
||||
c[key] = {
|
||||
value: appearanceVariables[key],
|
||||
description: allAvailableValues[key]?.description || '',
|
||||
};
|
||||
});
|
||||
setColors(c);
|
||||
setCustomValues(c);
|
||||
}, [appearanceVariables]);
|
||||
|
||||
const updateColor = (variable: string, color: string, description: string) => {
|
||||
setColors({
|
||||
...colors,
|
||||
setCustomValues({
|
||||
...customValues,
|
||||
[variable]: { value: color, description },
|
||||
});
|
||||
};
|
||||
@@ -167,7 +158,7 @@ export default function Appearance() {
|
||||
onSuccess: () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setColorDefaults();
|
||||
setCustomValues(null);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
@@ -178,8 +169,8 @@ export default function Appearance() {
|
||||
|
||||
const save = async () => {
|
||||
const c = {};
|
||||
Object.keys(colors).forEach(color => {
|
||||
c[color] = colors[color].value;
|
||||
Object.keys(customValues).forEach(color => {
|
||||
c[color] = customValues[color].value;
|
||||
});
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
@@ -202,7 +193,31 @@ export default function Appearance() {
|
||||
updateColor(variableName, `${value.toString()}px`, '');
|
||||
};
|
||||
|
||||
if (!colors) {
|
||||
type ColorCollectionProps = {
|
||||
variables: { name; description }[];
|
||||
};
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
const ColorCollection: FC<ColorCollectionProps> = ({ variables }) => {
|
||||
const cc = variables.map(colorVar => {
|
||||
const source = customValues?.[colorVar.name] ? customValues : defaultValues;
|
||||
const { name, description } = colorVar;
|
||||
const { value } = source[name];
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={value}
|
||||
name={name}
|
||||
description={description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{cc}</>;
|
||||
};
|
||||
|
||||
if (!defaultValues) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -217,56 +232,15 @@ export default function Appearance() {
|
||||
Certain sections of the interface can be customized by selecting new colors for them.
|
||||
</p>
|
||||
<Row gutter={[16, 16]}>
|
||||
{componentColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ColorCollection variables={componentColorVariables} />
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Chat User Colors</Title>} key="2">
|
||||
<Row gutter={[16, 16]}>
|
||||
{chatColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Theme Colors</Title>} key="3">
|
||||
<Row gutter={[16, 16]}>
|
||||
{paletteVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ColorCollection variables={chatColorVariables} />
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel header={<Title level={3}>Other Settings</Title>} key="4">
|
||||
How rounded should corners be?
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -278,7 +252,9 @@ export default function Appearance() {
|
||||
onChange={v => {
|
||||
onBorderRadiusChange(v);
|
||||
}}
|
||||
value={Number(colors['theme-rounded-corners']?.value?.replace('px', '') || 0)}
|
||||
value={Number(
|
||||
defaultValues['theme-rounded-corners']?.value?.replace('px', '') || 0,
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
@@ -286,7 +262,7 @@ export default function Appearance() {
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '30px',
|
||||
borderRadius: `${colors['theme-rounded-corners']?.value}`,
|
||||
borderRadius: `${defaultValues['theme-rounded-corners']?.value}`,
|
||||
backgroundColor: 'var(--theme-color-palette-7)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import { TextField, TEXTFIELD_TYPE_PASSWORD } from '../TextField';
|
||||
import { FormStatusIndicator } from '../FormStatusIndicator';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
TWITTER_CONFIG_FIELDS,
|
||||
} from '../../../utils/config-constants';
|
||||
import { ToggleSwitch } from '../ToggleSwitch';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../utils/input-statuses';
|
||||
import { UpdateArgs } from '../../../types/config-section';
|
||||
import { TEXTFIELD_TYPE_TEXT } from '../TextFieldWithSubmit';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const ConfigNotify = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { notifications } = serverConfig || {};
|
||||
const { twitter } = notifications || {};
|
||||
|
||||
const [formDataValues, setFormDataValues] = useState<any>({});
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
enabled,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
accessToken,
|
||||
accessTokenSecret,
|
||||
bearerToken,
|
||||
goLiveMessage,
|
||||
} = twitter || {};
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
accessToken,
|
||||
accessTokenSecret,
|
||||
bearerToken,
|
||||
goLiveMessage,
|
||||
});
|
||||
}, [twitter]);
|
||||
|
||||
const canSave = (): boolean => {
|
||||
const { apiKey, apiSecret, accessToken, accessTokenSecret, bearerToken, goLiveMessage } =
|
||||
formDataValues;
|
||||
|
||||
return (
|
||||
!!apiKey &&
|
||||
!!apiSecret &&
|
||||
!!accessToken &&
|
||||
!!accessTokenSecret &&
|
||||
!!bearerToken &&
|
||||
!!goLiveMessage
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEnableSaveButton(canSave());
|
||||
}, [formDataValues]);
|
||||
|
||||
// update individual values in state
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// toggle switch.
|
||||
const handleSwitchChange = (switchEnabled: boolean) => {
|
||||
const previouslySaved = formDataValues.enabled;
|
||||
|
||||
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
|
||||
|
||||
return switchEnabled !== previouslySaved;
|
||||
};
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
setEnableSaveButton(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const postValue = formDataValues;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: '/notifications/twitter',
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'twitter',
|
||||
value: postValue,
|
||||
path: 'notifications',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Twitter</Title>
|
||||
<p className="description reduced-margins">
|
||||
Let your Twitter followers know each time you go live.
|
||||
</p>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<p className="description reduced-margins">
|
||||
<a href="https://owncast.online/docs/notifications" target="_blank" rel="noreferrer">
|
||||
Read how to configure your Twitter account
|
||||
</a>{' '}
|
||||
to support posting from Owncast.
|
||||
</p>
|
||||
<p className="description reduced-margins">
|
||||
<a
|
||||
href="https://developer.twitter.com/en/portal/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
And then get your Twitter developer credentials
|
||||
</a>{' '}
|
||||
to fill in below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
apiPath=""
|
||||
fieldName="enabled"
|
||||
label="Enable Twitter"
|
||||
onChange={handleSwitchChange}
|
||||
checked={formDataValues.enabled}
|
||||
/>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.apiKey}
|
||||
required
|
||||
value={formDataValues.apiKey}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.apiSecret}
|
||||
type={TEXTFIELD_TYPE_PASSWORD}
|
||||
required
|
||||
value={formDataValues.apiSecret}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.accessToken}
|
||||
required
|
||||
value={formDataValues.accessToken}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.accessTokenSecret}
|
||||
type={TEXTFIELD_TYPE_PASSWORD}
|
||||
required
|
||||
value={formDataValues.accessTokenSecret}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.bearerToken}
|
||||
required
|
||||
value={formDataValues.bearerToken}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.goLiveMessage}
|
||||
type={TEXTFIELD_TYPE_TEXT}
|
||||
required
|
||||
value={formDataValues.goLiveMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={save}
|
||||
style={{
|
||||
display: enableSaveButton ? 'inline-block' : 'none',
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ConfigNotify;
|
||||
@@ -45,13 +45,11 @@
|
||||
}
|
||||
|
||||
.virtuoso::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: auto;
|
||||
background-color: var(--theme-color-components-chat-background);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.virtuoso::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-color-components-scrollbar-thumb);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chatTextField {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
.chatModerationNotification {
|
||||
background-color: var(--theme-background-primary);
|
||||
color: var(--theme-color-components-chat-text);
|
||||
margin: 5px;
|
||||
border-radius: 15px;
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
padding: 10px 10px;
|
||||
@include flexCenter;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
rgb(83, 67, 130) 80%
|
||||
);
|
||||
margin: 5px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
@@ -34,4 +35,13 @@
|
||||
background-color: var(--theme-color-palette-12);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--theme-color-palette-4);
|
||||
:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-color: var(--theme-color-palette-15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
|
||||
padding: 4px 0.1vw;
|
||||
padding: 0.6em;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--theme-color-palette-3);
|
||||
background-color: var(--theme-color-components-chat-background);
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
@@ -23,7 +23,6 @@
|
||||
transition: box-shadow 90ms ease-in-out;
|
||||
&:focus-within {
|
||||
background-color: var(--theme-color-components-form-field-background);
|
||||
// outline: 1px solid var(--theme-color-components-form-field-border);
|
||||
box-shadow: inset 0px 0px 2px 2px var(--theme-color-palette-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ $p-size: 8px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
|
||||
mark {
|
||||
padding-left: 0.35em;
|
||||
@@ -52,10 +53,16 @@ $p-size: 8px;
|
||||
display: none;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
& button:focus,
|
||||
& button:active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: var(--theme-rounded-corners);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .modMenuWrapper {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tooltip } from 'antd';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { decodeHTML } from 'entities';
|
||||
import linkifyHtml from 'linkify-html';
|
||||
import styles from './ChatUserMessage.module.scss';
|
||||
import { formatTimestamp } from './messageFmt';
|
||||
import { ChatMessage } from '../../../interfaces/chat-message.model';
|
||||
@@ -107,6 +108,8 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
||||
})}
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
<div className={styles.background} style={{ color }} />
|
||||
|
||||
{!sameUserAsLast && (
|
||||
<UserTooltip user={user}>
|
||||
<div className={styles.user} style={{ color }}>
|
||||
@@ -119,11 +122,10 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
||||
<Highlight search={highlightString}>
|
||||
<div
|
||||
className={styles.message}
|
||||
dangerouslySetInnerHTML={{ __html: formattedMessage }}
|
||||
dangerouslySetInnerHTML={{ __html: linkifyHtml(formattedMessage) }}
|
||||
/>
|
||||
</Highlight>
|
||||
</Tooltip>
|
||||
|
||||
{showModeratorMenu && (
|
||||
<div className={styles.modMenuWrapper}>
|
||||
<ChatModerationActionMenu
|
||||
@@ -134,7 +136,6 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.background} style={{ color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,10 +29,10 @@ export const ContentHeader: FC<ContentHeaderProps> = ({
|
||||
<Logo src={logo} />
|
||||
</div>
|
||||
<div className={styles.titleSection}>
|
||||
<div className={cn(styles.title, styles.row, 'header-title')}>{name}</div>
|
||||
<div className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
|
||||
<h2 className={cn(styles.title, styles.row, 'header-title')}>{name}</h2>
|
||||
<h3 className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
|
||||
<Linkify>{title || summary}</Linkify>
|
||||
</div>
|
||||
</h3>
|
||||
<div className={cn(styles.tagList, styles.row)}>
|
||||
{tags.length > 0 && tags.map(tag => <span key={tag}>#{tag} </span>)}
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,12 @@ export const UserDropdown: FC<UserDropdownProps> = ({ username: defaultUsername
|
||||
Authenticate
|
||||
</Menu.Item>
|
||||
{appState.chatAvailable && (
|
||||
<Menu.Item key="3" icon={<MessageOutlined />} onClick={() => toggleChatVisibility()}>
|
||||
<Menu.Item
|
||||
key="3"
|
||||
icon={<MessageOutlined />}
|
||||
onClick={() => toggleChatVisibility()}
|
||||
aria-expanded={chatToggleVisible}
|
||||
>
|
||||
{chatToggleVisible ? 'Hide Chat' : 'Show Chat'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,13 @@ import Head from 'next/head';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Script from 'next/script';
|
||||
import {
|
||||
ClientConfigStore,
|
||||
isChatAvailableSelector,
|
||||
clientConfigStateAtom,
|
||||
fatalErrorStateAtom,
|
||||
appStateAtom,
|
||||
} from '../../stores/ClientConfigStore';
|
||||
import { Content } from '../../ui/Content/Content';
|
||||
import { Header } from '../../ui/Header/Header';
|
||||
@@ -21,6 +23,7 @@ import { ServerRenderedHydration } from '../../ServerRendered/ServerRenderedHydr
|
||||
import { Theme } from '../../theme/Theme';
|
||||
import styles from './Main.module.scss';
|
||||
import { PushNotificationServiceWorker } from '../../workers/PushNotificationServiceWorker/PushNotificationServiceWorker';
|
||||
import { AppStateOptions } from '../../stores/application-state';
|
||||
|
||||
const lockBodyStyle = `
|
||||
body {
|
||||
@@ -45,9 +48,11 @@ export const Main: FC = () => {
|
||||
const { name, title, customStyles } = clientConfig;
|
||||
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
|
||||
const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom);
|
||||
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
|
||||
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const { chatDisabled } = clientConfig;
|
||||
const { videoAvailable } = appState;
|
||||
|
||||
useEffect(() => {
|
||||
setupNoLinkReferrer(layoutRef.current);
|
||||
@@ -133,8 +138,15 @@ export const Main: FC = () => {
|
||||
<PushNotificationServiceWorker />
|
||||
<TitleNotifier name={name} />
|
||||
<Theme />
|
||||
<Script strategy="afterInteractive" src="/customjavascript" />
|
||||
|
||||
<Layout ref={layoutRef} className={styles.layout}>
|
||||
<Header name={title || name} chatAvailable={isChatAvailable} chatDisabled={chatDisabled} />
|
||||
<Header
|
||||
name={title || name}
|
||||
chatAvailable={isChatAvailable}
|
||||
chatDisabled={chatDisabled}
|
||||
online={videoAvailable}
|
||||
/>
|
||||
<Content />
|
||||
{fatalError && (
|
||||
<FatalErrorStateModal title={fatalError.title} message={fatalError.message} />
|
||||
|
||||
@@ -77,6 +77,7 @@ export const NameChangeModal: FC = () => {
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Your chat display name"
|
||||
aria-label="Your chat display name"
|
||||
maxLength={30}
|
||||
showCount
|
||||
defaultValue={displayName}
|
||||
@@ -90,7 +91,7 @@ export const NameChangeModal: FC = () => {
|
||||
>
|
||||
{colorOptions.map(e => (
|
||||
<Option key={e.toString()} title={e}>
|
||||
<UserColor color={e} />
|
||||
<UserColor color={e} aria-label={e.toString()} />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -35,6 +35,8 @@ const ACCESS_TOKEN_KEY = 'accessToken';
|
||||
let serverStatusRefreshPoll: ReturnType<typeof setInterval>;
|
||||
let hasBeenModeratorNotified = false;
|
||||
|
||||
const serverConnectivityError = `Cannot connect to the Owncast service. Please check your internet connection or if needed, double check this Owncast server is running.`;
|
||||
|
||||
// Server status is what gets updated such as viewer count, durations,
|
||||
// stream title, online/offline state, etc.
|
||||
export const serverStatusState = atom<ServerStatus>({
|
||||
@@ -200,10 +202,7 @@ export const ClientConfigStore: FC = () => {
|
||||
setGlobalFatalErrorMessage(null);
|
||||
setHasLoadedConfig(true);
|
||||
} catch (error) {
|
||||
setGlobalFatalError(
|
||||
'Unable to reach Owncast server',
|
||||
`Owncast cannot launch. Please make sure the Owncast server is running.`,
|
||||
);
|
||||
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
|
||||
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
|
||||
}
|
||||
};
|
||||
@@ -221,10 +220,7 @@ export const ClientConfigStore: FC = () => {
|
||||
setGlobalFatalErrorMessage(null);
|
||||
} catch (error) {
|
||||
sendEvent(AppStateEvent.Fail);
|
||||
setGlobalFatalError(
|
||||
'Unable to reach Owncast server',
|
||||
`Owncast cannot launch. Please make sure the Owncast server is running.`,
|
||||
);
|
||||
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
|
||||
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
|
||||
}
|
||||
};
|
||||
@@ -325,7 +321,13 @@ export const ClientConfigStore: FC = () => {
|
||||
const startChat = async () => {
|
||||
try {
|
||||
const { socketHostOverride } = clientConfig;
|
||||
const host = socketHostOverride || window.location.toString();
|
||||
|
||||
// Get a copy of the browser location without #fragments.
|
||||
const l = window.location;
|
||||
l.hash = '';
|
||||
const location = l.toString().replaceAll('#', '');
|
||||
const host = socketHostOverride || location;
|
||||
|
||||
ws = new WebsocketService(accessToken, '/ws', host);
|
||||
ws.handleMessage = handleMessage;
|
||||
setWebsocketService(ws);
|
||||
|
||||
@@ -20,14 +20,11 @@
|
||||
}
|
||||
|
||||
.mainSection::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: auto;
|
||||
background-color: var(--theme-color-components-scrollbar-background);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mainSection::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-color-components-scrollbar-thumb);
|
||||
border-radius: 1px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topSection {
|
||||
|
||||
@@ -115,7 +115,7 @@ const DesktopContent = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.lowerHalf}>
|
||||
<div className={styles.lowerHalf} id="skip-to-content">
|
||||
<ContentHeader
|
||||
name={name}
|
||||
title={streamTitle}
|
||||
@@ -233,7 +233,7 @@ export const Content: FC = () => {
|
||||
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
|
||||
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
|
||||
const currentUser = useRecoilValue(currentUserAtom);
|
||||
|
||||
const serverStatus = useRecoilValue<ServerStatus>(serverStatusState);
|
||||
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
|
||||
const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom);
|
||||
const online = useRecoilValue<boolean>(isOnlineSelector);
|
||||
@@ -259,6 +259,7 @@ export const Content: FC = () => {
|
||||
const { account: fediverseAccount, enabled: fediverseEnabled } = federation;
|
||||
const { browser: browserNotifications } = notifications;
|
||||
const { enabled: browserNotificationsEnabled } = browserNotifications;
|
||||
const { online: isStreamLive } = serverStatus;
|
||||
const [externalActionToDisplay, setExternalActionToDisplay] = useState<ExternalAction>(null);
|
||||
|
||||
const [supportsBrowserNotifications, setSupportsBrowserNotifications] = useState(false);
|
||||
@@ -334,9 +335,16 @@ export const Content: FC = () => {
|
||||
<div className={styles.mainSection}>
|
||||
<div className={styles.topSection}>
|
||||
{appState.appLoading && <Skeleton loading active paragraph={{ rows: 7 }} />}
|
||||
{online && <OwncastPlayer source="/hls/stream.m3u8" online={online} />}
|
||||
{online && (
|
||||
<OwncastPlayer
|
||||
source="/hls/stream.m3u8"
|
||||
online={online}
|
||||
title={streamTitle || name}
|
||||
/>
|
||||
)}
|
||||
{!online && !appState.appLoading && (
|
||||
<OfflineBanner
|
||||
showsHeader={false}
|
||||
streamName={name}
|
||||
customText={offlineMessage}
|
||||
notificationsEnabled={browserNotificationsEnabled}
|
||||
@@ -346,7 +354,7 @@ export const Content: FC = () => {
|
||||
onFollowClick={() => setShowFollowModal(true)}
|
||||
/>
|
||||
)}
|
||||
{online && (
|
||||
{isStreamLive && (
|
||||
<Statusbar
|
||||
online={online}
|
||||
lastConnectTime={lastConnectTime}
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
background-color: var(--theme-color-background-header);
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
color: var(--theme-color-components-text-on-dark);
|
||||
font-family: var(--theme-text-body-font-family);
|
||||
|
||||
padding: 0 0.6rem;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-top: 1px solid rgba(214, 211, 211, 0.5);
|
||||
|
||||
@@ -6,7 +6,7 @@ export type FooterProps = {
|
||||
};
|
||||
|
||||
export const Footer: FC<FooterProps> = ({ version }) => (
|
||||
<footer className={styles.footer}>
|
||||
<footer className={styles.footer} id="footer">
|
||||
<span>
|
||||
Powered by <a href="https://owncast.online">{version}</a>
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 20;
|
||||
padding: 1rem 0.7rem;
|
||||
padding: 0.7rem;
|
||||
box-shadow: 0px 1px 3px 1px rgb(0 0 0 / 10%);
|
||||
background-color: var(--theme-color-background-header);
|
||||
|
||||
@@ -42,3 +42,18 @@
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.skipLink {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skipLink:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Tag, Tooltip } from 'antd';
|
||||
import { FC } from 'react';
|
||||
import cn from 'classnames';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { OwncastLogo } from '../../common/OwncastLogo/OwncastLogo';
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
@@ -18,19 +19,32 @@ export type HeaderComponentProps = {
|
||||
name: string;
|
||||
chatAvailable: boolean;
|
||||
chatDisabled: boolean;
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
export const Header: FC<HeaderComponentProps> = ({
|
||||
name = 'Your stream title',
|
||||
chatAvailable,
|
||||
chatDisabled,
|
||||
online,
|
||||
}) => (
|
||||
<header className={cn([`${styles.header}`], 'global-header')}>
|
||||
{online && (
|
||||
<Link href="#player" className={styles.skipLink}>
|
||||
Skip to player
|
||||
</Link>
|
||||
)}
|
||||
<Link href="#skip-to-content" className={styles.skipLink}>
|
||||
Skip to page content
|
||||
</Link>
|
||||
<Link href="#footer" className={styles.skipLink}>
|
||||
Skip to footer
|
||||
</Link>
|
||||
<div className={styles.logo}>
|
||||
<div id="header-logo" className={styles.logoImage}>
|
||||
<OwncastLogo variant="contrast" />
|
||||
</div>
|
||||
<h1 className={styles.title} id="global-header-text" title={name}>
|
||||
<h1 className={styles.title} id="global-header-text">
|
||||
{name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
flex-direction: column;
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
background-color: var(--theme-color-background-main);
|
||||
margin: 1rem auto;
|
||||
margin: 3rem auto;
|
||||
border-radius: var(--theme-rounded-corners);
|
||||
padding: 1rem;
|
||||
padding: 2.5em;
|
||||
font-size: 1.2rem;
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
|
||||
.bodyText {
|
||||
line-height: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type OfflineBannerProps = {
|
||||
lastLive?: Date;
|
||||
notificationsEnabled: boolean;
|
||||
fediverseAccount?: string;
|
||||
showsHeader?: boolean;
|
||||
onNotifyClick?: () => void;
|
||||
onFollowClick?: () => void;
|
||||
};
|
||||
@@ -27,6 +28,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
|
||||
lastLive,
|
||||
notificationsEnabled,
|
||||
fediverseAccount,
|
||||
showsHeader = true,
|
||||
onNotifyClick,
|
||||
onFollowClick,
|
||||
}) => {
|
||||
@@ -74,8 +76,12 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
|
||||
return (
|
||||
<div id="offline-banner" className={styles.outerContainer}>
|
||||
<div className={styles.innerContainer}>
|
||||
<div className={styles.header}>{streamName}</div>
|
||||
<Divider className={styles.separator} />
|
||||
{showsHeader && (
|
||||
<>
|
||||
<div className={styles.header}>{streamName}</div>
|
||||
<Divider className={styles.separator} />
|
||||
</>
|
||||
)}
|
||||
<div className={styles.bodyText}>{text}</div>
|
||||
{lastLive && (
|
||||
<div className={styles.lastLiveDate}>
|
||||
|
||||
@@ -7,7 +7,6 @@ export type SocialLinksProps = {
|
||||
links: SocialLink[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const SocialLinks: FC<SocialLinksProps> = ({ links }) => (
|
||||
<div className={styles.links}>
|
||||
{links.map(link => (
|
||||
@@ -22,7 +21,6 @@ export const SocialLinks: FC<SocialLinksProps> = ({ links }) => (
|
||||
<Image
|
||||
src={link.icon || '/img/platformlogos/default.svg'}
|
||||
alt={link.platform}
|
||||
title={link.platform}
|
||||
className={styles.link}
|
||||
width="30"
|
||||
height="30"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
padding: var(--content-padding);
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
background-color: var(--component-background);
|
||||
color: var(--theme-color-components-video-status-bar-foreground);
|
||||
background-color: var(--theme-color-components-video-status-bar-background);
|
||||
font-family: var(--theme-text-display-font-family);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const Statusbar: FC<StatusbarProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.statusbar}>
|
||||
<div className={styles.statusbar} role="status">
|
||||
<div>{onlineMessage}</div>
|
||||
<div>{rightSideMessage}</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,33 @@
|
||||
height: 75px;
|
||||
width: 250px;
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
@include screen(mobile){
|
||||
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
display: inline-block;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: calc(85%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account {
|
||||
color: var(--theme-color-components-text-on-light);
|
||||
word-break: break-all;
|
||||
line-height: 0.9rem;
|
||||
}
|
||||
|
||||
@include screen(mobile) {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-text-link);
|
||||
border-color: var(--theme-color-action);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -26,11 +46,6 @@
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.account {
|
||||
color: var(--theme-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SingleFollower: FC<SingleFollowerProps> = ({ follower }) => (
|
||||
</Avatar>
|
||||
</Col>
|
||||
<Col>
|
||||
<Row>{follower.name}</Row>
|
||||
<Row className={styles.name}>{follower.name}</Row>
|
||||
<Row className={styles.account}>{follower.username}</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
justify-items: center;
|
||||
max-height: 75vh;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
.player,
|
||||
.poster {
|
||||
// position: static;
|
||||
// height: auto !important;
|
||||
width: 100%;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 75vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,5 @@ export const LiveDemo = Template.bind({});
|
||||
LiveDemo.args = {
|
||||
online: true,
|
||||
source: 'https://watch.owncast.online/hls/stream.m3u8',
|
||||
title: 'Stream title',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import { isVideoPlayingAtom, clockSkewAtom } from '../../stores/ClientConfigStor
|
||||
import PlaybackMetrics from '../metrics/playback';
|
||||
import createVideoSettingsMenuButton from '../settings-menu';
|
||||
import LatencyCompensator from '../latencyCompensator';
|
||||
|
||||
import styles from './OwncastPlayer.module.scss';
|
||||
|
||||
const VIDEO_CONFIG_URL = '/api/video/variants';
|
||||
@@ -26,6 +25,7 @@ export type OwncastPlayerProps = {
|
||||
source: string;
|
||||
online: boolean;
|
||||
initiallyMuted?: boolean;
|
||||
title: string;
|
||||
};
|
||||
|
||||
async function getVideoSettings() {
|
||||
@@ -44,6 +44,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
||||
source,
|
||||
online,
|
||||
initiallyMuted = false,
|
||||
title,
|
||||
}) => {
|
||||
const playerRef = React.useRef(null);
|
||||
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
|
||||
@@ -85,13 +86,13 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const setLatencyCompensatorItemTitle = title => {
|
||||
const setLatencyCompensatorItemTitle = t => {
|
||||
const item = document.querySelector('.latency-toggle-item > .vjs-menu-item-text');
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.innerHTML = title;
|
||||
item.innerHTML = t;
|
||||
};
|
||||
|
||||
const startLatencyCompensator = () => {
|
||||
@@ -218,6 +219,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
||||
controls: true,
|
||||
responsive: true,
|
||||
fluid: false,
|
||||
fill: true,
|
||||
playsinline: true,
|
||||
liveui: true,
|
||||
preload: 'auto',
|
||||
@@ -306,10 +308,10 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.container} id="player">
|
||||
{online && (
|
||||
<div className={styles.player}>
|
||||
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} />
|
||||
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} aria-label={title} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.poster}>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.player {
|
||||
height: auto !important;
|
||||
width: 100%;
|
||||
video {
|
||||
position: static !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ export const VideoPoster: FC<VideoPosterProps> = ({ online, initialSrc, src: bas
|
||||
<CrossfadeImage
|
||||
src={src}
|
||||
duration={duration}
|
||||
objectFit="cover"
|
||||
objectFit="contain"
|
||||
height="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function createVideoSettingsMenuButton(player, videojs, qualities, latenc
|
||||
}
|
||||
|
||||
const menuButton = new MenuButton();
|
||||
menuButton.el().setAttribute('aria-label', 'Settings');
|
||||
|
||||
// If none of the settings in this menu are applicable then don't show it.
|
||||
const tech = player.tech({ IWillNotUseThisInPlugins: true });
|
||||
|
||||
Reference in New Issue
Block a user