Pārlūkot izejas kodu

allow dynamic hero video update (#807)

* allow dynamic hero video update

* add docs

* Apply suggestions from code review

Co-authored-by: Diego Cardenas <cardenasrdiego@gmail.com>

Co-authored-by: Diego Cardenas <cardenasrdiego@gmail.com>
Klaudiusz Dembler 3 gadi atpakaļ
vecāks
revīzija
cbd8b48e44

+ 40 - 0
docs/cover-video.md

@@ -0,0 +1,40 @@
+# Updating featured cover video
+
+Atlas will fetch a remote `cover-info.json` file to get information on what should be displayed in the hero section on top of the home page. That JSON file is hosted on our Linode object storage, and a designated community member will receive write permissions to that storage, so they can update the content dynamically.
+
+
+### Structure of the JSON file
+
+```json
+{
+  "videoId": "1",
+  "coverTitle": "Ghost Signals",
+  "coverDescription": "How We Lost Trust In Authority, And Authority Taught Us To Distrust Ourselves",
+  "coverCutMediaUrl": "https://eu-central-1.linodeobjects.com/atlas-hero/cover-cut-ghost-signals.mp4"
+}
+```
+
+- `videoId`: ID of the video uploaded to Joystream that this cover mentions
+- `coverTitle`: Title that should be displayed in the cover section. In general, this should be the same as the title of the published video but may need to be shortened for some videos for the cover to look sexy
+- `coverDescription`: Description that should be displayed under the title in the cover section. Not required, but in that case, an empty string should be provided. (`"coverDescription": ""`)
+- `coverCutMediaUrl`: URL to the video file that should be playing in the background of the cover section. Usually, this will be a trimmed version of the published video. This can be hosted in the same Linode bucket as `cover-info.json`
+
+### Changing cover info
+
+As mentioned above, Atlas will fetch a JSON file - `https://eu-central-1.linodeobjects.com/atlas-hero/cover-info.json`. This file is hosted in Linode object storage, which is an S3-compatible storage. A designated community member will have keypair to write to that storage. To access it, you can use any S3-compatible client, some good choices are [Cyberduck](https://cyberduck.io/) (UI-based) and [s3cmd](https://s3tools.org/s3cmd-howto]).
+
+When configuring the above tools, you may need to provide the following data:
+- storage host URL - `eu-central-1.linodeobjects.com`
+- storage bucket URL (for `s3cmd`) - `%(bucket).eu-central-1.linodeobjects.com`
+- bucket name - `atlas-hero`
+- access key - use yours
+- secret key - use yours
+
+Example of uploading a file via `s3cmd`:
+```shell
+s3cmd put cover-info.json s3://atlas-hero/cover-info.json
+```
+
+### Help
+
+If you got any questions, reach out to `@kdembler` on community discord

+ 0 - 20
src/api/hooks/coverVideo.ts

@@ -1,20 +0,0 @@
-import { CoverVideo, AllChannelFieldsFragment } from '@/api/queries'
-import { mockCoverVideo } from '@/mocking/data/mockCoverVideo'
-import { MockVideo } from '@/mocking/data/mockVideos'
-
-type UseCoverVideo = () => {
-  data: {
-    __typename: 'CoverVideo'
-    coverDescription: CoverVideo['coverDescription']
-    coverCutMediaMetadata: CoverVideo['coverCutMediaMetadata']
-    video: MockVideo & { channel: AllChannelFieldsFragment }
-  }
-}
-
-const useCoverVideo: UseCoverVideo = () => {
-  return {
-    data: mockCoverVideo,
-  }
-}
-
-export default useCoverVideo

+ 1 - 2
src/api/hooks/index.ts

@@ -1,9 +1,8 @@
 import useCategories from './categories'
-import useCoverVideo from './coverVideo'
 import useSearch from './search'
 import useVideosConnection from './videosConnection'
 
-export { useCoverVideo, useVideosConnection, useCategories, useSearch }
+export { useVideosConnection, useCategories, useSearch }
 
 export * from './channel'
 export * from './video'

+ 0 - 12
src/api/queries/__generated__/baseTypes.generated.ts

@@ -240,17 +240,6 @@ export enum WorkerOrderByInput {
   CreatedAtDesc = 'createdAt_DESC',
 }
 
-export type CoverVideo = {
-  __typename?: 'CoverVideo'
-  id: Scalars['ID']
-  video: Video
-  coverDescription: Scalars['String']
-  coverCutMediaMetadata: VideoMediaMetadata
-  coverCutMediaDataObject?: Maybe<DataObject>
-  coverCutMediaAvailability: AssetAvailability
-  coverCutMediaUrl?: Maybe<Scalars['String']>
-}
-
 export type SearchResult = Video | Channel
 
 export type SearchFtsOutput = {
@@ -284,7 +273,6 @@ export type Query = {
   channelViews?: Maybe<EntityViewsInfo>
   channels: Array<Channel>
   channelsConnection: ChannelConnection
-  coverVideo: CoverVideo
   membershipByUniqueInput?: Maybe<Membership>
   memberships: Array<Membership>
   search: Array<SearchFtsOutput>

+ 0 - 56
src/api/queries/__generated__/videos.generated.tsx

@@ -85,18 +85,6 @@ export type GetVideosQuery = {
   videos?: Types.Maybe<Array<{ __typename?: 'Video' } & VideoFieldsFragment>>
 }
 
-export type GetCoverVideoQueryVariables = Types.Exact<{ [key: string]: never }>
-
-export type GetCoverVideoQuery = {
-  __typename?: 'Query'
-  coverVideo: {
-    __typename?: 'CoverVideo'
-    coverDescription: string
-    video: { __typename?: 'Video' } & VideoFieldsFragment
-    coverCutMediaMetadata: { __typename?: 'VideoMediaMetadata' } & VideoMediaMetadataFieldsFragment
-  }
-}
-
 export type GetVideoViewsQueryVariables = Types.Exact<{
   videoId: Types.Scalars['ID']
 }>
@@ -314,50 +302,6 @@ export function useGetVideosLazyQuery(
 export type GetVideosQueryHookResult = ReturnType<typeof useGetVideosQuery>
 export type GetVideosLazyQueryHookResult = ReturnType<typeof useGetVideosLazyQuery>
 export type GetVideosQueryResult = Apollo.QueryResult<GetVideosQuery, GetVideosQueryVariables>
-export const GetCoverVideoDocument = gql`
-  query GetCoverVideo {
-    coverVideo {
-      video {
-        ...VideoFields
-      }
-      coverDescription
-      coverCutMediaMetadata {
-        ...VideoMediaMetadataFields
-      }
-    }
-  }
-  ${VideoFieldsFragmentDoc}
-  ${VideoMediaMetadataFieldsFragmentDoc}
-`
-
-/**
- * __useGetCoverVideoQuery__
- *
- * To run a query within a React component, call `useGetCoverVideoQuery` and pass it any options that fit your needs.
- * When your component renders, `useGetCoverVideoQuery` returns an object from Apollo Client that contains loading, error, and data properties
- * you can use to render your UI.
- *
- * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
- *
- * @example
- * const { data, loading, error } = useGetCoverVideoQuery({
- *   variables: {
- *   },
- * });
- */
-export function useGetCoverVideoQuery(
-  baseOptions?: Apollo.QueryHookOptions<GetCoverVideoQuery, GetCoverVideoQueryVariables>
-) {
-  return Apollo.useQuery<GetCoverVideoQuery, GetCoverVideoQueryVariables>(GetCoverVideoDocument, baseOptions)
-}
-export function useGetCoverVideoLazyQuery(
-  baseOptions?: Apollo.LazyQueryHookOptions<GetCoverVideoQuery, GetCoverVideoQueryVariables>
-) {
-  return Apollo.useLazyQuery<GetCoverVideoQuery, GetCoverVideoQueryVariables>(GetCoverVideoDocument, baseOptions)
-}
-export type GetCoverVideoQueryHookResult = ReturnType<typeof useGetCoverVideoQuery>
-export type GetCoverVideoLazyQueryHookResult = ReturnType<typeof useGetCoverVideoLazyQuery>
-export type GetCoverVideoQueryResult = Apollo.QueryResult<GetCoverVideoQuery, GetCoverVideoQueryVariables>
 export const GetVideoViewsDocument = gql`
   query GetVideoViews($videoId: ID!) {
     videoViews(videoId: $videoId) {

+ 0 - 12
src/api/queries/videos.graphql

@@ -84,18 +84,6 @@ query GetVideos($offset: Int, $limit: Int, $where: VideoWhereInput, $orderBy: Vi
   }
 }
 
-query GetCoverVideo {
-  coverVideo {
-    video {
-      ...VideoFields
-    }
-    coverDescription
-    coverCutMediaMetadata {
-      ...VideoMediaMetadataFields
-    }
-  }
-}
-
 ### Orion
 
 # modyfying this query name will need a sync-up in `src/api/client/resolvers.ts`

+ 0 - 13
src/api/schemas/extendedQueryNode.graphql

@@ -238,16 +238,6 @@ enum WorkerOrderByInput {
   createdAt_ASC
   createdAt_DESC
 }
-# Isn't provided by query node yet
-type CoverVideo {
-  id: ID!
-  video: Video!
-  coverDescription: String!
-  coverCutMediaMetadata: VideoMediaMetadata!
-  coverCutMediaDataObject: DataObject
-  coverCutMediaAvailability: AssetAvailability!
-  coverCutMediaUrl: String
-}
 
 union SearchResult = Video | Channel
 
@@ -302,9 +292,6 @@ type Query {
 
   workerByUniqueInput(where: WorkerWhereUniqueInput!): Worker
 
-  # Get the current cover video
-  coverVideo: CoverVideo!
-
   # Free text search across videos and channels
   search(limit: Int, text: String!, whereVideo: VideoWhereInput, whereChannel: ChannelWhereInput): [SearchFTSOutput!]!
 }

+ 15 - 16
src/components/CoverVideo/CoverVideo.tsx

@@ -2,7 +2,6 @@ import React, { useState } from 'react'
 import { Link } from 'react-router-dom'
 import { CSSTransition } from 'react-transition-group'
 
-import { useCoverVideo } from '@/api/hooks'
 import { absoluteRoutes } from '@/config/routes'
 import { useAsset } from '@/hooks'
 import { Placeholder, VideoPlayer } from '@/shared/components'
@@ -27,11 +26,12 @@ import {
   ControlsContainer,
   ButtonsContainer,
 } from './CoverVideo.style'
+import { useCoverVideo } from './coverVideoData'
 
 const VIDEO_PLAYBACK_DELAY = 1250
 
 const CoverVideo: React.FC = () => {
-  const { data } = useCoverVideo()
+  const coverVideo = useCoverVideo()
 
   const [videoPlaying, setVideoPlaying] = useState(false)
   const [displayControls, setDisplayControls] = useState(false)
@@ -62,18 +62,17 @@ const CoverVideo: React.FC = () => {
   }
 
   const thumbnailPhotoUrl = getAssetUrl(
-    data.video?.thumbnailPhotoAvailability,
-    data.video?.thumbnailPhotoUrls,
-    data.video?.thumbnailPhotoDataObject
+    coverVideo?.video?.thumbnailPhotoAvailability,
+    coverVideo?.video?.thumbnailPhotoUrls,
+    coverVideo?.video?.thumbnailPhotoDataObject
   )
-  const mediaUrl = getAssetUrl(data.video?.mediaAvailability, data.video?.mediaUrls, data.video?.mediaDataObject)
 
   return (
     <Container>
       <MediaWrapper>
         <Media>
           <PlayerContainer>
-            {data ? (
+            {coverVideo ? (
               <VideoPlayer
                 fluid
                 isInBackground
@@ -83,30 +82,30 @@ const CoverVideo: React.FC = () => {
                 onDataLoaded={handlePlaybackDataLoaded}
                 onPlay={handlePlay}
                 onPause={handlePause}
-                src={mediaUrl}
+                src={coverVideo?.coverCutMediaUrl}
               />
             ) : (
               <PlayerPlaceholder />
             )}
           </PlayerContainer>
-          {data && <HorizontalGradientOverlay />}
+          {coverVideo && <HorizontalGradientOverlay />}
           <VerticalGradientOverlay />
         </Media>
       </MediaWrapper>
-      <InfoContainer isLoading={!data}>
+      <InfoContainer isLoading={!coverVideo}>
         <StyledChannelLink
-          id={data?.video.channel.id}
+          id={coverVideo?.video.channel.id}
           hideHandle
-          overrideChannel={data?.video.channel}
+          overrideChannel={coverVideo?.video.channel}
           avatarSize="cover"
         />
         <TitleContainer>
-          {data ? (
+          {coverVideo ? (
             <>
-              <Link to={absoluteRoutes.viewer.video(data.video.id)}>
-                <Title variant="h2">{data.video.title}</Title>
+              <Link to={absoluteRoutes.viewer.video(coverVideo.video.id)}>
+                <Title variant="h2">{coverVideo.coverTitle}</Title>
               </Link>
-              <span>{data.coverDescription}</span>
+              <span>{coverVideo.coverDescription}</span>
             </>
           ) : (
             <>

+ 6 - 0
src/components/CoverVideo/backupCoverVideoInfo.json

@@ -0,0 +1,6 @@
+{
+  "videoId": "1",
+  "coverTitle": "Ghost Signals",
+  "coverDescription": "How We Lost Trust In Authority, And Authority Taught Us To Distrust Ourselves",
+  "coverCutMediaUrl": "https://eu-central-1.linodeobjects.com/atlas-assets/cover-video/cut.mp4"
+}

+ 53 - 0
src/components/CoverVideo/coverVideoData.ts

@@ -0,0 +1,53 @@
+import axios from 'axios'
+import { useEffect, useState } from 'react'
+
+import { useVideo } from '@/api/hooks/video'
+import { VideoFieldsFragment } from '@/api/queries'
+import { COVER_VIDEO_INFO_URL } from '@/config/urls'
+
+import backupCoverVideoInfo from './backupCoverVideoInfo.json'
+
+type RawCoverInfo = {
+  videoId: string
+  coverTitle: string
+  coverDescription: string
+  coverCutMediaUrl: string
+}
+
+type CoverInfo =
+  | (Omit<RawCoverInfo, 'videoId'> & {
+      video: VideoFieldsFragment
+    })
+  | null
+
+export const useCoverVideo = (): CoverInfo => {
+  const [fetchedCoverInfo, setFetchedCoverInfo] = useState<RawCoverInfo | null>(null)
+  const { video, error } = useVideo(fetchedCoverInfo?.videoId || '', { skip: !fetchedCoverInfo?.videoId })
+
+  if (error) {
+    throw error
+  }
+
+  useEffect(() => {
+    const fetchInfo = async () => {
+      try {
+        const response = await axios.get<RawCoverInfo>(COVER_VIDEO_INFO_URL)
+        setFetchedCoverInfo(response.data)
+      } catch (e) {
+        console.error(`Failed to fetch cover info from ${COVER_VIDEO_INFO_URL}. Using backup`, e)
+        setFetchedCoverInfo(backupCoverVideoInfo)
+      }
+    }
+
+    fetchInfo()
+  }, [])
+
+  return video && fetchedCoverInfo
+    ? {
+        video,
+        coverTitle: fetchedCoverInfo.coverTitle,
+        coverDescription: fetchedCoverInfo.coverDescription,
+        coverCutMediaUrl: fetchedCoverInfo.coverCutMediaUrl,
+      }
+    : null
+}

+ 2 - 0
src/config/urls.ts

@@ -13,3 +13,5 @@ export const NODE_URL = readEnv('REACT_APP_NODE_URL') || 'ws://127.0.0.1:9944'
 export const FAUCET_URL = readEnv('REACT_APP_FAUCET_URL') || '/mocked-faucet'
 
 export const STORAGE_URL_PATH = 'asset/v0'
+
+export const COVER_VIDEO_INFO_URL = 'https://eu-central-1.linodeobjects.com/atlas-hero/cover-info.json'

+ 0 - 44
src/mocking/data/mockCoverVideo.ts

@@ -1,44 +0,0 @@
-import { AssetAvailability, CoverVideo } from '@/api/queries'
-import { coverMockChannel } from '@/mocking/data/mockChannels'
-import { MockLicense } from '@/mocking/data/mockLicenses'
-
-import mockCategories from './mockCategories'
-import rawCoverVideo from './raw/coverVideo.json'
-
-export const mockCoverVideoLicense: MockLicense = {
-  __typename: 'License',
-  ...rawCoverVideo.license,
-}
-
-type MockCoverVideo = CoverVideo & {
-  __typename: 'CoverVideo'
-}
-
-export const mockCoverVideo: MockCoverVideo = {
-  __typename: 'CoverVideo',
-  id: 'fake-cover-video-id',
-  video: {
-    ...rawCoverVideo.video,
-    createdAt: new Date(rawCoverVideo.video.createdAt),
-    channel: {
-      ...coverMockChannel,
-      videos: [],
-    },
-    license: mockCoverVideoLicense,
-    mediaMetadata: rawCoverVideo.videoMediaMetadata,
-    mediaAvailability: AssetAvailability.Accepted,
-    mediaUrls: [rawCoverVideo.cover.coverCutMediaMetadata.location.url],
-    thumbnailPhotoUrls: [rawCoverVideo.video.thumbnailPhotoUrl],
-    thumbnailPhotoAvailability: AssetAvailability.Accepted,
-    duration: rawCoverVideo.videoMediaMetadata.duration,
-    category: mockCategories[mockCategories.length - 1],
-    isCensored: false,
-    isFeatured: false,
-  },
-  coverCutMediaMetadata: {
-    __typename: 'VideoMediaMetadata',
-    ...rawCoverVideo.cover.coverCutMediaMetadata,
-  },
-  coverCutMediaAvailability: AssetAvailability.Accepted,
-  coverDescription: rawCoverVideo.cover.coverDescription,
-}

+ 2 - 2
src/mocking/data/mockVideos.ts

@@ -41,8 +41,8 @@ const coverMockVideo: MockVideo = {
   mediaUrls: [rawCoverVideo.video.mediaUrl],
   thumbnailPhotoUrls: [rawCoverVideo.video.thumbnailPhotoUrl],
   thumbnailPhotoAvailability: AssetAvailability.Accepted,
-  mediaMetadata: rawCoverVideo.videoMediaMetadata,
-  duration: rawCoverVideo.videoMediaMetadata.duration,
+  mediaMetadata: rawCoverVideo.mediaMetadata,
+  duration: rawCoverVideo.mediaMetadata.duration,
   category: mockCategories[0],
   isPublic: Boolean(Math.round(Math.random())),
   isCensored: Boolean(Math.round(Math.random())),

+ 2 - 16
src/mocking/data/raw/coverVideo.json

@@ -8,7 +8,7 @@
     "thumbnailPhotoUrl": "https://eu-central-1.linodeobjects.com/atlas-assets/cover-video/thumbnail.jpg",
     "mediaUrl": "https://eu-central-1.linodeobjects.com/atlas-assets/cover-video/video.webm"
   },
-  "videoMediaMetadata": {
+  "mediaMetadata": {
     "id": "4551c421-8edd-4d3b-8357-9fe825742775",
     "codec": "H264_mpeg4",
     "pixelWidth": 2560,
@@ -17,27 +17,13 @@
     "size": 44851016
   },
   "channel": {
-    "id": "2",
+    "id": "1111111111",
     "title": "SCHISM",
     "avatarPhotoUrl": "https://eu-central-1.linodeobjects.com/joystream/schismavatar_black.png",
     "follows": 48717,
     "createdAt": "2018-01-27T08:59:19.385Z",
     "isCensored": false
   },
-  "cover": {
-    "coverDescription": "How We Lost Trust In Authority, And Authority Taught Us To Distrust Ourselves",
-    "coverCutMediaMetadata": {
-      "id": "4551c421-8edd-4d3b-8357-9fe825742775",
-      "codec": "H264_mpeg4",
-      "pixelWidth": 2560,
-      "pixelHeight": 1440,
-      "duration": 29,
-      "size": 44851016,
-      "location": {
-        "url": "https://eu-central-1.linodeobjects.com/atlas-assets/cover-video/cut.mp4"
-      }
-    }
-  },
   "license": {
     "id": "1000",
     "customText": "Free to consume and share, no derivatives or adaptations",