From 5ce78fbad44207a6dba08219bad99351a1e7c4e1 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sun, 25 Feb 2024 12:52:32 -0800 Subject: [PATCH] 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 --- .../e2e/offline/02_offline_video_embed.cy.js | 6 +- .../ui/OfflineEmbed/OfflineEmbed.module.scss | 99 ++++++++++++ .../ui/OfflineEmbed/OfflineEmbed.stories.tsx | 39 +++++ .../ui/OfflineEmbed/OfflineEmbed.tsx | 148 ++++++++++++++++++ web/pages/embed/video/index.tsx | 15 +- 5 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 web/components/ui/OfflineEmbed/OfflineEmbed.module.scss create mode 100644 web/components/ui/OfflineEmbed/OfflineEmbed.stories.tsx create mode 100644 web/components/ui/OfflineEmbed/OfflineEmbed.tsx diff --git a/test/automated/browser/cypress/e2e/offline/02_offline_video_embed.cy.js b/test/automated/browser/cypress/e2e/offline/02_offline_video_embed.cy.js index 242e5de5d..9e4b34300 100644 --- a/test/automated/browser/cypress/e2e/offline/02_offline_video_embed.cy.js +++ b/test/automated/browser/cypress/e2e/offline/02_offline_video_embed.cy.js @@ -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'); }); }); diff --git a/web/components/ui/OfflineEmbed/OfflineEmbed.module.scss b/web/components/ui/OfflineEmbed/OfflineEmbed.module.scss new file mode 100644 index 000000000..a692e4cd1 --- /dev/null +++ b/web/components/ui/OfflineEmbed/OfflineEmbed.module.scss @@ -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; + } +} diff --git a/web/components/ui/OfflineEmbed/OfflineEmbed.stories.tsx b/web/components/ui/OfflineEmbed/OfflineEmbed.stories.tsx new file mode 100644 index 000000000..5480a8b39 --- /dev/null +++ b/web/components/ui/OfflineEmbed/OfflineEmbed.stories.tsx @@ -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; + +export default meta; + +const Template: StoryFn = args => ( + + + +); + +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', + }, +}; diff --git a/web/components/ui/OfflineEmbed/OfflineEmbed.tsx b/web/components/ui/OfflineEmbed/OfflineEmbed.tsx new file mode 100644 index 000000000..077126d3a --- /dev/null +++ b/web/components/ui/OfflineEmbed/OfflineEmbed.tsx @@ -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 = ({ + 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 ( +
+ + {streamName} + +
+ +
+
This stream is not currently live.
+
{subtitle}
+ +
+
{streamName}
+ + {errorMessage && ( + + )} + + {currentMode === EmbedMode.CanFollow && ( + + )} + + {currentMode === EmbedMode.InProgress && ( + + Follow the instructions on your Fediverse server to complete the follow. + + )} + + {currentMode === EmbedMode.FollowPrompt && ( +
+ handleAccountChange(e.target.value)} + placeholder="Your fediverse account @account@server" + defaultValue={remoteAccount} + /> +
+ You'll be redirected to your Fediverse server and asked to confirm the + action. +
+ + + +
+ )} +
+ +
+
+ ); +}; diff --git a/web/pages/embed/video/index.tsx b/web/pages/embed/video/index.tsx index 6b2b7893f..25d2aa8d0 100644 --- a/web/pages/embed/video/index.tsx +++ b/web/pages/embed/video/index.tsx @@ -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(serverStatusState); const clientConfig = useRecoilValue(clientConfigStateAtom); const appState = useRecoilValue(appStateAtom); - const { name } = clientConfig; + const { name, summary, offlineMessage, federation } = clientConfig; - const { offlineMessage } = clientConfig; const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } = status; const online = useRecoilValue(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 = ; @@ -57,11 +58,11 @@ export default function VideoEmbed() { }, []); const offlineState = ( - );