Browse Source

Merge pull request #307 from siman/alex-improve-playback

Improve Playback page
Mokhtar Naamani 5 years ago
parent
commit
339ac8f197

+ 43 - 19
packages/joy-media/src/MediaView.tsx

@@ -4,51 +4,75 @@ import { MemberId } from '@joystream/types/members';
 import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
 import { useTransportContext } from './TransportContext';
 
-type ResolverProps<A> = A & {
-  transport: MediaTransport
+type InitialPropsWithMembership<A> = A & {
   myAddress?: string
   myMemberId?: MemberId
-};
+}
+
+type ResolverProps<A> = InitialPropsWithMembership<A> & {
+  transport: MediaTransport
+}
 
 type BaseProps<A, B> = {
   component: React.ComponentType<A & B>
-  resolveProps?: (props: ResolverProps<A>) => Promise<B>
   unresolvedView?: React.ReactElement
-};
+  resolveProps?: (props: ResolverProps<A>) => Promise<B>
+
+  /**
+   * Array of property names that can trigger re-render of the view,
+   * if values of such properties changed. 
+   */
+  triggers?: (keyof A)[]
+}
+
+function serializeTrigger(val: any): any {
+  if (['number', 'boolean', 'string'].indexOf(typeof val) >= 0) {
+    return val
+  } else if (typeof val === 'object' && typeof val.toString === 'function') {
+    return val.toString()
+  } else {
+    return undefined
+  }
+}
 
 export function MediaView<A = {}, B = {}> (baseProps: BaseProps<A, B>) {
   return function (initialProps: A & B) {
-    const { component: Component, resolveProps, unresolvedView = null } = baseProps;
+    const { component: Component, resolveProps, triggers = [], unresolvedView = null } = baseProps;
 
     const transport = useTransportContext();
     const { myAddress, myMemberId } = useMyMembership();
+    const resolverProps = {...initialProps, transport, myAddress, myMemberId }
     
     const [ resolvedProps, setResolvedProps ] = useState({} as B);
     const [ propsResolved, setPropsResolved ] = useState(false);
 
+    const initialDeps = triggers.map(propName => serializeTrigger(initialProps[propName]))
+    const rerenderDeps = [ ...initialDeps, myAddress ]
+
     useEffect(() => {
-      console.log('Resolving props of media view');
 
       async function doResolveProps () {
-        if (typeof resolveProps === 'function') {
-          // Transport session allows us to cache loaded channels, entites and classes
-          // during the render of this view:
-          transport.openSession()
-          setResolvedProps(await resolveProps(
-            {...initialProps, transport, myAddress, myMemberId }
-          ));
-          transport.closeSession()
-        }
+        if (typeof resolveProps !== 'function') return;
+        
+        console.log('Resolving props of media view');
+
+        // Transport session allows us to cache loaded channels, entites and classes
+        // during the render of this view:
+        transport.openSession()
+        setResolvedProps(await resolveProps(resolverProps));
+        transport.closeSession()
         setPropsResolved(true);
       }
 
       if (!transport) {
-        console.log('ERROR: Transport is not defined');
-      } else if (!propsResolved) {
+        console.error('Transport is not defined');
+      } else {
         doResolveProps();
       }
-    }, [ false ]);
+    }, rerenderDeps);
     
+    console.log('Rerender deps of Media View:', rerenderDeps);
+
     return propsResolved
       ? <Component {...initialProps} {...resolvedProps} />
       : unresolvedView;

+ 1 - 0
packages/joy-media/src/channels/EditChannel.view.tsx

