labo-components
Version:
608 lines (519 loc) • 26.4 kB
JSX
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
};