0

New offline embed (#3599)

* WIP

* feat(web): add new offline embed view. First step of #2917

* feat(web): support remote fediverse follow flow from embed

* feat(chore): add back offline video embed browser test
This commit is contained in:
Gabe Kangas 2024-02-25 12:52:32 -08:00 committed by GitHub
parent 96c769cf6f
commit 5ce78fbad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 296 additions and 11 deletions

View File

@ -7,9 +7,7 @@ describe(`Offline video embed`, () => {
});
// Offline banner
it('Has correct offline banner values', () => {
cy.contains('This stream is offline. Check back soon!').should(
'be.visible'
);
it('Has correct offline embed values', () => {
cy.contains('This stream is not currently live.').should('be.visible');
});
});

View File

@ -0,0 +1,99 @@
@import '../../../styles/mixins';
.offlineContainer {
position: absolute;
width: 100%;
height: 100%;
border-radius: 8px;
background-image: linear-gradient(to bottom, rgb(18 22 29 / 0%) 0%, rgb(18 22 29 / 75%) 100%),
radial-gradient(circle, rgb(18 22 29 / 0%) 0%, rgb(18 22 29 / 50%) 100%),
linear-gradient(to bottom, rgb(122 92 243 / 100%) 0%, rgb(35 134 226 / 100%) 100%),
linear-gradient(rgb(240 243 248 / 100%), rgb(240 243 248 / 100%));
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
/* Content */
.content {
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
text-align: center;
/* Message */
.message {
color: rgb(255 255 255 / 100%);
font-family: var(--theme-text-body-font-family);
font-style: normal;
font-size: 16px;
font-weight: 400;
line-height: 1.375;
letter-spacing: 0;
text-decoration: none;
text-transform: none;
}
/* Heading */
.heading {
color: rgb(255 255 255 / 100%);
font-family: var(--theme-text-display-font-family);
font-style: normal;
font-size: 24px;
font-weight: 500;
line-height: 1.125;
letter-spacing: -0.125px;
text-decoration: none;
text-transform: none;
}
/* Page Logo */
.pageLogo {
position: relative;
width: 10vw;
height: 10vw;
min-height: 64px;
min-width: 64px;
max-height: 100px;
max-width: 100px;
border-radius: 96px;
background-color: rgb(255 255 255 / 100%);
border: 5px solid rgb(18 22 29 / 100%);
display: flex;
flex-flow: row nowrap;
align-items: flex-start;
justify-content: flex-start;
gap: 0;
padding: 10px;
background-size: cover;
background-position: center;
}
/* Page Name */
.pageName {
color: rgb(255 255 255 / 100%);
font-family: var(--theme-text-display-font-family);
font-style: normal;
font-size: 20px;
font-weight: 500;
line-height: 1.1875;
letter-spacing: -0.0625px;
text-decoration: none;
text-transform: none;
}
}
.submitButton {
margin-top: 10px;
}
.footer {
color: white;
padding: 5px;
}
}

View File

@ -0,0 +1,39 @@
import { StoryFn, Meta } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { OfflineEmbed } from './OfflineEmbed';
import OfflineState from '../../../stories/assets/mocks/offline-state.png';
const meta = {
title: 'owncast/Layout/Offline Embed',
component: OfflineEmbed,
parameters: {
design: {
type: 'image',
url: OfflineState,
scale: 0.5,
},
docs: {
description: {
component: `When the stream is offline the player should be replaced by this banner that can support custom text and notify actions.`,
},
},
},
} satisfies Meta<typeof OfflineEmbed>;
export default meta;
const Template: StoryFn<typeof OfflineEmbed> = args => (
<RecoilRoot>
<OfflineEmbed {...args} />
</RecoilRoot>
);
export const ExampleDefaultWithNotifications = {
render: Template,
args: {
streamName: 'Cool stream 42',
subtitle: 'This stream rocks. You should watch it.',
image: 'https://placehold.co/600x400/orange/white',
},
};

View File

@ -0,0 +1,148 @@
import { FC, useEffect, useState } from 'react';
import classNames from 'classnames';
import Head from 'next/head';
import { Button, Input, Space, Spin, Alert, Typography } from 'antd';
import styles from './OfflineEmbed.module.scss';
import { isValidFediverseAccount } from '../../../utils/validators';
const { Title } = Typography;
const ENDPOINT = '/api/remotefollow';
export type OfflineEmbedProps = {
streamName: string;
subtitle?: string;
image: string;
supportsFollows: boolean;
};
enum EmbedMode {
CannotFollow = 1,
CanFollow,
FollowPrompt,
InProgress,
}
export const OfflineEmbed: FC<OfflineEmbedProps> = ({
streamName,
subtitle,
image,
supportsFollows,
}) => {
const [currentMode, setCurrentMode] = useState(EmbedMode.CanFollow);
const [remoteAccount, setRemoteAccount] = useState(null);
const [valid, setValid] = useState(false);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
useEffect(() => {
if (!supportsFollows) {
setCurrentMode(EmbedMode.CannotFollow);
}
}, [supportsFollows]);
const followButtonPressed = async () => {
setCurrentMode(EmbedMode.FollowPrompt);
};
const remoteFollowButtonPressed = async () => {
setLoading(true);
setCurrentMode(EmbedMode.CannotFollow);
try {
const sanitizedAccount = remoteAccount.replace(/^@+/, '');
const request = { account: sanitizedAccount };
const rawResponse = await fetch(ENDPOINT, {
method: 'POST',
body: JSON.stringify(request),
});
const result = await rawResponse.json();
if (result.redirectUrl) {
window.open(result.redirectUrl, '_blank');
}
if (!result.success) {
setErrorMessage(result.message);
setLoading(false);
return;
}
if (!result.redirectUrl) {
setErrorMessage('Unable to follow.');
setLoading(false);
return;
}
} catch (e) {
setErrorMessage(e.message);
}
setLoading(false);
};
const handleAccountChange = a => {
setRemoteAccount(a);
if (isValidFediverseAccount(a)) {
setValid(true);
} else {
setValid(false);
}
};
return (
<div>
<Head>
<title>{streamName}</title>
</Head>
<div className={classNames(styles.offlineContainer)}>
<Spin spinning={loading}>
<div className={styles.content}>
<div className={styles.heading}>This stream is not currently live.</div>
<div className={styles.message}>{subtitle}</div>
<div className={styles.pageLogo} style={{ backgroundImage: `url(${image})` }} />
<div className={styles.pageName}>{streamName}</div>
{errorMessage && (
<Alert message="Follow Error" description={errorMessage} type="error" showIcon />
)}
{currentMode === EmbedMode.CanFollow && (
<Button className={styles.submitButton} type="primary" onClick={followButtonPressed}>
Follow Server
</Button>
)}
{currentMode === EmbedMode.InProgress && (
<Title level={4} className={styles.heading}>
Follow the instructions on your Fediverse server to complete the follow.
</Title>
)}
{currentMode === EmbedMode.FollowPrompt && (
<div>
<Input
value={remoteAccount}
size="large"
onChange={e => handleAccountChange(e.target.value)}
placeholder="Your fediverse account @account@server"
defaultValue={remoteAccount}
/>
<div className={styles.footer}>
You&apos;ll be redirected to your Fediverse server and asked to confirm the
action.
</div>
<Space className={styles.buttons}>
<Button
className={styles.submitButton}
disabled={!valid}
type="primary"
onClick={remoteFollowButtonPressed}
>
Submit and Follow
</Button>
</Space>
</div>
)}
</div>
</Spin>
</div>
</div>
);
};

View File

@ -10,7 +10,6 @@ import {
serverStatusState,
appStateAtom,
} from '../../../components/stores/ClientConfigStore';
import { OfflineBanner } from '../../../components/ui/OfflineBanner/OfflineBanner';
import { Statusbar } from '../../../components/ui/Statusbar/Statusbar';
import { OwncastPlayer } from '../../../components/video/OwncastPlayer/OwncastPlayer';
import { ClientConfig } from '../../../interfaces/client-config.model';
@ -18,17 +17,18 @@ import { ServerStatus } from '../../../interfaces/server-status.model';
import { AppStateOptions } from '../../../components/stores/application-state';
import { Theme } from '../../../components/theme/Theme';
import styles from './VideoEmbed.module.scss';
import { OfflineEmbed } from '../../../components/ui/OfflineEmbed/OfflineEmbed';
export default function VideoEmbed() {
const status = useRecoilValue<ServerStatus>(serverStatusState);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const { name } = clientConfig;
const { name, summary, offlineMessage, federation } = clientConfig;
const { offlineMessage } = clientConfig;
const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } = status;
const online = useRecoilValue<boolean>(isOnlineSelector);
const { enabled: socialEnabled } = federation;
const router = useRouter();
@ -48,6 +48,7 @@ export default function VideoEmbed() {
);
const initiallyMuted = query.initiallyMuted === 'true';
const supportsSocialFollow = socialEnabled && query.supportsSocialFollow !== 'false';
const loadingState = <Skeleton active style={{ padding: '10px' }} paragraph={{ rows: 10 }} />;
@ -57,11 +58,11 @@ export default function VideoEmbed() {
}, []);
const offlineState = (
<OfflineBanner
<OfflineEmbed
streamName={name}
customText={offlineMessage}
lastLive={lastDisconnectTime}
notificationsEnabled={false}
subtitle={offlineMessage || summary}
image="https://placehold.co/600x400/orange/white"
supportsFollows={supportsSocialFollow}
/>
);