@@ -8,6 +8,7 @@ type Props = OuterProps;
 
 export const EditChannelView = MediaView<Props>({
   component: EditForm,
+  triggers: [ 'id' ],
   resolveProps: async (props) => {
     const { transport, id } = props;
     const entity = id && await transport.channelById(id);

+ 1 - 0
packages/joy-media/src/channels/ViewChannel.view.tsx

@@ -8,6 +8,7 @@ type Props = ViewChannelProps;
 
 export const ViewChannelView = MediaView<Props>({
   component: ViewChannel,
+  triggers: [ 'id' ],
   resolveProps: async (props) => {
     const { transport, id } = props;
     const channel = await transport.channelById(id);

+ 83 - 72
packages/joy-media/src/common/MediaPlayerView.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 import { Link } from 'react-router-dom';
 import DPlayer from 'react-dplayer';
 import APlayer from 'react-aplayer';
@@ -11,16 +11,16 @@ import { Option } from '@polkadot/types/codec';
 import translate from '../translate';
 import { DiscoveryProviderProps } from '../DiscoveryProvider';
 import { DataObject, ContentId } from '@joystream/types/media';
-import { MyAccountContext, MyAccountContextProps } from '@polkadot/joy-utils/MyAccountContext';
 import { VideoType } from '../schemas/video/Video';
 import { isAccountAChannelOwner } from '../channels/ChannelHelpers';
 import { ChannelEntity } from '../entities/ChannelEntity';
+import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext';
 
 const PLAYER_COMMON_PARAMS = {
   lang: 'en',
   autoplay: true,
   theme: '#2185d0'
-};
+}
 
 // This is just a part of Player's methods that are used in this component.
 // To see all the methods available on APlayer and DPlayer visit the next URLs:
@@ -37,94 +37,105 @@ export type RequiredMediaPlayerProps = {
   contentId: ContentId
 }
 
-type Props = ApiProps & I18nProps & DiscoveryProviderProps & RequiredMediaPlayerProps & {
+type ContentProps = {
   contentType?: string
   dataObjectOpt?: Option<DataObject>
   resolvedAssetUrl?: string
-};
+}
 
-class InnerComponent extends React.PureComponent<Props> {
+type MediaPlayerViewProps = ApiProps & I18nProps &
+  DiscoveryProviderProps & RequiredMediaPlayerProps & ContentProps
 
-  static contextType = MyAccountContext;
+type PlayerProps = RequiredMediaPlayerProps & ContentProps
 
-  private player?: PartOfPlayer = undefined;
+function Player(props: PlayerProps) {
+  const { video, resolvedAssetUrl: url, contentType = 'video/video' } = props
+  const { thumbnail: cover } = video
+  const prefix = contentType.substring(0, contentType.indexOf('/'))
 
-  private onPlayerCreated = (player: PartOfPlayer) => {
-    this.player = player;
+  const [ player, setPlayer ] = useState<PartOfPlayer>()
+
+  const onPlayerCreated = (newPlayer: PartOfPlayer) => {
+    console.log('onPlayerCreated:', newPlayer)
+    setPlayer(newPlayer)
   }
 
-  componentWillUnmount () {
-    const { player } = this;
-    if (player) {
-      console.log('Destroy the current player');
-      player.pause();
-      player.destroy();
-    }
+  const destroyPlayer = () => {
+    if (!player) return;
+
+    console.log('Destroy the current player');
+    player.pause();
+    player.destroy();
+    setPlayer(undefined)
   }
 
-  render () {
-    const { dataObjectOpt, channel } = this.props;
-    if (!dataObjectOpt || dataObjectOpt.isNone ) {
-      return null;
+  useEffect(() => {
+    return () => {
+      destroyPlayer()
     }
+  }, [ url ])
+
+  if (prefix === 'video') {
+    const video = { url, name, pic: cover };
+    return <DPlayer
+      video={video}
+      {...PLAYER_COMMON_PARAMS}
+      loop={false}
+      onLoad={onPlayerCreated} // Note that DPlayer has onLoad, but APlayer - onInit.
+    />;
+  } else if (prefix === 'audio') {
+    const audio = { url, name, cover };
+    return <APlayer
+      audio={audio}
+      {...PLAYER_COMMON_PARAMS}
+      loop='none'
+      onInit={onPlayerCreated} // Note that APlayer has onInit, but DPlayer - onLoad.
+    />;
+  }
 
-    // TODO extract and show the next info from dataObject:
-    // {"owner":"5GSMNn8Sy8k64mGUWPDafjMZu9bQNX26GujbBQ1LeJpNbrfg","added_at":{"block":2781,"time":1582750854000},"type_id":1,"size":3664485,"liaison":"5HN528fspu4Jg3KXWm7Pu7aUK64RSBz2ZSbwo1XKR9iz3hdY","liaison_judgement":1,"ipfs_content_id":"QmNk4QczoJyPTAKdfoQna6KhAz3FwfjpKyRBXAZHG5djYZ"}
-
-    const { video, resolvedAssetUrl: url, contentType = 'video/video' } = this.props;
-    const { thumbnail: cover } = video;
-    const prefix = contentType.substring(0, contentType.indexOf('/'));
-
-    const myAccountCtx = this.context as MyAccountContextProps;
-    const myAccountId = myAccountCtx.state.address;
-    const iAmOwner = isAccountAChannelOwner(channel, myAccountId)
-
-    const renderPlayer = () => {
-      if (prefix === 'video') {
-        const video = { url, name, pic: cover };
-        return <DPlayer
-          video={video}
-          {...PLAYER_COMMON_PARAMS}
-          loop={false}
-          onLoad={this.onPlayerCreated} // Note that DPlayer has onLoad, but APlayer - onInit.
-        />;
-      } else if (prefix === 'audio') {
-        const audio = { url, name, cover };
-        return <APlayer
-          audio={audio}
-          {...PLAYER_COMMON_PARAMS}
-          loop='none'
-          onInit={this.onPlayerCreated} // Note that APlayer has onInit, but DPlayer - onLoad.
-        />;
-      } else {
-        return <em>Unsupported type of content: {contentType}</em>;
-      }
-    };
-
-    return (
-      <div className='PlayBox'>
-        {renderPlayer()}
-        <div className='ContentHeader'>
-          <a className='ui button outline DownloadBtn' href={`${url}?download`}><i className='cloud download icon'></i> Download</a>
-
-          {iAmOwner &&
-            <Link to={`/media/videos/${video.id}/edit`} className='ui button' style={{ float: 'right' }}>
-              <i className='pencil alternate icon'></i>
-              Edit
-            </Link>
-          }
-
-          <h1>{video.title}</h1>
-        </div>
-      </div>
-    );
+  return <em>Unsupported type of content: {contentType}</em>;
+}
+
+function InnerComponent(props: MediaPlayerViewProps) {
+  const { video, resolvedAssetUrl: url } = props
+  
+  const { dataObjectOpt, channel } = props;
+  if (!dataObjectOpt || dataObjectOpt.isNone ) {
+    return null;
   }
+
+  // TODO extract and show the next info from dataObject:
+  // {"owner":"5GSMNn8Sy8k64mGUWPDafjMZu9bQNX26GujbBQ1LeJpNbrfg","added_at":{"block":2781,"time":1582750854000},"type_id":1,"size":3664485,"liaison":"5HN528fspu4Jg3KXWm7Pu7aUK64RSBz2ZSbwo1XKR9iz3hdY","liaison_judgement":1,"ipfs_content_id":"QmNk4QczoJyPTAKdfoQna6KhAz3FwfjpKyRBXAZHG5djYZ"}
+
+  const { myAccountId } = useMyMembership()
+  const iAmOwner = isAccountAChannelOwner(channel, myAccountId)
+
+  return (
+    <div className='PlayBox'>
+      
+      {/* Note that here we use a 'key' prop to force Player component to rerender */}
+      <Player {...props} key={url} />
+
+      <div className='ContentHeader'>
+        <a className='ui button outline DownloadBtn' href={`${url}?download`}><i className='cloud download icon'></i> Download</a>
+
+        {iAmOwner &&
+          <Link to={`/media/videos/${video.id}/edit`} className='ui button' style={{ float: 'right' }}>
+            <i className='pencil alternate icon'></i>
+            Edit
+          </Link>
+        }
+
+        <h1>{video.title}</h1>
+      </div>
+    </div>
+  );
 }
 
 export const MediaPlayerView = withMulti(
   InnerComponent,
   translate,
-  withCalls<Props>(
+  withCalls<MediaPlayerViewProps>(
     ['query.dataDirectory.dataObjectByContentId',
       { paramName: 'contentId', propName: 'dataObjectOpt' } ]
   )

+ 47 - 64
packages/joy-media/src/common/MediaPlayerWithResolver.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 import axios, { CancelTokenSource } from 'axios';
 import _ from 'lodash';
 
@@ -16,34 +16,30 @@ import { MediaPlayerView, RequiredMediaPlayerProps } from './MediaPlayerView';
 
 type Props = ApiProps & I18nProps & DiscoveryProviderProps & RequiredMediaPlayerProps;
 
-type State = {
-  contentType?: string,
-  resolvingAsset: boolean,
-  resolvedAssetUrl?: string,
-  error?: Error,
-  cancelSource: CancelTokenSource
-};
+function newCancelSource(): CancelTokenSource {
+  return axios.CancelToken.source()
+}
 
-class InnerComponent extends React.PureComponent<Props, State> {
+function InnerComponent(props: Props) {
+  const { contentId, api, discoveryProvider } = props
 
-  state: State = {
-    resolvingAsset: false,
-    cancelSource: axios.CancelToken.source()
-  };
+  const [ error, setError ] = useState<Error>()
+  const [ resolvedAssetUrl, setResolvedAssetUrl ] = useState<string>()
+  const [ contentType, setContentType ] = useState<string>()
+  const [ cancelSource, setCancelSource ] = useState<CancelTokenSource>(newCancelSource())
 
-  componentDidMount () {
-    this.resolveAsset();
-  }
+  useEffect(() => {
 
-  componentWillUnmount () {
-    const { cancelSource } = this.state;
-    cancelSource.cancel();
-  }
+    resolveAsset()
 
-  private resolveAsset = async () => {
-    const { contentId, discoveryProvider, api } = this.props;
+    return () => {
+      cancelSource.cancel()
+    }
+  }, [ contentId.encode() ])
 
-    this.setState({ resolvingAsset: true, error: undefined });
+  const resolveAsset = async () => {
+    setError(undefined)
+    setCancelSource(newCancelSource())
 
     const rids: DataObjectStorageRelationshipId[] = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId) as any;
 
@@ -57,10 +53,7 @@ class InnerComponent extends React.PureComponent<Props, State> {
     readyProviders = _.uniqBy(readyProviders, provider => provider.toString());
 
     if (!readyProviders.length) {
-      this.setState({
-        resolvingAsset: false,
-        error: new Error('No Storage Providers found storing this content')
-      });
+      setError(new Error('No Storage Providers found storing this content'))
       return;
     }
 
@@ -76,21 +69,14 @@ class InnerComponent extends React.PureComponent<Props, State> {
     // TODO: prioritize already resolved providers, least reported unreachable, closest
     // by geography etc..
 
-    const { cancelSource } = this.state;
-
     // loop over providers until we find one that responds
     while (readyProviders.length) {
       const provider = readyProviders.shift();
       if (!provider) continue;
 
-      const { resolvingAsset } = this.state;
-      if (!resolvingAsset) {
-        break;
-      }
-
+      let assetUrl: string | undefined
       try {
-        var resolvedAssetUrl = await discoveryProvider.resolveAssetEndpoint(provider, contentId.encode(), cancelSource.token);
-        console.log({ resolvedAssetUrl })
+        assetUrl = await discoveryProvider.resolveAssetEndpoint(provider, contentId.encode(), cancelSource.token);
       } catch (err) {
         if (axios.isCancel(err)) {
           return;
@@ -100,10 +86,12 @@ class InnerComponent extends React.PureComponent<Props, State> {
       }
 
       try {
-        console.log('Checking an URL of resolved asset:', resolvedAssetUrl);
-        const response = await axios.head(resolvedAssetUrl, { cancelToken: cancelSource.token });
-        const contentType = response.headers['content-type'] || 'video/video';
-        this.setState({ contentType, resolvedAssetUrl, resolvingAsset: false });
+        console.log('Check URL of resolved asset:', assetUrl);
+        const response = await axios.head(assetUrl, { cancelToken: cancelSource.token });
+
+        setContentType(response.headers['content-type'] || 'video/video')
+        setResolvedAssetUrl(assetUrl)
+
         return;
       } catch (err) {
         if (axios.isCancel(err)) {
@@ -113,40 +101,35 @@ class InnerComponent extends React.PureComponent<Props, State> {
             // network connection error
             discoveryProvider.reportUnreachable(provider);
           }
+          
           // try next provider
           continue;
         }
       }
     }
 
-    this.setState({
-      resolvingAsset: false,
-      error: new Error('Unable to reach any provider serving this content')
-    });
+    setError(new Error('Unable to reach any provider serving this content'))
   }
 
-  render () {
-    const { error, contentType, resolvedAssetUrl } = this.state;
-
-    console.log({ resolvedAssetUrl })
-
-    if (error) {
-      return (
-        <Message error className='JoyMainStatus'>
-          <Message.Header>Error loading media content</Message.Header>
-          <p>{error.toString()}</p>
-          <button className='ui button' onClick={this.resolveAsset}>Try again</button>
-        </Message>
-      );
-    }
+  console.log('Content id:', contentId.encode())
+  console.log('Resolved asset URL:', resolvedAssetUrl)
+
+  if (error) {
+    return (
+      <Message error className='JoyMainStatus'>
+        <Message.Header>Error loading media content</Message.Header>
+        <p>{error.toString()}</p>
+        <button className='ui button' onClick={resolveAsset}>Try again</button>
+      </Message>
+    );
+  }
 
-    if (resolvedAssetUrl) {
-      const playerProps = { ...this.props, contentType, resolvedAssetUrl }
-      return <MediaPlayerView {...playerProps} />;
-    } else {
-      return <em>Resolving media content...</em>;
-    }
+  if (!resolvedAssetUrl) {
+    return <em>Resolving media content...</em>
   }
+
+  const playerProps = { ...props, contentType, resolvedAssetUrl }
+  return <MediaPlayerView {...playerProps} />
 }
 
 export const MediaPlayerWithResolver = withMulti(

+ 1 - 1
packages/joy-media/src/common/index.css

@@ -376,7 +376,7 @@ $borderColor: #e4e4e4;
   }
 
   .JoyPlayAlbum_Featured {
-    max-width: 200px;
+    max-width: 450px;
   }
 }
 

+ 1 - 0
packages/joy-media/src/music/EditMusicAlbum.view.tsx

@@ -3,6 +3,7 @@ import { OuterProps, EditForm } from './EditMusicAlbum';
 
 export const EditMusicAlbumView = MediaView<OuterProps>({
   component: EditForm,
+  triggers: [ 'id' ],
   resolveProps: async (props) => {
     const { transport, id } = props;
     const entity = id ? await transport.musicAlbumById(id) : undefined;

+ 1 - 0
packages/joy-media/src/upload/EditVideo.view.tsx

@@ -9,6 +9,7 @@ type Props = OuterProps;
 
 export const EditVideoView = MediaView<Props>({
   component: EditForm,
+  triggers: [ 'id' ],
   resolveProps: async (props) => {
     const { transport, id, channelId } = props;
     const channel = channelId && await transport.channelById(channelId);

+ 1 - 0
packages/joy-media/src/video/PlayVideo.view.tsx

@@ -9,6 +9,7 @@ type Props = PlayVideoProps;
 
 export const PlayVideoView = MediaView<Props>({
   component: PlayVideo,
+  triggers: [ 'id' ],
   resolveProps: async (props) => {
     const { transport, id } = props
 

+ 5 - 0
packages/joy-utils/src/IdLike.ts

@@ -0,0 +1,5 @@
+export type IdLike = { toString: () => string } | number
+
+export type HasId = { id: IdLike }
+
+export type MayHaveId = { id?: IdLike }

+ 1 - 4
packages/joy-utils/src/SimpleCache.ts

@@ -1,8 +1,5 @@
 import BN from 'bn.js'
-
-type IdLike = { toString: () => string } | number
-
-type HasId = { id: IdLike }
+import { IdLike, HasId } from './IdLike'
 
 type LoadObjectsByIdsFn<Id extends IdLike, Obj extends HasId> =
   (ids: Id[]) => Promise<Obj[]>