From be9bbafc45636c194d257a8ba14ba7094eb41c45 Mon Sep 17 00:00:00 2001 From: Ole Wieners Date: Wed, 27 Nov 2024 21:28:32 +0100 Subject: [PATCH] Move thumbnail back to synced data --- backend/src/api/model/event.rs | 26 ++++++-- frontend/src/routes/Embed.tsx | 1 + frontend/src/routes/Search.tsx | 10 +-- frontend/src/routes/Video.tsx | 8 +-- .../Realm/Content/Edit/EditMode/Video.tsx | 14 +--- frontend/src/routes/manage/Video/Shared.tsx | 3 +- frontend/src/routes/manage/Video/index.tsx | 11 +++- frontend/src/schema.graphql | 3 +- frontend/src/ui/Blocks/Video.tsx | 2 +- frontend/src/ui/Blocks/VideoList.tsx | 4 +- frontend/src/ui/Video.tsx | 65 ++++++------------- frontend/src/ui/player/Paella.tsx | 2 +- frontend/src/ui/player/index.tsx | 4 +- 13 files changed, 65 insertions(+), 88 deletions(-) diff --git a/backend/src/api/model/event.rs b/backend/src/api/model/event.rs index 0c6a4a9cc..a85162604 100644 --- a/backend/src/api/model/event.rs +++ b/backend/src/api/model/event.rs @@ -51,6 +51,7 @@ pub(crate) struct SyncedEventData { updated: DateTime, start_time: Option>, end_time: Option>, + thumbnail: Option, /// Duration in milliseconds duration: i64, @@ -59,7 +60,6 @@ pub(crate) struct SyncedEventData { #[derive(Debug)] pub(crate) struct AuthorizedEventData { tracks: Vec, - thumbnail: Option, captions: Vec, segments: Vec, } @@ -98,12 +98,12 @@ impl_from_db!( start_time: row.start_time(), end_time: row.end_time(), duration: row.duration(), + thumbnail: row.thumbnail(), }), EventState::Waiting => None, }, authorized_data: match row.state::() { EventState::Ready => Some(AuthorizedEventData { - thumbnail: row.thumbnail(), tracks: row.tracks::>().into_iter().map(Track::from).collect(), captions: row.captions::>() .into_iter() @@ -165,6 +165,9 @@ impl SyncedEventData { fn duration(&self) -> f64 { self.duration as f64 } + fn thumbnail(&self) -> Option<&str> { + self.thumbnail.as_deref() + } } /// Represents event data that is only accessible for users with read access @@ -174,9 +177,6 @@ impl AuthorizedEventData { fn tracks(&self) -> &[Track] { &self.tracks } - fn thumbnail(&self) -> Option<&str> { - self.thumbnail.as_deref() - } fn captions(&self) -> &[Caption] { &self.captions } @@ -231,8 +231,8 @@ impl AuthorizedEvent { /// Returns the authorized event data if the user has read access or is authenticated for the event. async fn authorized_data( &self, - context: &Context, - user: Option, + context: &Context, + user: Option, password: Option, ) -> Option<&AuthorizedEventData> { let sha1_matches = |input: &str, encoded: &str| { @@ -319,6 +319,18 @@ impl AuthorizedEvent { .get::<_, bool>(0) .pipe(Ok) } + + async fn audio_only(&self, context: &Context) -> ApiResult { + let query = "select not exists ( \ + select from events, unnest(tracks) as t \ + where id = $1 and t.resolution is not null \ + )"; + + context.db.query_one(&query, &[&self.key]) + .await? + .get::<_, bool>(0) + .pipe(Ok) + } } #[derive(juniper::GraphQLUnion)] diff --git a/frontend/src/routes/Embed.tsx b/frontend/src/routes/Embed.tsx index 730057b37..05752af07 100644 --- a/frontend/src/routes/Embed.tsx +++ b/frontend/src/routes/Embed.tsx @@ -123,6 +123,7 @@ const embedEventFragment = graphql` startTime endTime duration + thumbnail } ... VideoPageAuthorizedData @arguments(eventUser: $eventUser, eventPassword: $eventPassword) diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index f71bf0ff1..a0622dd1d 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -161,7 +161,6 @@ const query = graphql` startTime endTime created - userIsAuthorized hostRealms { path ancestorNames } textMatches { start @@ -521,7 +520,6 @@ const SearchEvent: React.FC = ({ hostRealms, textMatches, matches, - userIsAuthorized, }) => { // TODO: decide what to do in the case of more than two host realms. Direct // link should be avoided. @@ -534,21 +532,15 @@ const SearchEvent: React.FC = ({ image: diff --git a/frontend/src/routes/Video.tsx b/frontend/src/routes/Video.tsx index bc711256b..f497c8882 100644 --- a/frontend/src/routes/Video.tsx +++ b/frontend/src/routes/Video.tsx @@ -461,6 +461,7 @@ const eventFragment = graphql` duration startTime endTime + thumbnail } ... VideoPageAuthorizedData @arguments(eventUser: $eventUser, eventPassword: $eventPassword) @@ -486,13 +487,12 @@ const authorizedDataFragment = graphql` tracks { uri flavor mimetype resolution isMaster } captions { uri lang } segments { uri startTime } - thumbnail } } `; // Custom query to refetch authorized event data manually. Unfortunately, using -// the fragment here is not enough, we need to also selected `authorizedData` +// the fragment here is not enough, we need to also select `authorizedData` // manually. Without that, we could not access that field below to check if the // credentials were correct. Normally, adding `@relay(mask: false)` to the // fragment should also fix that, but that does not work for some reason. @@ -509,7 +509,7 @@ export const authorizedDataQuery = graphql` @arguments(eventUser: $eventUser, eventPassword: $eventPassword) ...on AuthorizedEvent { authorizedData(user: $eventUser, password: $eventPassword) { - thumbnail + __id } } } @@ -558,7 +558,7 @@ const VideoPage: React.FC = ({ eventRef, realmRef, playlistRef, basePath "@type": "VideoObject", name: event.title, description: event.description ?? undefined, - thumbnailUrl: event.authorizedData?.thumbnail ?? undefined, + thumbnailUrl: event.syncedData?.thumbnail ?? undefined, uploadDate: event.created, duration: toIsoDuration(event.syncedData.duration), ...event.isLive && event.syncedData.startTime && event.syncedData.endTime && { diff --git a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx index 35dadac04..3bfcd304d 100644 --- a/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx +++ b/frontend/src/routes/manage/Realm/Content/Edit/EditMode/Video.tsx @@ -47,8 +47,8 @@ export const EditVideoBlock: React.FC = ({ block: blockRef created isLive creators + audioOnly syncedData { duration startTime endTime } - authorizedData { thumbnail } } } showTitle @@ -152,6 +152,7 @@ const EventSelector: React.FC = ({ onChange, onBlur, default duration startTime endTime + audioOnly } } } @@ -204,16 +205,7 @@ const formatOption = (event: Option, t: TFunction) => ( ? : }
{event.title}
diff --git a/frontend/src/routes/manage/Video/Shared.tsx b/frontend/src/routes/manage/Video/Shared.tsx index 70bd5c0fd..9f8606895 100644 --- a/frontend/src/routes/manage/Video/Shared.tsx +++ b/frontend/src/routes/manage/Video/Shared.tsx @@ -86,15 +86,16 @@ const query = graphql` created canWrite isLive + audioOnly acl { role actions info { label implies large } } syncedData { duration updated startTime endTime + thumbnail } authorizedData { - thumbnail tracks { flavor resolution mimetype uri } } series { diff --git a/frontend/src/routes/manage/Video/index.tsx b/frontend/src/routes/manage/Video/index.tsx index 167d68cae..cfc6c34db 100644 --- a/frontend/src/routes/manage/Video/index.tsx +++ b/frontend/src/routes/manage/Video/index.tsx @@ -76,13 +76,18 @@ const query = graphql` startIndex endIndex } items { - id title created description isLive tobiraDeletionTimestamp + id + title + created + description + isLive + tobiraDeletionTimestamp + audioOnly series { id } syncedData { - duration updated startTime endTime + duration thumbnail updated startTime endTime } authorizedData { - thumbnail tracks { resolution } } } diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index c5527ca67..5f59a5df7 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -215,6 +215,7 @@ type SyncedEventData implements Node { endTime: DateTimeUtc "Duration in ms." duration: Float! + thumbnail: String } input NewPlaylistBlock { @@ -335,6 +336,7 @@ type AuthorizedEvent implements Node { Otherwise, `false` is returned. """ isReferencedByRealm(path: String!): Boolean! + audioOnly: Boolean! } "A block just showing some title." @@ -766,7 +768,6 @@ union RemoveMountedSeriesOutcome = RemovedRealm | RemovedBlock """ type AuthorizedEventData implements Node { tracks: [Track!]! - thumbnail: String captions: [Caption!]! segments: [Segment!]! } diff --git a/frontend/src/ui/Blocks/Video.tsx b/frontend/src/ui/Blocks/Video.tsx index 5726a0ba6..a7fafec53 100644 --- a/frontend/src/ui/Blocks/Video.tsx +++ b/frontend/src/ui/Blocks/Video.tsx @@ -44,6 +44,7 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { updated startTime endTime + thumbnail } ... VideoPageAuthorizedData } @@ -65,7 +66,6 @@ export const VideoBlock: React.FC = ({ fragRef, basePath }) => { return unreachable(); } - return
{showTitle && } <PlayerContextProvider> diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index 6a1c13dba..145832c4a 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -55,10 +55,10 @@ export const videoListEventFragment = graphql` creators isLive description + audioOnly series { title id } - syncedData { duration startTime endTime } + syncedData { thumbnail duration startTime endTime } authorizedData { - thumbnail tracks { resolution } } } diff --git a/frontend/src/ui/Video.tsx b/frontend/src/ui/Video.tsx index 0a80fe5ac..624b023c8 100644 --- a/frontend/src/ui/Video.tsx +++ b/frontend/src/ui/Video.tsx @@ -1,14 +1,6 @@ import { PropsWithChildren, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - LuAlertTriangle, - LuFilm, - LuLock, - LuRadio, - LuTrash, - LuUserCircle, - LuVolume2, -} from "react-icons/lu"; +import { LuAlertTriangle, LuFilm, LuRadio, LuTrash, LuUserCircle, LuVolume2 } from "react-icons/lu"; import { useColorScheme } from "@opencast/appkit"; import { COLORS } from "../color"; @@ -17,27 +9,19 @@ import { COLORS } from "../color"; type ThumbnailProps = JSX.IntrinsicElements["div"] & { /** The event of which a thumbnail should be shown */ event: { - id: string; title: string; isLive: boolean; created: string; - series?: { - id: string; - } | null; + audioOnly: boolean; syncedData?: { duration: number; + thumbnail?: string | null; startTime?: string | null; endTime?: string | null; } | null; authorizedData?: { - thumbnail?: string | null; - } & ( - { - tracks: readonly { resolution?: readonly number[] | null }[]; - } | { - audioOnly: boolean; - } - ) | null; + tracks: readonly { resolution?: readonly number[] | null }[]; + } | null; }; /** If `true`, an indicator overlay is shown */ @@ -56,31 +40,21 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const { t } = useTranslation(); const isDark = useColorScheme().scheme === "dark"; const isUpcoming = isUpcomingLiveEvent(event.syncedData?.startTime ?? null, event.isLive); - const audioOnly = event.authorizedData - ? ( - "audioOnly" in event.authorizedData - ? event.authorizedData.audioOnly - : event.authorizedData.tracks.every(t => t.resolution == null) - ) - : false; let inner; - if (event.authorizedData?.thumbnail && !deletionIsPending) { + if (event.syncedData?.thumbnail && !deletionIsPending) { // We have a proper thumbnail. inner = <ThumbnailImg - src={event.authorizedData.thumbnail} + src={event.syncedData.thumbnail} alt={t("video.thumbnail-for", { video: event.title })} />; } else { - inner = <ThumbnailReplacement - {...{ audioOnly, isUpcoming, isDark, deletionIsPending }} - previewOnly={!event.authorizedData} - />; + inner = <ThumbnailReplacement audioOnly={event.audioOnly} {...{ + isUpcoming, isDark, deletionIsPending, + }}/>; } let overlay; - let innerOverlay; - let backgroundColor = "hsla(0, 0%, 0%, 0.75)"; if (deletionIsPending) { overlay = null; } else if (event.isLive) { @@ -90,7 +64,9 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ const endTime = event.syncedData?.endTime; const hasEnded = endTime == null ? null : new Date(endTime) < now; const hasStarted = startTime < now; + const currentlyLive = hasStarted && !hasEnded; + let innerOverlay; if (hasEnded) { innerOverlay = t("video.ended"); } else if (hasStarted) { @@ -98,18 +74,19 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({ <LuRadio css={{ fontSize: 19, strokeWidth: 1.4 }} /> {t("video.live")} </>; - backgroundColor = "rgba(200, 0, 0, 0.9)"; } else { innerOverlay = t("video.upcoming"); } - } else if (event.syncedData) { - innerOverlay = formatDuration(event.syncedData.duration); - } - if (innerOverlay) { + const backgroundColor = currentlyLive ? "rgba(200, 0, 0, 0.9)" : "hsla(0, 0%, 0%, 0.75)"; + overlay = <ThumbnailOverlay {...{ backgroundColor }}> {innerOverlay} </ThumbnailOverlay>; + } else if (event.syncedData) { + overlay = <ThumbnailOverlay backgroundColor="hsla(0, 0%, 0%, 0.75)"> + {formatDuration(event.syncedData.duration)} + </ThumbnailOverlay>; } return <ThumbnailOverlayContainer {...rest}> @@ -124,10 +101,9 @@ type ThumbnailReplacementProps = { isDark: boolean; isUpcoming?: boolean; deletionIsPending?: boolean; - previewOnly?: boolean; } export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( - { audioOnly, isDark, isUpcoming, deletionIsPending, previewOnly } + { audioOnly, isDark, isUpcoming, deletionIsPending } ) => { // We have no thumbnail. If the resolution is `null` as well, we are // dealing with an audio-only event and show an appropriate icon. @@ -138,9 +114,6 @@ export const ThumbnailReplacement: React.FC<ThumbnailReplacementProps> = ( if (audioOnly) { icon = <LuVolume2 />; } - if (previewOnly) { - icon = <LuLock />; - } if (deletionIsPending) { icon = <LuTrash />; } diff --git a/frontend/src/ui/player/Paella.tsx b/frontend/src/ui/player/Paella.tsx index 2afea116c..6ae29cfdd 100644 --- a/frontend/src/ui/player/Paella.tsx +++ b/frontend/src/ui/player/Paella.tsx @@ -69,7 +69,7 @@ const PaellaPlayer: React.FC<PaellaPlayerProps> = ({ event }) => { metadata: { title: event.title, duration: fixedDuration, - preview: event.authorizedData.thumbnail, + preview: event.syncedData.thumbnail, // These are not strictly necessary for Paella to know, but can be used by // plugins, like the Matomo plugin. It is not well defined what to pass how, diff --git a/frontend/src/ui/player/index.tsx b/frontend/src/ui/player/index.tsx index 78b0f3a6d..9ae0e6b1c 100644 --- a/frontend/src/ui/player/index.tsx +++ b/frontend/src/ui/player/index.tsx @@ -39,11 +39,11 @@ export type PlayerEvent = { startTime?: string | null; endTime?: string | null; duration: number; + thumbnail?: string | null; }; authorizedData: { tracks: readonly Track[]; captions: readonly Caption[]; - thumbnail?: string | null; segments: readonly Segment[]; }; }; @@ -98,7 +98,7 @@ export const Player: React.FC<PlayerProps> = ({ event, onEventStateChange }) => }); return ( - <Suspense fallback={<PlayerFallback image={event.authorizedData?.thumbnail} />}> + <Suspense fallback={<PlayerFallback image={event.syncedData?.thumbnail} />}> {event.isLive && (hasStarted === false || hasEnded === true) ? <LiveEventPlaceholder {...{ ...hasStarted === false