UNPKG

labo-components

Version:
608 lines (519 loc) 26.4 kB
import React from 'react'; import PropTypes from 'prop-types'; import IDUtil from './util/IDUtil'; // for generating unique CSS classnames for this component import RegexUtil from './util/RegexUtil'; // for matching the initial search term with available transcripts import LocalStorageHandler from './util/LocalStorageHandler'; // for updating the stored-visited-results import SessionStorageHandler from './util/SessionStorageHandler'; import Events from './util/Events'; // for updating the stored-visited-results import DocumentAPI from './api/DocumentAPI'; // for fetching the resource from the search API import ProjectAPI from './api/ProjectAPI'; // for fetching the active project (user can change the project in the resource viewer) import PlayoutAPI from './api/PlayoutAPI'; // for requesting access to the play-out proxy import CollectionUtil from './util/CollectionUtil'; // for generating a CollectionConfig object for the current resource (after loading the resource) import ViewerBase from './components/resourceviewer/ViewerBase'; import Loading from './components/shared/Loading'; import Query from './model/Query'; import { ResourceViewerContext } from './components/resourceviewer/ResourceViewerContext'; import {AnnotationClient, AnnotationEvents, MSAnnotationUtil} from './components/resourceviewer/AnnotationClient'; import { ANNOTATION_TYPE, SESSION_STORAGE_ACTIVE_TYPES } from './util/AnnotationConstants'; import { ENTITY_TYPE } from './util/EntityConstants'; import TranscriptUtil from "./util/TranscriptUtil"; /* This component gets the following from the URL: - resource ID - collection ID - search term - fragement url (@deprecated, ignore for now) Mainly this component fetches all the necessary data for the ViewerBase (drawing all the awesome new stuff): - user (present in this compoment's props) - (optional) query (fetch from local storage or construct one from the URL param: search term) - user projects (list of projects owned by the current user) - resource (containing metadata + playlist of media objects + more stuff, see SearchResult.js in the model package) - collectionConfig (TODO merge with resource object) Simple stuff like loading a 404 and loading graphic is rendered here (but might as well be done in the ViewerBase as well) */ export const ResourceEvents = { // When entities were asynchronously loaded, notify listeners to update their resource representation SET_ENTITIES: 'set_entities' }; export default class ResourceViewer extends React.Component { constructor(props) { super(props); const storedSearchResults = LocalStorageHandler.getJSONFromLocalStorage('stored-search-results'); const singleResource = window.location.search.indexOf('singleResource') > -1; const query = !singleResource && storedSearchResults ? storedSearchResults.query : null; this.state = { isLoading: true, // whenever data is being fetched and a loading graphic needs to be shown found: false, // whenever the DocumentAPI returns a 404 reflect this in this boolean activeProject: LocalStorageHandler.getJSONFromLocalStorage( 'stored-active-project' ), // the currently selected project || null activeMediaObject: null, // nothing selected by default, is populated after the ViewerColumn finishes initialisation query, // the query.term can be used for highlighting parts of the metadata singleResource, // load just a single resource, not a results set userProjects: [], // list of the user's projects (loaded via the ProjectAPI) resource: null, // instance of SearchResult (TODO change class name later) collectionConfig: null, // should be part of the resource/SearchResult (TODO update the constructor of SearchResult) playerAPI : null, //will be populated (via setPlayerAPI) by the MediaColumn when the player is ready! activeAnnotationTypes: this.getActiveAnnotationTypes(), }; //store the search term in the session so the annotation columns can get it const SESSION_SEARCH_TERM = 'bg__content-annotations-search-term'; if(query) { // empty on corrupted queries? SessionStorageHandler.set(SESSION_SEARCH_TERM, query.term) } this.mediaEvents = new Events(); this.resourceEvents = new Events(); this.CLASS_PREFIX = 'RV'; // please use this when generating unique class names for this component } async componentDidMount() { // first create a Query object from the search term URL parameter const query = this.state.query ? this.state.query : this.props.params.st ? Query.construct({ term: this.props.params.st }) // query from local storage or construct based on URL param : null; // this item was accessed not via the search page, so leave the query out // then fetch the user projects to populate the project selection at the top of the page const userProjects = await this.loadUserProjects(this.props.user); // then construct a collection config (so the resource can be formatted for the UI on retrieval) const collectionConfig = await this.generateCollectionConfig( this.props.clientId, this.props.user, this.props.params.cid ); // then load the resource (actually a SearchResult object...) const resource = await this.loadResource( this.props.params.id, this.props.params.cid, collectionConfig, query, this.props.params.l ); // set the state to something 404'ish if the resource could not be retrieved if (!resource || !resource.resourceId) { this.setState({ isLoading: false, // loading graphic should be disabled found: false, // so the render method knows that the resource could not be retrieved (e.g. because of invalid ids OR server problems) query: query, // so the 404 message can also include a reference to the original query userProjects: userProjects, // it's ok to render the list of projects, but could be hidden as well resource: null, // reset collectionConfig: null // reset }); return; } const activeMediaObject = this.determineInitialMediaObject( resource, this.props.params, collectionConfig ); //add the entities to the resource (load them via grlc queries) //const resourceEntities = await this.fetchEntitiesInResource(collectionConfig, collectionConfig.getResourceUri(resource)); //resource.setEntities(resourceEntities); //this time provice a callback function this.fetchEntitiesInResource( collectionConfig, collectionConfig.getResourceUri(resource), entities => { console.debug('All entities were fetched, now setting the entities'); this.setResourceEntities(entities) } ); // the annotation client is a singleton, so always the same instance is returned (with updated props) this.annotationClient = AnnotationClient.singleton( this.props.recipe.ingredients.annotationConfig, this.mediaEvents //augment the media events with annotation event handling ); // set the state with everything that got successfully loaded this.setState( { isLoading: false, // hide the loading graphic found: true, // proudly show the resource was found, by drawing the new awesome components on screen! query: query, // always nice to be able to reference the query leading up to this resource userProjects: userProjects, // for populating the ProjectList.jsx component (or something more fancy) resource: resource, // contains all the metadata + playlist of media objects + more collectionConfig: collectionConfig // use this properly format resource data for the particular collection }, () => { // finally add the resource to the list of visited items LocalStorageHandler.pushItemToLocalStorage( 'stored-visited-results', resource.resourceId ); // set the first MediaObject to active if ( resource.playableContent && resource.playableContent.length > 0 ) { this.setActiveMediaObject(activeMediaObject, true); //resource.playableContent[0] } } ); } /* ----------------------------- DETERMINE INITIAL MEDIA OBJECT ------------------------ */ //turns url params into a media fragment for the resource viewer to highlight //otherwise: look for the first matching media object/fragment in the entirety of the resource determineInitialMediaObject = (resource, urlParams, collectionConfig) => { const mediaObjects = resource.playableContent || []; if (mediaObjects.length === 0) return null; let activeMediaObject = null; //first try to simpy fetch the mediaObject indicated by the URL params if (urlParams.contentId !== undefined) { activeMediaObject = mediaObjects.find( mo => mo.contentId === urlParams.contentId ); //if the client side mapping did not add a mediafragment, create one here if(activeMediaObject) { activeMediaObject.mediaFragments = this.__toMediaFragments(urlParams); } } else if(typeof urlParams.st === 'string' && urlParams.st.length >= 2) { //then see if a media object can be fetched by checking the whole resource activeMediaObject = collectionConfig.findMatchingMediaFragments(resource, urlParams.st); } return activeMediaObject ? activeMediaObject : mediaObjects[0]; //just return the first if nothing was found }; __toMediaFragments = urlParams => { if (urlParams.s !== undefined || urlParams.e !== undefined) { return [{ start : urlParams.s !== undefined ? urlParams.s : 0, end : urlParams.e !== undefined ? urlParams.e : 0 }] } else if (urlParams.x !== undefined && urlParams.y !== undefined && urlParams.w !== undefined && urlParams.h !== undefined) { return [{ x : urlParams.x, y : urlParams.y, w : urlParams.w, h : urlParams.h }] } }; /* -------------------------------- FIND THE ENTITIES IN THE RESOURCE -------------------------- */ fetchEntitiesInResource = async(collectionConfig, resourceURI, callback=null) => { const entities = {} for(const et in ENTITY_TYPE) {//NOTE: async does not easily work in a forEach let entitiesOfType = await this.__fetchEntitiesOfType(collectionConfig, resourceURI, ENTITY_TYPE[et]); if(entitiesOfType != null) { //only assign if not null entities[ENTITY_TYPE[et]] = entitiesOfType } } if(callback && typeof(callback) === "function") { console.debug('calling back the caller with some delicious entities', entities); callback(entities) } return entities; }; __fetchEntitiesOfType = (collectionConfig, resourceURI, entityType) => { return new Promise((resolve) => { const entityTypeConfig = this.__getEntityTypeConfig(collectionConfig, entityType); if(entityTypeConfig && typeof(entityTypeConfig.fetchEntitiesInResource) === "function") { entityTypeConfig.fetchEntitiesInResource( resourceURI, resolve ); } else { resolve(null); } }); }; __getEntityTypeConfig = (collectionConfig, entityType) => { const entityConfig = collectionConfig.getEntityConfig(); if(!entityConfig || !entityConfig[entityType]) return null; return entityConfig[entityType]; }; setResourceEntities = entities => { this.resourceEvents.trigger( ResourceEvents.SET_ENTITIES, entities ); const resource = this.state.resource; resource.setEntities(entities) this.setState({resource : resource}); }; /* ----------------------------- CONTEXT MANIPULATION FUNCTIONS ------------------------ */ setActiveProject = async (project) => { //first make sure to reload the annotations from the server await this.annotationClient.setActiveTarget( [ this.state.collectionConfig ? this.state.collectionConfig.getSearchIndex() : null, this.state.resource ? this.state.resource.resourceId : null, this.state.activeMediaObject ? this.state.activeMediaObject.assetId : null ], { user : this.props.user.id, project: project ? project.id : null }, this.state.activeMediaObject ? this.__extractAnnotationTypes(this.state.activeMediaObject.mimeType) : null ); this.setState({ activeProject: project }); // store active project to localstorage LocalStorageHandler.storeJSONInLocalStorage( 'stored-active-project', project ); }; setActiveMediaObject = async (mediaObject, initPage=false) => { if (!mediaObject) { throw 'Could not set empty mediaobject as active'; } //await the playout access if(this.state.collectionConfig) { if(this.state.collectionConfig.requiresPlayoutAccess()) { if(mediaObject.requiresPlayoutAccess === false) { mediaObject.playoutAccess = true; } else { mediaObject.playoutAccess = await this.requestPlayoutAccess(mediaObject); } } else { mediaObject.playoutAccess = true; } } else { mediaObject.playoutAccess = true; } //now check if the active object matches the url content ID, so the fragment can be highlighted (again) //FIXME: this does not work properly yet! if(this.props.params.contentId === mediaObject.contentId) { mediaObject.mediaFragments = this.__toMediaFragments(this.props.params); } else if(typeof this.props.params.st === 'string' && this.props.params.st.length >= 2) { //TODO add an extra check to see if the user paged manually //then see if a media object can be fetched by checking the whole resource const matchingFragment = this.state.collectionConfig.findMatchingMediaFragments( this.state.resource, this.props.params.st, mediaObject //make sure the match is within the active media object ); //only assign the fragment if it matches the retrieved first hit if(matchingFragment && matchingFragment.contentId === mediaObject.contentId) { mediaObject.mediaFragment = matchingFragment ? matchingFragment.mediaFragment : null; } } //update the target in the annotation client (also making the client refetch annotations related to that target) //TODO basically here the known local context should be translated into a nested PID. //NOTE: clients of STAN should think about the resource structure await this.annotationClient.setActiveTarget( [ this.state.collectionConfig ? this.state.collectionConfig.getSearchIndex() : null, this.state.resource ? this.state.resource.resourceId : null, mediaObject.assetId ], { user : this.props.user.id, project: this.state.activeProject ? this.state.activeProject.id : null }, this.__extractAnnotationTypes(mediaObject.mimeType) ) //now set the state and then resolve to the callee return new Promise(resolve => { this.setState({ activeMediaObject: mediaObject, //transcript : transcript, //transcriptFirstHit : transcriptFirstHit //TODO should be removed and derived from activeMediaObject.fragment!!! }, resolve(mediaObject)); }) }; __extractAnnotationTypes = mimeType => { if(!mimeType) return []; if(mimeType.indexOf('video') !== -1) { return ['Video'] } else if(mimeType.indexOf('audio') !== -1) { return ['Audio'] } else if(mimeType.indexOf('image') !== -1) { return ['Image'] } return []; }; //so all components can control the player for fancy features! setPlayerAPI = playerAPI => this.setState({playerAPI}); /* ----------------------------- FUNCTIONS FOR SEQUENTIALLY LOADING ALL THE REQUIRED STATE INFORMATION ------------------------ */ loadUserProjects = user => { return new Promise(resolve => { if (!user || !user.id) resolve(null); ProjectAPI.list(user.id, null, resolve); }); }; generateCollectionConfig = (clientId, user, collectionId) => { return new Promise(resolve => { if (!clientId || !user || !collectionId) resolve(null); CollectionUtil.generateCollectionConfig( clientId, user, collectionId, resolve ); }); }; loadResource = ( resourceId, collectionId, collectionConfig, query = null, layerName = null ) => { return new Promise(resolve => { if (!resourceId || !collectionId || !collectionConfig) resolve(null); DocumentAPI.getResource( layerName ? collectionId + '__' + layerName : collectionId, // collectionId (with optional layer suffix) resourceId, collectionConfig, query, resolve // pass the resolve function as the callback for the API ); }); }; //this one is tied to the setActiveMediaObject() requestPlayoutAccess = mediaObject => { return new Promise(resolve => { if(mediaObject.mimeType.startsWith('application')) { resolve(true, null); } else if (mediaObject.contentServerId === undefined || mediaObject.contentId === undefined) { resolve(true, null); //e.g. youtube or vimeo playable content does not have a contentServerId or contentId } else { // if there is a contentServerId + contentId it means the playout proxy provide access PlayoutAPI.requestAccess( mediaObject.contentServerId, mediaObject.contentId, null, resolve ); } }); }; // ---------------------------------- TRANSCRIPT STATUS FUNCTIONS ------------------------------- getActiveTranscripts = () => { //TODO currently we're checking if we can allow transcripts even though there is no active media object if(this.state.resource.transcripts && this.state.activeMediaObject) { return this.state.resource.transcripts[this.state.activeMediaObject.assetId]; } return null; }; getActiveTranscript = (transcriptType=null, matchTitle=false) => { const activeTranscripts = this.getActiveTranscripts(); //FIXME ASR for DAAN/IMMIX needs to be fixed on the server if(!activeTranscripts || Object.keys(activeTranscripts).length === 0) return null; const typeIndex = activeTranscripts.findIndex( t => matchTitle ? t.title === transcriptType : t.type === transcriptType ) //if no type is specified, just return the first one (TODO test with 2e kamer, which has multiple transcripts) return transcriptType && typeIndex !== -1 ? activeTranscripts[typeIndex] : activeTranscripts[0]; }; getFirstHitInTranscript = (searchTerm, transcript) => { if(!searchTerm || searchTerm.length < 2 || !transcript || !transcript.lines) { return null; } let firstHit = null; let regex = null; try { regex = RegexUtil.generateRegexForSearchTerm(searchTerm.toLowerCase()); } catch (err) { firstHit = transcript.lines[0]; } firstHit = regex ? transcript.lines.find(item => item.text.toLowerCase().search(regex) !== -1) : null; return firstHit ? Math.floor(firstHit.start / 1000): -1; //return in seconds }; /* ----------------------------- ANNOTATION TYPES ------------------------ */ // get active types from local storage getActiveAnnotationTypes(){ return SessionStorageHandler.getSplit(SESSION_STORAGE_ACTIVE_TYPES, Object.values(ANNOTATION_TYPE)); } // set active annotations to state and store to session storage setActiveAnnotationTypes = (activeAnnotationTypes) =>{ this.setState({activeAnnotationTypes}); SessionStorageHandler.set(SESSION_STORAGE_ACTIVE_TYPES, activeAnnotationTypes.join(",")); } /* ----------------------------- RENDER FUNCTIONS ------------------------ */ renderResourceNotFound = () => ( <div className={IDUtil.cssClassName('not-found', this.CLASS_PREFIX)}> The resource could not be found </div> ); renderLoadingGraphic = () => <Loading />; renderViewerbase = (resource, collectionConfig) => { if (!resource || !collectionConfig) return null; return <ViewerBase />; }; render() { const loadingGraphic = this.state.isLoading ? this.renderLoadingGraphic() : null; const notFoundMsg = this.state.found === false && this.state.isLoading === false ? this.renderResourceNotFound() : null; const viewerBase = this.renderViewerbase( this.state.resource, this.state.collectionConfig ); return ( <div className={IDUtil.cssClassName('resource-viewer')}> {loadingGraphic} {notFoundMsg} <ResourceViewerContext.Provider value={{ //specific RV props singleResource: this.state.singleResource, resourceEvents: this.resourceEvents, mediaEvents: this.mediaEvents, //kind of stable properties also used elsewhere in the MS urlParams : this.props.params, recipe: this.props.recipe, user: this.props.user, userProjects: this.state.userProjects, query: this.state.query, collectionConfig: this.state.collectionConfig, resource: this.state.resource, // active annotation types activeAnnotationTypes: this.state.activeAnnotationTypes, setActiveAnnotationTypes: this.setActiveAnnotationTypes, // setting a project affects most components activeProject: this.state.activeProject, setActiveProject: this.setActiveProject, // changing the media object affects most components setActiveMediaObject: this.setActiveMediaObject, activeMediaObject: this.state.activeMediaObject, // annotation & segment related props are stored in the annotation client annotationClient: this.annotationClient, getActiveTranscripts : this.getActiveTranscripts, getActiveTranscript : this.getActiveTranscript, getFirstHitInTranscript : this.getFirstHitInTranscript, //playerAPI can be used everywhere! playerAPI : this.state.playerAPI, setPlayerAPI : this.setPlayerAPI }} > {viewerBase} </ResourceViewerContext.Provider> </div> ); } } ResourceViewer.propTypes = { clientId: PropTypes.string.isRequired, // Required for generating the collection config user: PropTypes.shape({ // required for several API calls id: PropTypes.string.isRequired }).isRequired, params: PropTypes.shape({ // these are params passed via the URL the params cid: PropTypes.string.isRequired, id: PropTypes.string.isRequired, st: PropTypes.string, fragmentUrl: PropTypes.string //replace this with media object ID (a.k.a. asset ID) }).isRequired, recipe: PropTypes.shape({ // for now the recipe ingredients are needed for the FlexPlayer; TODO remove later description: PropTypes.string, id: PropTypes.string, inRecipeList: PropTypes.bool, name: PropTypes.string.isRequired, phase: PropTypes.string, recipeDescription: PropTypes.string, type: PropTypes.string, url: PropTypes.string, ingredients: PropTypes.shape({ useProjects: PropTypes.bool, // should use, but forget it for now annotationConfig: PropTypes.object // updated for STAN }).isRequired }).isRequired };