diff options
author | geireann <geireann.lindfield@gmail.com> | 2021-08-25 21:42:55 -0400 |
---|---|---|
committer | geireann <geireann.lindfield@gmail.com> | 2021-08-25 21:42:55 -0400 |
commit | d5ee7f7436aaf3a50275379ff986cb3546d6f6a0 (patch) | |
tree | 8f78096042c66ae4b4f2de00de151eee521dc5cf | |
parent | 685c2c2e34a56619ba07052d7298149bc218e6e5 (diff) | |
parent | 8beb8fa42ba5f84bb13b5877560fc92ad3613e88 (diff) |
Merge branch 'temporalmedia-mehek' into menu_updates_geireann
-rw-r--r-- | src/client/documents/Documents.ts | 2 | ||||
-rw-r--r-- | src/client/util/CurrentUserUtils.ts | 2 | ||||
-rw-r--r-- | src/client/views/AudioWaveform.tsx | 121 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.tsx | 11 | ||||
-rw-r--r-- | src/client/views/collections/CollectionStackedTimeline.scss | 142 | ||||
-rw-r--r-- | src/client/views/collections/CollectionStackedTimeline.tsx | 757 | ||||
-rw-r--r-- | src/client/views/nodes/AudioBox.scss | 338 | ||||
-rw-r--r-- | src/client/views/nodes/AudioBox.tsx | 645 | ||||
-rw-r--r-- | src/client/views/nodes/DocumentView.tsx | 1 | ||||
-rw-r--r-- | src/client/views/nodes/LabelBox.tsx | 21 |
10 files changed, 1457 insertions, 583 deletions
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index ee3a38a0a..8ef31362c 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -377,7 +377,7 @@ export namespace Docs { }], [DocumentType.AUDIO, { layout: { view: AudioBox, dataField: defaultDataKey }, - options: { _height: 35, backgroundColor: "lightGray", links: ComputedField.MakeFunction("links(self)") as any } + options: { _height: 100, backgroundColor: "lightGray", links: ComputedField.MakeFunction("links(self)") as any } }], [DocumentType.PDF, { layout: { view: PDFBox, dataField: defaultDataKey }, diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index fbe216ef1..cd2803373 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -480,7 +480,7 @@ export class CurrentUserUtils { (doc.emptyWall as Doc).videoWall = true; } if (doc.emptyAudio === undefined) { - doc.emptyAudio = Docs.Create.AudioDocument(nullAudio, { _width: 200, title: "audio recording", system: true, cloneFieldFilter: new List<string>(["system"]) }); + doc.emptyAudio = Docs.Create.AudioDocument(nullAudio, { _width: 200, _height: 100, title: "audio recording", system: true, cloneFieldFilter: new List<string>(["system"]) }); ((doc.emptyAudio as Doc).proto as Doc)["dragFactory-count"] = 0; } if (doc.emptyNote === undefined) { diff --git a/src/client/views/AudioWaveform.tsx b/src/client/views/AudioWaveform.tsx index 7ff9c1071..f519f9ef0 100644 --- a/src/client/views/AudioWaveform.tsx +++ b/src/client/views/AudioWaveform.tsx @@ -6,56 +6,121 @@ import Waveform from "react-audio-waveform"; import { Doc } from "../../fields/Doc"; import { List } from "../../fields/List"; import { listSpec } from "../../fields/Schema"; -import { Cast } from "../../fields/Types"; +import { Cast, NumCast } from "../../fields/Types"; import { numberRange } from "../../Utils"; import "./AudioWaveform.scss"; +import { Colors } from "./global/globalEnums"; export interface AudioWaveformProps { duration: number; mediaPath: string; - dataDoc: Doc; + layoutDoc: Doc; + trimming: boolean; PanelHeight: () => number; } @observer export class AudioWaveform extends React.Component<AudioWaveformProps> { public static NUMBER_OF_BUCKETS = 100; - @computed get _waveHeight() { return Math.max(50, this.props.PanelHeight()); } + @computed get _waveHeight() { + return Math.max(50, this.props.PanelHeight()); + } componentDidMount() { - const audioBuckets = Cast(this.props.dataDoc.audioBuckets, listSpec("number"), []); + const audioBuckets = Cast( + this.props.layoutDoc.audioBuckets, + listSpec("number"), + [] + ); if (!audioBuckets.length) { - this.props.dataDoc.audioBuckets = new List<number>([0, 0]); /// "lock" to prevent other views from computing the same data + this.props.layoutDoc.audioBuckets = new List<number>([0, 0]); /// "lock" to prevent other views from computing the same data setTimeout(this.createWaveformBuckets); } } + // decodes the audio file into peaks for generating the waveform createWaveformBuckets = async () => { - axios({ url: this.props.mediaPath, responseType: "arraybuffer" }) - .then(response => { + axios({ url: this.props.mediaPath, responseType: "arraybuffer" }).then( + (response) => { const context = new window.AudioContext(); - context.decodeAudioData(response.data, - action(buffer => { + context.decodeAudioData( + response.data, + action((buffer) => { const decodedAudioData = buffer.getChannelData(0); - const bucketDataSize = Math.floor(decodedAudioData.length / AudioWaveform.NUMBER_OF_BUCKETS); + + const bucketDataSize = Math.floor( + decodedAudioData.length / AudioWaveform.NUMBER_OF_BUCKETS + ); const brange = Array.from(Array(bucketDataSize)); - this.props.dataDoc.audioBuckets = new List<number>( - numberRange(AudioWaveform.NUMBER_OF_BUCKETS).map((i: number) => - brange.reduce((p, x, j) => Math.abs(Math.max(p, decodedAudioData[i * bucketDataSize + j])), 0) / 2)); - })); - }); - } + this.props.layoutDoc.audioBuckets = new List<number>( + numberRange(AudioWaveform.NUMBER_OF_BUCKETS).map( + (i: number) => + brange.reduce( + (p, x, j) => + Math.abs( + Math.max(p, decodedAudioData[i * bucketDataSize + j]) + ), + 0 + ) / 2 + ) + ); + }) + ); + } + ); + }; + + @action + createTrimBuckets = () => { + const audioBuckets = Cast( + this.props.layoutDoc.audioBuckets, + listSpec("number"), + [] + ); + + const start = Math.floor( + (NumCast(this.props.layoutDoc.clipStart) / this.props.duration) * 100 + ); + const end = Math.floor( + (NumCast(this.props.layoutDoc.clipEnd) / this.props.duration) * 100 + ); + return audioBuckets.slice(start, end); + }; render() { - const audioBuckets = Cast(this.props.dataDoc.audioBuckets, listSpec("number"), []); - return <div className="audioWaveform"> - <Waveform - color={"darkblue"} - height={this._waveHeight} - barWidth={0.1} - pos={this.props.duration} - duration={this.props.duration} - peaks={audioBuckets.length === AudioWaveform.NUMBER_OF_BUCKETS ? audioBuckets : undefined} - progressColor={"blue"} /> - </div>; + const audioBuckets = Cast( + this.props.layoutDoc.audioBuckets, + listSpec("number"), + [] + ); + + return ( + <div className="audioWaveform"> + {this.props.trimming || !this.props.layoutDoc.clipEnd ? ( + <Waveform + color={Colors.MEDIUM_BLUE} + height={this._waveHeight} + barWidth={0.1} + pos={this.props.duration} + duration={this.props.duration} + peaks={ + audioBuckets.length === AudioWaveform.NUMBER_OF_BUCKETS + ? audioBuckets + : undefined + } + progressColor={Colors.MEDIUM_BLUE} + /> + ) : ( + <Waveform + color={Colors.MEDIUM_BLUE} + height={this._waveHeight} + barWidth={0.1} + pos={this.props.duration} + duration={this.props.duration} + peaks={this.createTrimBuckets()} + progressColor={Colors.MEDIUM_BLUE} + /> + )} + </div> + ); } -}
\ No newline at end of file +} diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 118d2e7c7..e454be618 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -391,10 +391,10 @@ export class DocumentDecorations extends React.Component<{ boundsLeft: number, b this._inkDragDocs.map(oldbds => ({ oldbds, inkPts: Cast(oldbds.doc.data, InkField)?.inkData || [] })) .forEach(({ oldbds: { doc, x, y, width, height }, inkPts }) => { Doc.GetProto(doc).data = new InkField(inkPts.map(ipt => // (new x — oldx) + newWidth * (oldxpoint /oldWidth) - ({ - X: (NumCast(doc.x) - x) + NumCast(doc.width) * ipt.X / width, - Y: (NumCast(doc.y) - y) + NumCast(doc.height) * ipt.Y / height - }))); + ({ + X: (NumCast(doc.x) - x) + NumCast(doc.width) * ipt.X / width, + Y: (NumCast(doc.y) - y) + NumCast(doc.height) * ipt.Y / height + }))); Doc.SetNativeWidth(doc, undefined); Doc.SetNativeHeight(doc, undefined); }); @@ -404,6 +404,9 @@ export class DocumentDecorations extends React.Component<{ boundsLeft: number, b get selectionTitle(): string { if (SelectionManager.Views().length === 1) { const selected = SelectionManager.Views()[0]; + if (selected.ComponentView?.getTitle?.()) { + return selected.ComponentView.getTitle(); + } if (this._titleControlString.startsWith("=")) { return ScriptField.MakeFunction(this._titleControlString.substring(1), { doc: Doc.name })!.script.run({ self: selected.rootDoc, this: selected.layoutDoc }, console.log).result?.toString() || ""; } diff --git a/src/client/views/collections/CollectionStackedTimeline.scss b/src/client/views/collections/CollectionStackedTimeline.scss index e456c0664..59c21210a 100644 --- a/src/client/views/collections/CollectionStackedTimeline.scss +++ b/src/client/views/collections/CollectionStackedTimeline.scss @@ -1,70 +1,94 @@ +@import "../global/globalCssVariables.scss"; + .collectionStackedTimeline { + position: absolute; + width: 100%; + height: 100%; + z-index: 1000; + overflow: hidden; + top: 0px; + + .collectionStackedTimeline-trim-shade { position: absolute; - width: 100%; height: 100%; - border: gray solid 1px; - border-radius: 3px; - z-index: 1000; - overflow: hidden; - top: 0px; + background-color: $dark-gray; + opacity: 0.3; + } - .collectionStackedTimeline-selector { - position: absolute; - width: 10px; - top: 2.5%; - height: 95%; - background: lightblue; - border-radius: 5px; - opacity: 0.3; - z-index: 500; - border-style: solid; - border-color: darkblue; - border-width: 1px; - } + .collectionStackedTimeline-trim-controls { + height: 100%; + position: absolute; + box-sizing: border-box; + border: 2px solid $medium-blue; + display: flex; + justify-content: space-between; + max-width: 100%; - .collectionStackedTimeline-current { - width: 1px; - height: 100%; - background-color: red; - position: absolute; - top: 0px; - pointer-events: none; + .collectionStackedTimeline-trim-handle { + background-color: $medium-blue; + height: 100%; + width: 5px; + cursor: ew-resize; } + } - .collectionStackedTimeline-marker-timeline { - position: absolute; - top: 2.5%; - height: 95%; - border-radius: 4px; - &:hover { - opacity: 1; - } + .collectionStackedTimeline-selector { + position: absolute; + width: 10px; + top: 2.5%; + height: 95%; + background: $light-blue; + border-radius: 3px; + opacity: 0.3; + z-index: 500; + border-style: solid; + border-color: $medium-blue; + border-width: 1px; + } - .collectionStackedTimeline-left-resizer, - .collectionStackedTimeline-resizer { - background: dimgrey; - position: absolute; - top: 0; - height: 100%; - width: 10px; - pointer-events: all; - cursor: ew-resize; - z-index: 100; - } - .collectionStackedTimeline-resizer { - right: 0; - } - .collectionStackedTimeline-left-resizer { - left: 0; - } + .collectionStackedTimeline-current { + width: 1px; + height: 100%; + background-color: $pink; + position: absolute; + top: 0px; + pointer-events: none; + } + + .collectionStackedTimeline-marker-timeline { + position: absolute; + top: 2.5%; + height: 95%; + border-radius: 4px; + &:hover { + opacity: 1; } - .collectionStackedTimeline-waveform { - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - pointer-events: none; + .collectionStackedTimeline-left-resizer, + .collectionStackedTimeline-resizer { + background: $medium-gray; + position: absolute; + top: 0; + height: 100%; + width: 10px; + pointer-events: all; + cursor: ew-resize; + z-index: 100; + } + .collectionStackedTimeline-resizer { + right: 0; } -}
\ No newline at end of file + .collectionStackedTimeline-left-resizer { + left: 0; + } + } + + .collectionStackedTimeline-waveform { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + } +} diff --git a/src/client/views/collections/CollectionStackedTimeline.tsx b/src/client/views/collections/CollectionStackedTimeline.tsx index a2c95df6e..51e05e278 100644 --- a/src/client/views/collections/CollectionStackedTimeline.tsx +++ b/src/client/views/collections/CollectionStackedTimeline.tsx @@ -1,5 +1,12 @@ import React = require("react"); -import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { + action, + computed, + IReactionDisposer, + observable, + reaction, + runInAction, +} from "mobx"; import { observer } from "mobx-react"; import { computedFn } from "mobx-utils"; import { Doc, DocListCast } from "../../../fields/Doc"; @@ -8,7 +15,16 @@ import { List } from "../../../fields/List"; import { listSpec, makeInterface } from "../../../fields/Schema"; import { ComputedField, ScriptField } from "../../../fields/ScriptField"; import { Cast, NumCast } from "../../../fields/Types"; -import { emptyFunction, formatTime, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, StopEvent, returnTrue } from "../../../Utils"; +import { + emptyFunction, + formatTime, + OmitKeys, + returnFalse, + returnOne, + setupMoveUpEvents, + StopEvent, + returnTrue, +} from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { LinkManager } from "../../util/LinkManager"; import { Scripting } from "../../util/Scripting"; @@ -18,9 +34,15 @@ import { undoBatch } from "../../util/UndoManager"; import { AudioWaveform } from "../AudioWaveform"; import { CollectionSubView } from "../collections/CollectionSubView"; import { LightboxView } from "../LightboxView"; -import { DocAfterFocusFunc, DocFocusFunc, DocumentView, DocumentViewProps } from "../nodes/DocumentView"; +import { + DocAfterFocusFunc, + DocFocusFunc, + DocumentView, + DocumentViewProps, +} from "../nodes/DocumentView"; import { LabelBox } from "../nodes/LabelBox"; import "./CollectionStackedTimeline.scss"; +import { Colors } from "../global/globalEnums"; type PanZoomDocument = makeInterface<[]>; const PanZoomDocument = makeInterface(); @@ -36,11 +58,21 @@ export type CollectionStackedTimelineProps = { endTag: string; mediaPath: string; dictationKey: string; + trimming: boolean; + trimStart: number; + trimEnd: number; + trimDuration: number; + setStartTrim: (newStart: number) => void; + setEndTrim: (newEnd: number) => void; }; @observer -export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument, CollectionStackedTimelineProps>(PanZoomDocument) { - @observable static SelectingRegion: CollectionStackedTimeline | undefined = undefined; +export class CollectionStackedTimeline extends CollectionSubView< + PanZoomDocument, + CollectionStackedTimelineProps +>(PanZoomDocument) { + @observable static SelectingRegion: CollectionStackedTimeline | undefined = + undefined; static RangeScript: ScriptField; static LabelScript: ScriptField; static RangePlayScript: ScriptField; @@ -50,58 +82,123 @@ export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument private _markerStart: number = 0; @observable _markerEnd: number = 0; - get duration() { return this.props.duration; } - @computed get currentTime() { return NumCast(this.layoutDoc._currentTimecode); } + get minLength() { + const rect = this._timeline?.getBoundingClientRect(); + if (rect) { + return 0.05 * this.duration; + } + return 0; + } + + get trimStart() { + return this.props.trimStart; + } + + get trimEnd() { + return this.props.trimEnd; + } + + get duration() { + return this.props.duration; + } + + @computed get currentTime() { + return NumCast(this.layoutDoc._currentTimecode); + } @computed get selectionContainer() { - return CollectionStackedTimeline.SelectingRegion !== this ? (null) : <div className="collectionStackedTimeline-selector" style={{ - left: `${Math.min(NumCast(this._markerStart), NumCast(this._markerEnd)) / this.duration * 100}%`, - width: `${Math.abs(this._markerStart - this._markerEnd) / this.duration * 100}%` - }} />; + return CollectionStackedTimeline.SelectingRegion !== this ? null : ( + <div + className="collectionStackedTimeline-selector" + style={{ + left: `${((Math.min(this._markerStart, this._markerEnd) - this.trimStart) / this.props.trimDuration) * 100}%`, + width: `${(Math.abs(this._markerStart - this._markerEnd) / this.props.trimDuration) * 100}%`, + }} + /> + ); } constructor(props: any) { super(props); // onClick play scripts - CollectionStackedTimeline.RangeScript = CollectionStackedTimeline.RangeScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this, clientX)`, { self: Doc.name, scriptContext: "any", clientX: "number" })!; - CollectionStackedTimeline.RangePlayScript = CollectionStackedTimeline.RangePlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this, clientX)`, { self: Doc.name, scriptContext: "any", clientX: "number" })!; + CollectionStackedTimeline.RangeScript = + CollectionStackedTimeline.RangeScript || + ScriptField.MakeFunction(`scriptContext.clickAnchor(this, clientX)`, { + self: Doc.name, + scriptContext: "any", + clientX: "number", + })!; + CollectionStackedTimeline.RangePlayScript = + CollectionStackedTimeline.RangePlayScript || + ScriptField.MakeFunction(`scriptContext.playOnClick(this, clientX)`, { + self: Doc.name, + scriptContext: "any", + clientX: "number", + })!; } - componentDidMount() { document.addEventListener("keydown", this.keyEvents, true); } + componentDidMount() { + document.addEventListener("keydown", this.keyEvents, true); + } componentWillUnmount() { document.removeEventListener("keydown", this.keyEvents, true); - if (CollectionStackedTimeline.SelectingRegion === this) runInAction(() => CollectionStackedTimeline.SelectingRegion = undefined); + if (CollectionStackedTimeline.SelectingRegion === this) + runInAction( + () => (CollectionStackedTimeline.SelectingRegion = undefined) + ); } - anchorStart = (anchor: Doc) => NumCast(anchor._timecodeToShow, NumCast(anchor[this.props.startTag])); + anchorStart = (anchor: Doc) => + NumCast(anchor._timecodeToShow, NumCast(anchor[this.props.startTag])); anchorEnd = (anchor: Doc, val: any = null) => { const endVal = NumCast(anchor[this.props.endTag], val); - return NumCast(anchor._timecodeToHide, endVal === undefined ? null : endVal); + return NumCast( + anchor._timecodeToHide, + endVal === undefined ? null : endVal + ); + }; + toTimeline = (screen_delta: number, width: number) => { + return Math.max( + this.trimStart, + Math.min(this.trimEnd, (screen_delta / width) * this.props.trimDuration + this.trimStart)) } - toTimeline = (screen_delta: number, width: number) => Math.max(0, Math.min(this.duration, screen_delta / width * this.duration)); + rangeClickScript = () => CollectionStackedTimeline.RangeScript; rangePlayScript = () => CollectionStackedTimeline.RangePlayScript; // for creating key anchors with key events @action keyEvents = (e: KeyboardEvent) => { - if (!(e.target instanceof HTMLInputElement) && this.props.isSelected(true)) { + if ( + !(e.target instanceof HTMLInputElement) && + this.props.isSelected(true) + ) { switch (e.key) { case " ": if (!CollectionStackedTimeline.SelectingRegion) { this._markerStart = this._markerEnd = this.currentTime; CollectionStackedTimeline.SelectingRegion = this; } else { - CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.props.fieldKey, this.props.startTag, this.props.endTag, this.currentTime); + CollectionStackedTimeline.createAnchor( + this.rootDoc, + this.dataDoc, + this.props.fieldKey, + this.props.startTag, + this.props.endTag, + this.currentTime + ); CollectionStackedTimeline.SelectingRegion = undefined; } } } - } + }; getLinkData(l: Doc) { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; - const linkTime = NumCast(la2[this.props.startTag], NumCast(la1[this.props.startTag])); + const linkTime = NumCast( + la2[this.props.startTag], + NumCast(la1[this.props.startTag]) + ); if (Doc.AreProtosEqual(la1, this.dataDoc)) { la1 = l.anchor2 as Doc; la2 = l.anchor1 as Doc; @@ -118,10 +215,18 @@ export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument const wasPlaying = this.props.playing(); if (wasPlaying) this.props.Pause(); const wasSelecting = CollectionStackedTimeline.SelectingRegion === this; - setupMoveUpEvents(this, e, - action(e => { - if (!wasSelecting && CollectionStackedTimeline.SelectingRegion !== this) { - this._markerStart = this._markerEnd = this.toTimeline(clientX - rect.x, rect.width); + setupMoveUpEvents( + this, + e, + action((e) => { + if ( + !wasSelecting && + CollectionStackedTimeline.SelectingRegion !== this + ) { + this._markerStart = this._markerEnd = this.toTimeline( + clientX - rect.x, + rect.width + ); CollectionStackedTimeline.SelectingRegion = this; } this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); @@ -134,32 +239,129 @@ export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument this._markerStart = this._markerEnd; this._markerEnd = tmp; } - if (!isClick && CollectionStackedTimeline.SelectingRegion === this && (Math.abs(movement[0]) > 15)) { - CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.props.fieldKey, this.props.startTag, this.props.endTag, - this._markerStart, this._markerEnd); + if ( + !isClick && + CollectionStackedTimeline.SelectingRegion === this && + Math.abs(movement[0]) > 15 && + !this.props.trimming + ) { + CollectionStackedTimeline.createAnchor( + this.rootDoc, + this.dataDoc, + this.props.fieldKey, + this.props.startTag, + this.props.endTag, + this._markerStart, + this._markerEnd + ); } - (!isClick || !wasSelecting) && (CollectionStackedTimeline.SelectingRegion = undefined); + (!isClick || !wasSelecting) && + (CollectionStackedTimeline.SelectingRegion = undefined); }), (e, doubleTap) => { this.props.select(false); - e.shiftKey && CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.props.fieldKey, this.props.startTag, this.props.endTag, this.currentTime); + e.shiftKey && + CollectionStackedTimeline.createAnchor( + this.rootDoc, + this.dataDoc, + this.props.fieldKey, + this.props.startTag, + this.props.endTag, + this.currentTime + ); !wasPlaying && doubleTap && this.props.Play(); }, - this.props.isSelected(true) || this.props.isContentActive(), undefined, - () => !wasPlaying && this.props.setTime((clientX - rect.x) / rect.width * this.duration)); + this.props.isSelected(true) || this.props.isContentActive(), + undefined, + () => { + !wasPlaying && + (this.props.trimming && this.duration ? + this.props.setTime(((clientX - rect.x) / rect.width) * this.duration) + : + this.props.setTime(((clientX - rect.x) / rect.width) * this.props.trimDuration + this.trimStart) + ) + } + ); } - } + + }; + + @action + trimLeft = (e: React.PointerEvent): void => { + const rect = this._timeline?.getBoundingClientRect(); + const clientX = e.movementX; + setupMoveUpEvents( + this, + e, + action((e, [], []) => { + if (rect && this.props.isContentActive()) { + this.props.setStartTrim(Math.min( + Math.max( + this.trimStart + (e.movementX / rect.width) * this.duration, + 0 + ), + this.trimEnd - this.minLength + )); + } + return false; + }), + emptyFunction, + action((e, doubleTap) => { + if (doubleTap) { + this.props.setStartTrim(0); + } + }) + ); + }; + + @action + trimRight = (e: React.PointerEvent): void => { + const rect = this._timeline?.getBoundingClientRect(); + const clientX = e.movementX; + setupMoveUpEvents( + this, + e, + action((e, [], []) => { + if (rect && this.props.isContentActive()) { + this.props.setEndTrim(Math.max( + Math.min( + this.trimEnd + (e.movementX / rect.width) * this.duration, + this.duration + ), + this.trimStart + this.minLength + )); + } + return false; + }), + emptyFunction, + action((e, doubleTap) => { + if (doubleTap) { + this.props.setEndTrim(this.duration); + } + }) + ); + }; @undoBatch @action - static createAnchor(rootDoc: Doc, dataDoc: Doc, fieldKey: string, startTag: string, endTag: string, anchorStartTime?: number, anchorEndTime?: number) { + static createAnchor( + rootDoc: Doc, + dataDoc: Doc, + fieldKey: string, + startTag: string, + endTag: string, + anchorStartTime?: number, + anchorEndTime?: number + ) { if (anchorStartTime === undefined) return rootDoc; const anchor = Docs.Create.LabelDocument({ - title: ComputedField.MakeFunction(`"#" + formatToTime(self["${startTag}"]) + "-" + formatToTime(self["${endTag}"])`) as any, + title: ComputedField.MakeFunction( + `"#" + formatToTime(self["${startTag}"]) + "-" + formatToTime(self["${endTag}"])` + ) as any, useLinkSmallAnchor: true, hideLinkButton: true, annotationOn: rootDoc, - _timelineLabel: true + _timelineLabel: true, }); Doc.GetProto(anchor)[startTag] = anchorStartTime; Doc.GetProto(anchor)[endTag] = anchorEndTime; @@ -179,7 +381,10 @@ export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument if (this.props.playing()) this.props.Pause(); else this.props.playFrom(seekTimeInSeconds, endTime); } else { - if (seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) && endTime > NumCast(this.layoutDoc._currentTimecode)) { + if ( + seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) && + endTime > NumCast(this.layoutDoc._currentTimecode) + ) { if (!this.layoutDoc.autoPlayAnchors && this.props.playing()) { this.props.Pause(); } else { @@ -190,127 +395,249 @@ export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument } } return { select: true }; - } + }; @action clickAnchor = (anchorDoc: Doc, clientX: number) => { - if (anchorDoc.isLinkButton) LinkManager.FollowLink(undefined, anchorDoc, this.props, false); + if (anchorDoc.isLinkButton) + LinkManager.FollowLink(undefined, anchorDoc, this.props, false); const seekTimeInSeconds = this.anchorStart(anchorDoc) - 0.25; const endTime = this.anchorEnd(anchorDoc); - if (seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) + 1e-4 && endTime > NumCast(this.layoutDoc._currentTimecode) - 1e-4) { + if ( + seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) + 1e-4 && + endTime > NumCast(this.layoutDoc._currentTimecode) - 1e-4 + ) { if (this.props.playing()) this.props.Pause(); else if (this.layoutDoc.autoPlayAnchors) this.props.Play(); else if (!this.layoutDoc.autoPlayAnchors) { const rect = this._timeline?.getBoundingClientRect(); - rect && this.props.setTime(this.toTimeline(clientX - rect.x, rect.width)); + rect && + this.props.setTime(this.toTimeline(clientX - rect.x, rect.width)); } } else { - if (this.layoutDoc.autoPlayAnchors) this.props.playFrom(seekTimeInSeconds, endTime); - else this.props.setTime(seekTimeInSeconds); + if (this.layoutDoc.autoPlayAnchors) + this.props.playFrom(seekTimeInSeconds, endTime); + else { + this.props.setTime(seekTimeInSeconds); + } } return { select: true }; - } - + }; // makes sure no anchors overlaps each other by setting the correct position and width - getLevel = (m: Doc, placed: { anchorStartTime: number, anchorEndTime: number, level: number }[]) => { + getLevel = ( + m: Doc, + placed: { anchorStartTime: number; anchorEndTime: number; level: number }[] + ) => { const timelineContentWidth = this.props.PanelWidth(); const x1 = this.anchorStart(m); - const x2 = this.anchorEnd(m, x1 + 10 / timelineContentWidth * this.duration); + const x2 = this.anchorEnd( + m, + x1 + (10 / timelineContentWidth) * this.duration + ); let max = 0; - const overlappedLevels = new Set(placed.map(p => { - const y1 = p.anchorStartTime; - const y2 = p.anchorEndTime; - if ((x1 >= y1 && x1 <= y2) || (x2 >= y1 && x2 <= y2) || - (y1 >= x1 && y1 <= x2) || (y2 >= x1 && y2 <= x2)) { - max = Math.max(max, p.level); - return p.level; - } - })); + const overlappedLevels = new Set( + placed.map((p) => { + const y1 = p.anchorStartTime; + const y2 = p.anchorEndTime; + if ( + (x1 >= y1 && x1 <= y2) || + (x2 >= y1 && x2 <= y2) || + (y1 >= x1 && y1 <= x2) || + (y2 >= x1 && y2 <= x2) + ) { + max = Math.max(max, p.level); + return p.level; + } + }) + ); let level = max + 1; for (let j = max; j >= 0; j--) !overlappedLevels.has(j) && (level = j); placed.push({ anchorStartTime: x1, anchorEndTime: x2, level }); return level; - } + }; dictationHeightPercent = 50; - dictationHeight = () => this.props.PanelHeight() * (100 - this.dictationHeightPercent) / 100; - timelineContentHeight = () => this.props.PanelHeight() * this.dictationHeightPercent / 100; - dictationScreenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, -this.timelineContentHeight()); + dictationHeight = () => + (this.props.PanelHeight() * (100 - this.dictationHeightPercent)) / 100; + timelineContentHeight = () => + (this.props.PanelHeight() * this.dictationHeightPercent) / 100; + dictationScreenToLocalTransform = () => + this.props + .ScreenToLocalTransform() + .translate(0, -this.timelineContentHeight()); @computed get renderDictation() { const dictation = Cast(this.dataDoc[this.props.dictationKey], Doc, null); - return !dictation ? (null) : <div style={{ position: "absolute", height: "100%", top: this.timelineContentHeight(), background: "tan" }}> - <DocumentView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} - Document={dictation} - PanelHeight={this.dictationHeight} - isAnnotationOverlay={true} - isDocumentActive={returnFalse} - select={emptyFunction} - scaling={returnOne} - xMargin={25} - yMargin={10} - ScreenToLocalTransform={this.dictationScreenToLocalTransform} - whenChildContentsActiveChanged={emptyFunction} - removeDocument={returnFalse} - moveDocument={returnFalse} - addDocument={returnFalse} - CollectionView={undefined} - renderDepth={this.props.renderDepth + 1}> - </DocumentView> - </div>; + return !dictation ? null : ( + <div + style={{ + position: "absolute", + height: "100%", + top: this.timelineContentHeight(), + background: Colors.LIGHT_BLUE, + }} + > + <DocumentView + {...OmitKeys(this.props, [ + "NativeWidth", + "NativeHeight", + "setContentView", + ]).omit} + Document={dictation} + PanelHeight={this.dictationHeight} + isAnnotationOverlay={true} + isDocumentActive={returnFalse} + select={emptyFunction} + scaling={returnOne} + xMargin={25} + yMargin={10} + ScreenToLocalTransform={this.dictationScreenToLocalTransform} + whenChildContentsActiveChanged={emptyFunction} + removeDocument={returnFalse} + moveDocument={returnFalse} + addDocument={returnFalse} + CollectionView={undefined} + renderDepth={this.props.renderDepth + 1} + ></DocumentView> + </div> + ); } @computed get renderAudioWaveform() { - return !this.props.mediaPath ? (null) : - <div className="collectionStackedTimeline-waveform" > + return !this.props.mediaPath ? null : ( + <div className="collectionStackedTimeline-waveform"> <AudioWaveform duration={this.duration} mediaPath={this.props.mediaPath} - dataDoc={this.dataDoc} - PanelHeight={this.timelineContentHeight} /> - </div>; + layoutDoc={this.layoutDoc} + PanelHeight={this.timelineContentHeight} + trimming={this.props.trimming} + /> + </div> + ); } + currentTimecode = () => this.currentTime; render() { const timelineContentWidth = this.props.PanelWidth(); - const overlaps: { anchorStartTime: number, anchorEndTime: number, level: number }[] = []; - const drawAnchors = this.childDocs.map(anchor => ({ level: this.getLevel(anchor, overlaps), anchor })); + const overlaps: { + anchorStartTime: number; + anchorEndTime: number; + level: number; + }[] = []; + const drawAnchors = this.childDocs.map((anchor) => ({ + level: this.getLevel(anchor, overlaps), + anchor, + })); const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; - const isActive = this.props.isContentActive() || this.props.isSelected(false); - return <div className="collectionStackedTimeline" ref={(timeline: HTMLDivElement | null) => this._timeline = timeline} - onClick={e => isActive && StopEvent(e)} onPointerDown={e => isActive && this.onPointerDownTimeline(e)}> - {drawAnchors.map(d => { - const start = this.anchorStart(d.anchor); - const end = this.anchorEnd(d.anchor, start + 10 / timelineContentWidth * this.duration); - const left = start / this.duration * timelineContentWidth; - const top = d.level / maxLevel * this.timelineContentHeight(); - const timespan = end - start; - return this.props.Document.hideAnchors ? (null) : - <div className={"collectionStackedTimeline-marker-timeline"} key={d.anchor[Id]} - style={{ left, top, width: `${timespan / this.duration * timelineContentWidth}px`, height: `${this.timelineContentHeight() / maxLevel}px` }} - onClick={e => { this.props.playFrom(start, this.anchorEnd(d.anchor)); e.stopPropagation(); }} > - <StackedTimelineAnchor {...this.props} - mark={d.anchor} - rangeClickScript={this.rangeClickScript} - rangePlayScript={this.rangePlayScript} - left={left} - top={top} - width={timelineContentWidth * timespan / this.duration} - height={this.timelineContentHeight() / maxLevel} - toTimeline={this.toTimeline} - layoutDoc={this.layoutDoc} - currentTimecode={this.currentTimecode} - _timeline={this._timeline} - stackedTimeline={this} - /> - </div>; - })} - {this.selectionContainer} - {this.renderAudioWaveform} - {this.renderDictation} - - <div className="collectionStackedTimeline-current" style={{ left: `${this.currentTime / this.duration * 100}%` }} /> - </div>; + const isActive = + this.props.isContentActive() || this.props.isSelected(false); + return ( + <div + className="collectionStackedTimeline" + ref={(timeline: HTMLDivElement | null) => (this._timeline = timeline)} + onClick={(e) => isActive && StopEvent(e)} + onPointerDown={(e) => isActive && this.onPointerDownTimeline(e)} + > + {drawAnchors.map((d) => { + + const start = this.anchorStart(d.anchor); + const end = this.anchorEnd( + d.anchor, + start + (10 / timelineContentWidth) * this.duration + ); + const left = this.props.trimming ? + (start / this.duration) * timelineContentWidth + : (start - this.trimStart) / this.props.trimDuration * timelineContentWidth; + const top = (d.level / maxLevel) * this.timelineContentHeight(); + const timespan = end - start; + const width = (timespan / this.props.trimDuration) * timelineContentWidth; + const height = this.timelineContentHeight() / maxLevel + return this.props.Document.hideAnchors ? null : ( + <div + className={"collectionStackedTimeline-marker-timeline"} + key={d.anchor[Id]} + style={{ + left, + top, + width: `${width}px`, + height: `${height}px`, + }} + onClick={(e) => { + this.props.playFrom(start, this.anchorEnd(d.anchor)); + e.stopPropagation(); + }} + > + <StackedTimelineAnchor + {...this.props} + mark={d.anchor} + rangeClickScript={this.rangeClickScript} + rangePlayScript={this.rangePlayScript} + left={left} + top={top} + width={width} + height={height} + toTimeline={this.toTimeline} + layoutDoc={this.layoutDoc} + currentTimecode={this.currentTimecode} + _timeline={this._timeline} + stackedTimeline={this} + trimStart={this.trimStart} + trimEnd={this.trimEnd} + /> + </div> + ); + })} + {!this.props.trimming && this.selectionContainer} + {this.renderAudioWaveform} + {this.renderDictation} + + <div + className="collectionStackedTimeline-current" + style={{ + left: this.props.trimming + ? `${(this.currentTime / this.duration) * 100}%` + : `${(this.currentTime - this.trimStart) / (this.trimEnd - this.trimStart) * 100}%`, + }} + /> + + {this.props.trimming && ( + <> + <div + className="collectionStackedTimeline-trim-shade" + style={{ width: `${(this.trimStart / this.duration) * 100}%` }} + ></div> + + <div + className="collectionStackedTimeline-trim-controls" + style={{ + left: `${(this.trimStart / this.duration) * 100}%`, + width: `${((this.trimEnd - this.trimStart) / this.duration) * 100 + }%`, + }} + > + <div + className="collectionStackedTimeline-trim-handle" + onPointerDown={this.trimLeft} + ></div> + <div + className="collectionStackedTimeline-trim-handle" + onPointerDown={this.trimRight} + ></div> + </div> + + <div + className="collectionStackedTimeline-trim-shade" + style={{ + left: `${(this.trimEnd / this.duration) * 100}%`, + width: `${((this.duration - this.trimEnd) / this.duration) * 100 + }%`, + }} + ></div> + </> + )} + </div> + ); } } @@ -335,6 +662,8 @@ interface StackedTimelineAnchorProps { currentTimecode: () => number; isSelected: (outsideReaction?: boolean) => boolean; stackedTimeline: CollectionStackedTimeline; + trimStart: number; + trimEnd: number; } @observer class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> { @@ -345,22 +674,41 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> this._lastTimecode = this.props.currentTimecode(); } componentDidMount() { - this._disposer = reaction(() => this.props.currentTimecode(), + this._disposer = reaction( + () => this.props.currentTimecode(), (time) => { - const dictationDoc = Cast(this.props.layoutDoc["data-dictation"], Doc, null); - const isDictation = dictationDoc && DocListCast(this.props.mark.links).some(link => Cast(link.anchor1, Doc, null)?.annotationOn === dictationDoc); - if (!LightboxView.LightboxDoc + const dictationDoc = Cast( + this.props.layoutDoc["data-dictation"], + Doc, + null + ); + const isDictation = + dictationDoc && + DocListCast(this.props.mark.links).some( + (link) => + Cast(link.anchor1, Doc, null)?.annotationOn === dictationDoc + ); + if ( + !LightboxView.LightboxDoc && // bcz: when should links be followed? we don't want to move away from the video to follow a link but we can open it in a sidebar/etc. But we don't know that upfront. // for now, we won't follow any links when the lightbox is oepn to avoid "losing" the video. /*(isDictation || !Doc.AreProtosEqual(LightboxView.LightboxDoc, this.props.layoutDoc))*/ - && DocListCast(this.props.mark.links).length && + DocListCast(this.props.mark.links).length && time > NumCast(this.props.mark[this.props.startTag]) && time < NumCast(this.props.mark[this.props.endTag]) && - this._lastTimecode < NumCast(this.props.mark[this.props.startTag])) { - LinkManager.FollowLink(undefined, this.props.mark, this.props as any as DocumentViewProps, false, true); + this._lastTimecode < NumCast(this.props.mark[this.props.startTag]) + ) { + LinkManager.FollowLink( + undefined, + this.props.mark, + this.props as any as DocumentViewProps, + false, + true + ); } this._lastTimecode = time; - }); + } + ); } componentWillUnmount() { this._disposer?.(); @@ -373,57 +721,134 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> return this.props.toTimeline(e.clientX - rect.x, rect.width); }; const changeAnchor = (anchor: Doc, left: boolean, time: number) => { - const timelineOnly = Cast(anchor[this.props.startTag], "number", null) !== undefined; - if (timelineOnly) Doc.SetInPlace(anchor, left ? this.props.startTag : this.props.endTag, time, true); - else left ? anchor._timecodeToShow = time : anchor._timecodeToHide = time; + const timelineOnly = + Cast(anchor[this.props.startTag], "number", null) !== undefined; + if (timelineOnly) + Doc.SetInPlace( + anchor, + left ? this.props.startTag : this.props.endTag, + time, + true + ); + else + left + ? (anchor._timecodeToShow = time) + : (anchor._timecodeToHide = time); return false; }; - setupMoveUpEvents(this, e, + setupMoveUpEvents( + this, + e, (e) => changeAnchor(anchor, left, newTime(e)), (e) => { this.props.setTime(newTime(e)); this.props._timeline?.releasePointerCapture(e.pointerId); }, - emptyFunction); + emptyFunction + ); + }; + + @action + computeTitle = () => { + const start = Math.max(NumCast(this.props.mark[this.props.startTag]), this.props.trimStart) - this.props.trimStart; + const end = Math.min(NumCast(this.props.mark[this.props.endTag]), this.props.trimEnd) - this.props.trimStart; + return `#${formatTime(start)}-${formatTime(end)}` } - renderInner = computedFn(function (this: StackedTimelineAnchor, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { + + renderInner = computedFn(function ( + this: StackedTimelineAnchor, + mark: Doc, + script: undefined | (() => ScriptField), + doublescript: undefined | (() => ScriptField), + x: number, + y: number, + width: number, + height: number + ) { const anchor = observable({ view: undefined as any }); - const focusFunc = (doc: Doc, willZoom?: boolean, scale?: number, afterFocus?: DocAfterFocusFunc, docTransform?: Transform) => { + const focusFunc = ( + doc: Doc, + willZoom?: boolean, + scale?: number, + afterFocus?: DocAfterFocusFunc, + docTransform?: Transform + ) => { this.props.playLink(mark); this.props.focus(doc, { willZoom, scale, afterFocus, docTransform }); }; return { - anchor, view: <DocumentView key="view" {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} - ref={action((r: DocumentView | null) => anchor.view = r)} - Document={mark} - DataDoc={undefined} - renderDepth={this.props.renderDepth + 1} - LayoutTemplate={undefined} - LayoutTemplateString={LabelBox.LayoutString("data")} - isDocumentActive={returnFalse} - PanelWidth={() => width} - PanelHeight={() => height} - ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(-x, -y)} - focus={focusFunc} - rootSelected={returnFalse} - onClick={script} - onDoubleClick={this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript} - ignoreAutoHeight={false} - hideResizeHandles={true} - bringToFront={emptyFunction} - scriptContext={this.props.stackedTimeline} /> + anchor, + view: ( + <DocumentView + key="view" + {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} + ref={action((r: DocumentView | null) => (anchor.view = r))} + Document={mark} + DataDoc={undefined} + renderDepth={this.props.renderDepth + 1} + LayoutTemplate={undefined} + LayoutTemplateString={LabelBox.LayoutStringWithTitle(LabelBox, "data", this.computeTitle())} + isDocumentActive={returnFalse} + PanelWidth={() => width} + PanelHeight={() => height} + ScreenToLocalTransform={() => + this.props.ScreenToLocalTransform().translate(-x, -y) + } + focus={focusFunc} + rootSelected={returnFalse} + onClick={script} + onDoubleClick={ + this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript + } + ignoreAutoHeight={false} + hideResizeHandles={true} + bringToFront={emptyFunction} + scriptContext={this.props.stackedTimeline} + /> + ), }; }); + render() { - const inner = this.renderInner(this.props.mark, this.props.rangeClickScript, this.props.rangePlayScript, this.props.left, this.props.top, this.props.width, this.props.height); - return <> - {inner.view} - {!inner.anchor.view || !SelectionManager.IsSelected(inner.anchor.view) ? (null) : - <> - <div key="left" className="collectionStackedTimeline-left-resizer" onPointerDown={e => this.onAnchorDown(e, this.props.mark, true)} /> - <div key="right" className="collectionStackedTimeline-resizer" onPointerDown={e => this.onAnchorDown(e, this.props.mark, false)} /> - </>} - </>; + const inner = this.renderInner( + this.props.mark, + this.props.rangeClickScript, + this.props.rangePlayScript, + this.props.left, + this.props.top, + this.props.width, + this.props.height + ); + return ( + <> + {inner.view} + {!inner.anchor.view || + !SelectionManager.IsSelected(inner.anchor.view) ? null : ( + <> + <div + key="left" + className="collectionStackedTimeline-left-resizer" + onPointerDown={(e) => this.onAnchorDown(e, this.props.mark, true)} + /> + <div + key="right" + className="collectionStackedTimeline-resizer" + onPointerDown={(e) => + this.onAnchorDown(e, this.props.mark, false) + } + /> + </> + )} + </> + ); } } -Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); });
\ No newline at end of file +Scripting.addGlobal(function formatToTime(time: number): any { + return formatTime(time); +}); +Scripting.addGlobal(function min(num1: number, num2: number): number { + return Math.min(num1, num2); +}); +Scripting.addGlobal(function max(num1: number, num2: number): number { + return Math.max(num1, num2); +});
\ No newline at end of file diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 3fcb024df..ac2b19fd6 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -1,188 +1,204 @@ +@import "../global/globalCssVariables.scss"; + + .audiobox-container, .audiobox-container-interactive { + width: 100%; + height: 100%; + position: inherit; + display: flex; + position: relative; + cursor: default; + + .audiobox-buttons { + display: flex; width: 100%; + align-items: center; height: 100%; - position: inherit; - display: flex; - position: relative; - cursor: default; - .audiobox-buttons { - display: flex; - width: 100%; - align-items: center; - height: 100%; - - .audiobox-dictation { - position: relative; - width: 30px; - height: 100%; - align-items: center; - display: inherit; - background: dimgray; - left: 0px; - &:hover { - color: white; - cursor: pointer; - } - } + .audiobox-dictation { + position: relative; + width: 30px; + height: 100%; + align-items: center; + display: inherit; + background: $medium-gray; + left: 0px; + color: $dark-gray; + &:hover { + color: $black; + cursor: pointer; + } } + } - .audiobox-control, - .audiobox-control-interactive { - top: 0; - max-height: 32px; - width: 100%; - display: inline-block; - pointer-events: none; - } + .audiobox-control, + .audiobox-control-interactive { + top: 0; + max-height: 32px; + width: 100%; + display: inline-block; + pointer-events: none; + } + + .audiobox-control-interactive { + pointer-events: all; + } - .audiobox-control-interactive { - pointer-events: all; + .audiobox-record-interactive, + .audiobox-record { + pointer-events: all; + width: 100%; + height: 100%; + position: relative; + } + + .audiobox-record { + pointer-events: none; + } + + .recording { + margin-top: auto; + margin-bottom: auto; + width: 100%; + height: 100%; + position: relative; + padding-right: 5px; + display: flex; + background-color: $medium-blue; + + .time { + position: relative; + height: 100%; + width: 100%; + font-size: $large-header; + text-align: center; + top: 5; } - .audiobox-record-interactive, - .audiobox-record { - pointer-events: all; - width: 100%; - height: 100%; - position: relative; + .buttons { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + width: 25px; + padding: 5px; + color: $dark-gray; + &:hover { + color: $black; + } } + } - .audiobox-record { - pointer-events: none; + .audiobox-controls { + width: 100%; + height: 100%; + position: relative; + display: flex; + background: $dark-gray; + + .audiobox-dictation { + position: absolute; + width: 40px; + height: 100%; + align-items: center; + display: inherit; + background: $medium-gray; + left: 0px; } - .recording { + .audiobox-player { + margin-top: auto; + margin-bottom: auto; + width: 100%; + position: relative; + padding-right: 5px; + display: flex; + flex-direction: column; + justify-content: center; + + .audiobox-buttons { + position: relative; margin-top: auto; margin-bottom: auto; - width: 100%; - height: 100%; - position: relative; - padding-right: 5px; + width: 30px; + height: 30px; + border-radius: 50%; + background-color: $dark-gray; + color: $white; display: flex; - background-color: red; - - .time { - position: relative; - height: 100%; - width: 100%; - font-size: 20; - text-align: center; - top: 5; + align-items: center; + justify-content: center; + left: 5px; + &:hover { + background-color: $black; } - .buttons { - position: relative; - margin-top: auto; - margin-bottom: auto; - width: 25px; - padding: 5px; - &:hover{ - background-color: crimson; - } + svg { + width: 100%; + position: absolute; + border-width: "thin"; + border-color: "white"; } - } + } - .audiobox-controls { - width: 100%; - height: 100%; + .audiobox-dictation { position: relative; - display: flex; - padding-left: 2px; - background: black; - - .audiobox-dictation { - position: absolute; - width: 30px; - height: 100%; - align-items: center; - display: inherit; - background: dimgray; - left: 0px; - } + margin-top: auto; + margin-bottom: auto; + width: 25px; + align-items: center; + display: inherit; + background: $medium-gray; + } - .audiobox-player { - margin-top: auto; - margin-bottom: auto; - width: 100%; - position: relative; - padding-right: 5px; - display: flex; - - .audiobox-playhead { - position: relative; - margin-top: auto; - margin-bottom: auto; - margin-right: 2px; - height: 25px; - padding: 2px; - border-radius: 50%; - background-color: black; - color: white; - &:hover { - background-color: grey; - color: lightgrey; - } - } - - .audiobox-dictation { - position: relative; - margin-top: auto; - margin-bottom: auto; - width: 25px; - padding: 2px; - align-items: center; - display: inherit; - background: dimgray; - } - - .audiobox-timeline { - position: absolute; - width: 100%; - border: gray solid 1px; - border-radius: 3px; - z-index: 1000; - overflow: hidden; - } - - .audioBox-total-time, - .audioBox-current-time { - position: absolute; - font-size: 8; - top: 100%; - color: white; - } - .audioBox-current-time { - left: 30px; - } - - .audioBox-total-time { - right: 2px; - } - } + .audiobox-timeline { + position: absolute; + width: 100%; + z-index: 1000; + overflow: hidden; + border-right: 5px solid black; + } + + .audioBox-total-time, + .audioBox-current-time { + position: absolute; + font-size: $small-text; + top: 100%; + color: $white; + } + .audioBox-current-time { + left: 42px; + } + + .audioBox-total-time { + right: 2px; + } } + } } - @media only screen and (max-device-width: 480px) { - .audiobox-dictation { - font-size: 5em; - display: flex; - width: 100; - justify-content: center; - flex-direction: column; - align-items: center; - } - - .audiobox-container .audiobox-record, - .audiobox-container-interactive .audiobox-record { - font-size: 3em; - } - - .audiobox-container .audiobox-controls .audiobox-player .audiobox-playhead, - .audiobox-container .audiobox-controls .audiobox-player .audiobox-dictation, - .audiobox-container-interactive .audiobox-controls .audiobox-player .audiobox-playhead { - width: 70px; - } -}
\ No newline at end of file + .audiobox-dictation { + font-size: 5em; + display: flex; + width: 100; + justify-content: center; + flex-direction: column; + align-items: center; + } + + .audiobox-container .audiobox-record, + .audiobox-container-interactive .audiobox-record { + font-size: 3em; + } + + .audiobox-container .audiobox-controls .audiobox-player .audiobox-buttons, + .audiobox-container .audiobox-controls .audiobox-player .audiobox-dictation, + .audiobox-container-interactive + .audiobox-controls + .audiobox-player + .audiobox-buttons { + width: 70px; + } +} diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 60c655958..a3f03fc4b 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -1,6 +1,13 @@ import React = require("react"); import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { + action, + computed, + IReactionDisposer, + observable, + reaction, + runInAction, +} from "mobx"; import { observer } from "mobx-react"; import { DateField } from "../../../fields/DateField"; import { Doc, DocListCast, Opt } from "../../../fields/Doc"; @@ -17,23 +24,34 @@ import { SnappingManager } from "../../util/SnappingManager"; import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent"; +import { + ViewBoxAnnotatableComponent, + ViewBoxAnnotatableProps, +} from "../DocComponent"; import "./AudioBox.scss"; -import { FieldView, FieldViewProps } from './FieldView'; +import { FieldView, FieldViewProps } from "./FieldView"; import { LinkDocPreview } from "./LinkDocPreview"; +import { faLessThan } from "@fortawesome/free-solid-svg-icons"; +import { Colors } from "../global/globalEnums"; + declare class MediaRecorder { - constructor(e: any); // whatever MediaRecorder has + constructor(e: any); // whatever MediaRecorder has } type AudioDocument = makeInterface<[typeof documentSchema]>; const AudioDocument = makeInterface(documentSchema); @observer -export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps, AudioDocument>(AudioDocument) { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(AudioBox, fieldKey); } +export class AudioBox extends ViewBoxAnnotatableComponent< + ViewBoxAnnotatableProps & FieldViewProps, + AudioDocument +>(AudioDocument) { + public static LayoutString(fieldKey: string) { + return FieldView.LayoutString(AudioBox, fieldKey); + } public static Enabled = false; - static playheadWidth = 30; // width of playhead - static heightPercent = 80; // height of timeline in percent of height of audioBox. + static playheadWidth = 40; // width of playhead + static heightPercent = 75; // height of timeline in percent of height of audioBox. static Instance: AudioBox; _disposers: { [name: string]: IReactionDisposer } = {}; @@ -47,35 +65,82 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp _stream: MediaStream | undefined; _start: number = 0; _play: any = null; + _ended: boolean = false; @observable static _scrubTime = 0; @observable _markerEnd: number = 0; @observable _position: number = 0; @observable _waveHeight: Opt<number> = this.layoutDoc._height; @observable _paused: boolean = false; - @computed get mediaState(): undefined | "pendingRecording" | "recording" | "paused" | "playing" { return this.dataDoc.mediaState as (undefined | "pendingRecording" | "recording" | "paused" | "playing"); } - set mediaState(value) { this.dataDoc.mediaState = value; } - public static SetScrubTime = action((timeInMillisFrom1970: number) => { AudioBox._scrubTime = 0; AudioBox._scrubTime = timeInMillisFrom1970; }); - @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } - @computed get duration() { return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); } - @computed get anchorDocs() { return DocListCast(this.dataDoc[this.annotationKey]); } - @computed get links() { return DocListCast(this.dataDoc.links); } - @computed get pauseTime() { return this._pauseEnd - this._pauseStart; } // total time paused to update the correct time - @computed get heightPercent() { return AudioBox.heightPercent; } + @observable _trimming: boolean = false; + @observable _trimStart: number = NumCast(this.layoutDoc.clipStart) ? NumCast(this.layoutDoc.clipStart) : 0; + @observable _trimEnd: number = NumCast(this.layoutDoc.clipEnd) ? NumCast(this.layoutDoc.clipEnd) + : this.duration; + + @computed get mediaState(): + | undefined + | "pendingRecording" + | "recording" + | "paused" + | "playing" { + return this.dataDoc.mediaState as + | undefined + | "pendingRecording" + | "recording" + | "paused" + | "playing"; + } + set mediaState(value) { + this.dataDoc.mediaState = value; + } + public static SetScrubTime = action((timeInMillisFrom1970: number) => { + AudioBox._scrubTime = 0; + AudioBox._scrubTime = timeInMillisFrom1970; + }); + @computed get recordingStart() { + return Cast( + this.dataDoc[this.props.fieldKey + "-recordingStart"], + DateField + )?.date.getTime(); + } + @computed get duration() { + return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); + } + @computed get trimDuration() { + return this._trimming && this._trimEnd ? this.duration : this._trimEnd - this._trimStart; + } + @computed get anchorDocs() { + return DocListCast(this.dataDoc[this.annotationKey]); + } + @computed get links() { + return DocListCast(this.dataDoc.links); + } + @computed get pauseTime() { + return this._pauseEnd - this._pauseStart; + } // total time paused to update the correct time + @computed get heightPercent() { + return AudioBox.heightPercent; + } constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) { super(props); AudioBox.Instance = this; if (this.duration === undefined) { - runInAction(() => this.Document[this.fieldKey + "-duration"] = this.Document.duration); + runInAction( + () => + (this.Document[this.fieldKey + "-duration"] = this.Document.duration) + ); } } getLinkData(l: Doc) { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; - const linkTime = this._stackedTimeline.current?.anchorStart(la2) || this._stackedTimeline.current?.anchorStart(la1) || 0; + const linkTime = + this._stackedTimeline.current?.anchorStart(la2) || + this._stackedTimeline.current?.anchorStart(la1) || + 0; if (Doc.AreProtosEqual(la1, this.dataDoc)) { la1 = l.anchor2 as Doc; la2 = l.anchor1 as Doc; @@ -84,16 +149,26 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } getAnchor = () => { - return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, - "_timecodeToShow" /* audioStart */, "_timecodeToHide" /* audioEnd */, this._ele?.currentTime || - Cast(this.props.Document._currentTimecode, "number", null) || (this.mediaState === "recording" ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined)) - || this.rootDoc; - } + return ( + CollectionStackedTimeline.createAnchor( + this.rootDoc, + this.dataDoc, + this.annotationKey, + "_timecodeToShow" /* audioStart */, + "_timecodeToHide" /* audioEnd */, + this._ele?.currentTime || + Cast(this.props.Document._currentTimecode, "number", null) || + (this.mediaState === "recording" + ? (Date.now() - (this.recordingStart || 0)) / 1000 + : undefined) + ) || this.rootDoc + ); + }; componentWillUnmount() { - Object.values(this._disposers).forEach(disposer => disposer?.()); + Object.values(this._disposers).forEach((disposer) => disposer?.()); const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); + ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); } @action @@ -102,41 +177,70 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this.mediaState = this.path ? "paused" : undefined; + this.layoutDoc.clipStart = this.layoutDoc.clipStart ? this.layoutDoc.clipStart : 0; + this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? this.layoutDoc.clipEnd : this.duration ? this.duration : undefined; + + this.path && this.setAnchorTime(NumCast(this.layoutDoc.clipStart)); + this.path && this.timecodeChanged(); + this._disposers.triggerAudio = reaction( - () => !LinkDocPreview.LinkInfo && this.props.renderDepth !== -1 ? NumCast(this.Document._triggerAudio, null) : undefined, - start => start !== undefined && setTimeout(() => { - this.playFrom(start); + () => + !LinkDocPreview.LinkInfo && this.props.renderDepth !== -1 + ? NumCast(this.Document._triggerAudio, null) + : undefined, + (start) => + start !== undefined && setTimeout(() => { - this.Document._currentTimecode = start; - this.Document._triggerAudio = undefined; - }, 10); - }), // wait for mainCont and try again to play + this.playFrom(start); + setTimeout(() => { + this.Document._currentTimecode = start; + this.Document._triggerAudio = undefined; + }, 10); + }), // wait for mainCont and try again to play { fireImmediately: true } ); this._disposers.audioStop = reaction( - () => this.props.renderDepth !== -1 && !LinkDocPreview.LinkInfo ? Cast(this.Document._audioStop, "number", null) : undefined, - audioStop => audioStop !== undefined && setTimeout(() => { - this.Pause(); - setTimeout(() => this.Document._audioStop = undefined, 10); - }), // wait for mainCont and try again to play + () => + this.props.renderDepth !== -1 && !LinkDocPreview.LinkInfo + ? Cast(this.Document._audioStop, "number", null) + : undefined, + (audioStop) => + audioStop !== undefined && + setTimeout(() => { + this.Pause(); + setTimeout(() => (this.Document._audioStop = undefined), 10); + }), // wait for mainCont and try again to play { fireImmediately: true } ); } // for updating the timecode + @action timecodeChanged = () => { const htmlEle = this._ele; if (this.mediaState !== "recording" && htmlEle) { - htmlEle.duration && htmlEle.duration !== Infinity && runInAction(() => this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration); - this.links.map(l => this.getLinkData(l)).forEach(({ la1, la2, linkTime }) => { - if (linkTime > NumCast(this.layoutDoc._currentTimecode) && linkTime < htmlEle.currentTime) { - Doc.linkFollowHighlight(la1); - } - }); + htmlEle.duration && + htmlEle.duration !== Infinity && + runInAction( + () => (this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration) + ); + this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? Math.min(this.duration, NumCast(this.layoutDoc.clipEnd)) : this.duration; + this._trimEnd = this._trimEnd ? Math.min(this.duration, this._trimEnd) : this.duration; + this.links + .map((l) => this.getLinkData(l)) + .forEach(({ la1, la2, linkTime }) => { + if ( + linkTime > NumCast(this.layoutDoc._currentTimecode) && + linkTime < htmlEle.currentTime + ) { + Doc.linkFollowHighlight(la1); + } + }); this.layoutDoc._currentTimecode = htmlEle.currentTime; + } - } + }; // pause play back Pause = action(() => { @@ -146,12 +250,13 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp // play audio for documents created during recording playFromTime = (absoluteTime: number) => { - this.recordingStart && this.playFrom((absoluteTime - this.recordingStart) / 1000); - } + this.recordingStart && + this.playFrom((absoluteTime - this.recordingStart) / 1000); + }; // play back the audio from time @action - playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => { + playFrom = (seekTimeInSeconds: number, endTime: number = this._trimEnd, fullPlay: boolean = false) => { clearTimeout(this._play); if (Number.isNaN(this._ele?.duration)) { setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); @@ -162,18 +267,26 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } else { this.Pause(); } - } else if (seekTimeInSeconds <= this._ele.duration) { - this._ele.currentTime = seekTimeInSeconds; + } else if (this._trimStart <= endTime && seekTimeInSeconds <= this._trimEnd) { + const start = Math.max(this._trimStart, seekTimeInSeconds); + const end = Math.min(this._trimEnd, endTime); + this._ele.currentTime = start; this._ele.play(); - runInAction(() => this.mediaState = "playing"); + runInAction(() => (this.mediaState = "playing")); if (endTime !== this.duration) { - this._play = setTimeout(() => this.Pause(), (endTime - seekTimeInSeconds) * 1000); // use setTimeout to play a specific duration + this._play = setTimeout( + () => { + this._ended = fullPlay ? true : this._ended; + this.Pause() + }, + (end - start) * 1000 + ); // use setTimeout to play a specific duration } } else { this.Pause(); } } - } + }; // update the recording time updateRecordTime = () => { @@ -182,16 +295,19 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp if (this._paused) { this._pausedTime += (new Date().getTime() - this._recordStart) / 1000; } else { - this.layoutDoc._currentTimecode = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + this.layoutDoc._currentTimecode = + (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; } } - } + }; // starts recording recordAudioAnnotation = async () => { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this._recorder = new MediaRecorder(this._stream); - this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField(new Date()); + this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField( + new Date() + ); DocUtils.ActiveRecordings.push(this); this._recorder.ondataavailable = async (e: any) => { const [{ result }] = await Networking.UploadFilesToServer(e.data); @@ -200,30 +316,58 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } }; this._recordStart = new Date().getTime(); - runInAction(() => this.mediaState = "recording"); + runInAction(() => (this.mediaState = "recording")); setTimeout(this.updateRecordTime, 0); this._recorder.start(); setTimeout(() => this._recorder && this.stopRecording(), 60 * 60 * 1000); // stop after an hour - } + }; // context menu specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ description: (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors", event: () => this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors, icon: "expand-arrows-alt" }); - funcs.push({ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", event: () => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, icon: "expand-arrows-alt" }); - funcs.push({ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt" }); - ContextMenu.Instance?.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); - } + funcs.push({ + description: + (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors", + event: () => (this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors), + icon: "expand-arrows-alt", + }); + funcs.push({ + description: + (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + + " play when link is selected", + event: () => + (this.layoutDoc.dontAutoPlayFollowedLinks = + !this.layoutDoc.dontAutoPlayFollowedLinks), + icon: "expand-arrows-alt", + }); + funcs.push({ + description: + (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + + " anchors onClick", + event: () => + (this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors), + icon: "expand-arrows-alt", + }); + ContextMenu.Instance?.addItem({ + description: "Options...", + subitems: funcs, + icon: "asterisk", + }); + }; // stops the recording stopRecording = action(() => { this._recorder.stop(); this._recorder = undefined; - this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + this.dataDoc[this.fieldKey + "-duration"] = + (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; this.mediaState = "paused"; + this._trimEnd = this.duration; + this.layoutDoc.clipStart = 0; + this.layoutDoc.clipEnd = this.duration; this._stream?.getAudioTracks()[0].stop(); const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); + ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); }); // button for starting and stopping the recording @@ -232,45 +376,71 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._recorder ? this.stopRecording() : this.recordAudioAnnotation(); e.stopPropagation(); } - } + }; // for play button Play = (e?: any) => { - this.playFrom(this._ele!.paused ? this._ele!.currentTime : -1); + let start; + if (this._ended || this._ele!.currentTime == this.duration) { + start = this._trimStart; + this._ended = false; + } + else { + start = this._ele!.currentTime; + } + + this.playFrom(start, this._trimEnd, true); e?.stopPropagation?.(); - } + }; // creates a text document for dictation onFile = (e: any) => { - const newDoc = CurrentUserUtils.GetNewTextDoc("", NumCast(this.props.Document.x), NumCast(this.props.Document.y) + NumCast(this.props.Document._height) + 10, - NumCast(this.props.Document._width), 2 * NumCast(this.props.Document._height)); + const newDoc = CurrentUserUtils.GetNewTextDoc( + "", + NumCast(this.props.Document.x), + NumCast(this.props.Document.y) + + NumCast(this.props.Document._height) + + 10, + NumCast(this.props.Document._width), + 2 * NumCast(this.props.Document._height) + ); Doc.GetProto(newDoc).recordingSource = this.dataDoc; - Doc.GetProto(newDoc).recordingStart = ComputedField.MakeFunction(`self.recordingSource["${this.props.fieldKey}-recordingStart"]`); - Doc.GetProto(newDoc).mediaState = ComputedField.MakeFunction("self.recordingSource.mediaState"); + Doc.GetProto(newDoc).recordingStart = ComputedField.MakeFunction( + `self.recordingSource["${this.props.fieldKey}-recordingStart"]` + ); + Doc.GetProto(newDoc).mediaState = ComputedField.MakeFunction( + "self.recordingSource.mediaState" + ); this.props.addDocument?.(newDoc); e.stopPropagation(); - } + }; // ref for updating time setRef = (e: HTMLAudioElement | null) => { e?.addEventListener("timeupdate", this.timecodeChanged); e?.addEventListener("ended", this.Pause); this._ele = e; - } + }; // returns the path of the audio file @computed get path() { const field = Cast(this.props.Document[this.props.fieldKey], AudioField); - const path = (field instanceof AudioField) ? field.url.href : ""; + const path = field instanceof AudioField ? field.url.href : ""; return path === nullAudio ? "" : path; } // returns the html audio element @computed get audio() { - return <audio ref={this.setRef} className={`audiobox-control${this.isContentActive() ? "-interactive" : ""}`}> - <source src={this.path} type="audio/mpeg" /> - Not supported. - </audio>; + return ( + <audio + ref={this.setRef} + className={`audiobox-control${this.isContentActive() ? "-interactive" : "" + }`} + > + <source src={this.path} type="audio/mpeg" /> + Not supported. + </audio> + ); } // pause the time during recording phase @@ -280,7 +450,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._paused = true; this._recorder.pause(); e.stopPropagation(); - } + }; // continue the recording @action @@ -289,104 +459,259 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._paused = false; this._recorder.resume(); e.stopPropagation(); - } + }; playing = () => this.mediaState === "playing"; playLink = (link: Doc) => { const stack = this._stackedTimeline.current; if (link.annotationOn === this.rootDoc) { - if (!this.layoutDoc.dontAutoPlayFollowedLinks) this.playFrom(stack?.anchorStart(link) || 0, stack?.anchorEnd(link)); - else this._ele!.currentTime = this.layoutDoc._currentTimecode = (stack?.anchorStart(link) || 0); + if (!this.layoutDoc.dontAutoPlayFollowedLinks) + this.playFrom(stack?.anchorStart(link) || 0, stack?.anchorEnd(link)); + else + this._ele!.currentTime = this.layoutDoc._currentTimecode = + stack?.anchorStart(link) || 0; + } else { + this.links + .filter((l) => l.anchor1 === link || l.anchor2 === link) + .forEach((l) => { + const { la1, la2 } = this.getLinkData(l); + const startTime = stack?.anchorStart(la1) || stack?.anchorStart(la2); + const endTime = stack?.anchorEnd(la1) || stack?.anchorEnd(la2); + if (startTime !== undefined) { + if (!this.layoutDoc.dontAutoPlayFollowedLinks) + endTime + ? this.playFrom(startTime, endTime) + : this.playFrom(startTime); + else + this._ele!.currentTime = this.layoutDoc._currentTimecode = + startTime; + } + }); } - else { - this.links.filter(l => l.anchor1 === link || l.anchor2 === link).forEach(l => { - const { la1, la2 } = this.getLinkData(l); - const startTime = stack?.anchorStart(la1) || stack?.anchorStart(la2); - const endTime = stack?.anchorEnd(la1) || stack?.anchorEnd(la2); - if (startTime !== undefined) { - if (!this.layoutDoc.dontAutoPlayFollowedLinks) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); - else this._ele!.currentTime = this.layoutDoc._currentTimecode = startTime; - } - }); + }; + + // shows trim controls + @action + startTrim = () => { + if (!this.duration) { + this.timecodeChanged(); + } + if (this.mediaState === "playing") { + this.Pause(); + } + this._trimming = true; + }; + + // hides trim controls and displays new clip + @action + finishTrim = () => { + if (this.mediaState === "playing") { + this.Pause(); } + this.layoutDoc.clipStart = this._trimStart; + this.layoutDoc.clipEnd = this._trimEnd; + this._trimming = false; + this.setAnchorTime(Math.max(Math.min(this._trimEnd, this._ele!.currentTime), this._trimStart)); + }; + + @action + setStartTrim = (newStart: number) => { + this._trimStart = newStart; + } + + @action + setEndTrim = (newEnd: number) => { + this._trimEnd = newEnd; } isActiveChild = () => this._isAnyChildContentActive; - timelineWhenChildContentsActiveChanged = (isActive: boolean) => this.props.whenChildContentsActiveChanged(runInAction(() => this._isAnyChildContentActive = isActive)); - timelineScreenToLocal = () => this.props.ScreenToLocalTransform().translate(-AudioBox.playheadWidth, -(100 - this.heightPercent) / 200 * this.props.PanelHeight()); - setAnchorTime = (time: number) => this._ele!.currentTime = this.layoutDoc._currentTimecode = time; - timelineHeight = () => this.props.PanelHeight() * this.heightPercent / 100 * this.heightPercent / 100; // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) + timelineWhenChildContentsActiveChanged = (isActive: boolean) => + this.props.whenChildContentsActiveChanged( + runInAction(() => (this._isAnyChildContentActive = isActive)) + ); + timelineScreenToLocal = () => + this.props + .ScreenToLocalTransform() + .translate( + -AudioBox.playheadWidth, + (-(100 - this.heightPercent) / 200) * this.props.PanelHeight() + ); + setAnchorTime = (time: number) => { + (this._ele!.currentTime = this.layoutDoc._currentTimecode = time); + } + + timelineHeight = () => + (((this.props.PanelHeight() * this.heightPercent) / 100) * + this.heightPercent) / + 100; // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) timelineWidth = () => this.props.PanelWidth() - AudioBox.playheadWidth; @computed get renderTimeline() { - return <CollectionStackedTimeline ref={this._stackedTimeline} {...this.props} - fieldKey={this.annotationKey} - dictationKey={this.fieldKey + "-dictation"} - mediaPath={this.path} - renderDepth={this.props.renderDepth + 1} - startTag={"_timecodeToShow" /* audioStart */} - endTag={"_timecodeToHide" /* audioEnd */} - focus={DocUtils.DefaultFocus} - bringToFront={emptyFunction} - CollectionView={undefined} - duration={this.duration} - playFrom={this.playFrom} - setTime={this.setAnchorTime} - playing={this.playing} - whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} - removeDocument={this.removeDocument} - ScreenToLocalTransform={this.timelineScreenToLocal} - Play={this.Play} - Pause={this.Pause} - isContentActive={this.isContentActive} - playLink={this.playLink} - PanelWidth={this.timelineWidth} - PanelHeight={this.timelineHeight} - />; + return ( + <CollectionStackedTimeline + ref={this._stackedTimeline} + {...this.props} + fieldKey={this.annotationKey} + dictationKey={this.fieldKey + "-dictation"} + mediaPath={this.path} + renderDepth={this.props.renderDepth + 1} + startTag={"_timecodeToShow" /* audioStart */} + endTag={"_timecodeToHide" /* audioEnd */} + focus={DocUtils.DefaultFocus} + bringToFront={emptyFunction} + CollectionView={undefined} + duration={this.duration} + playFrom={this.playFrom} + setTime={this.setAnchorTime} + playing={this.playing} + whenChildContentsActiveChanged={ + this.timelineWhenChildContentsActiveChanged + } + removeDocument={this.removeDocument} + ScreenToLocalTransform={this.timelineScreenToLocal} + Play={this.Play} + Pause={this.Pause} + isContentActive={this.isContentActive} + playLink={this.playLink} + PanelWidth={this.timelineWidth} + PanelHeight={this.timelineHeight} + trimming={this._trimming} + trimStart={this._trimStart} + trimEnd={this._trimEnd} + trimDuration={this.trimDuration} + setStartTrim={this.setStartTrim} + setEndTrim={this.setEndTrim} + /> + ); } render() { - const interactive = SnappingManager.GetIsDragging() || this.isContentActive() ? "-interactive" : ""; - return <div className="audiobox-container" - onContextMenu={this.specificContextMenu} - onClick={!this.path && !this._recorder ? this.recordAudioAnnotation : undefined} - style={{ pointerEvents: this.props.layerProvider?.(this.layoutDoc) === false ? "none" : undefined }}> - {!this.path ? - <div className="audiobox-buttons"> - <div className="audiobox-dictation" onClick={this.onFile}> - <FontAwesomeIcon style={{ width: "30px", background: !this.layoutDoc.dontAutoPlayFollowedLinks ? "yellow" : "rgba(0,0,0,0)" }} icon="file-alt" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + const interactive = + SnappingManager.GetIsDragging() || this.isContentActive() + ? "-interactive" + : ""; + return ( + <div + className="audiobox-container" + onContextMenu={this.specificContextMenu} + onClick={ + !this.path && !this._recorder ? this.recordAudioAnnotation : undefined + } + style={{ + pointerEvents: + this.props.layerProvider?.(this.layoutDoc) === false + ? "none" + : undefined, + }} + > + {!this.path ? ( + <div className="audiobox-buttons"> + <div className="audiobox-dictation" onClick={this.onFile}> + <FontAwesomeIcon + style={{ + width: "30px", + background: !this.layoutDoc.dontAutoPlayFollowedLinks + ? Colors.LIGHT_BLUE + : "rgba(0,0,0,0)", + }} + icon="file-alt" + size={this.props.PanelHeight() < 36 ? "1x" : "2x"} + /> + </div> + {this.mediaState === "recording" || this.mediaState === "paused" ? ( + <div className="recording" onClick={(e) => e.stopPropagation()}> + <div className="recording-buttons" onClick={this.recordClick}> + <FontAwesomeIcon + icon={"stop"} + size={this.props.PanelHeight() < 36 ? "1x" : "2x"} + /> + </div> + <div + className="recording-buttons" + onClick={this._paused ? this.recordPlay : this.recordPause} + > + <FontAwesomeIcon + icon={this._paused ? "play" : "pause"} + size={this.props.PanelHeight() < 36 ? "1x" : "2x"} + /> + </div> + <div className="time"> + {formatTime( + Math.round(NumCast(this.layoutDoc._currentTimecode)) + )} + </div> + </div> + ) : ( + <button + className={`audiobox-record${interactive}`} + style={{ backgroundColor: Colors.DARK_GRAY }} + > + RECORD + </button> + )} </div> - {this.mediaState === "recording" || this.mediaState === "paused" ? - <div className="recording" onClick={e => e.stopPropagation()}> - <div className="buttons" onClick={this.recordClick}> - <FontAwesomeIcon icon={"stop"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + ) : ( + <div + className="audiobox-controls" + style={{ + pointerEvents: + this._isAnyChildContentActive || this.isContentActive() + ? "all" + : "none", + }} + > + <div className="audiobox-dictation" /> + <div + className="audiobox-player" + style={{ height: `${AudioBox.heightPercent}%` }} + > + <div + className="audiobox-buttons" + title={this.mediaState === "paused" ? "play" : "pause"} + onClick={this.mediaState === "paused" ? this.Play : this.Pause} + > + {" "} + <FontAwesomeIcon + icon={this.mediaState === "paused" ? "play" : "pause"} + size={"1x"} + /> </div> - <div className="buttons" onClick={this._paused ? this.recordPlay : this.recordPause}> - <FontAwesomeIcon icon={this._paused ? "play" : "pause"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + <div + className="audiobox-buttons" + title={this._trimming ? "finish" : "trim"} + onClick={this._trimming ? this.finishTrim : this.startTrim} + > + <FontAwesomeIcon + icon={this._trimming ? "check" : "cut"} + size={"1x"} + /> + </div> + <div + className="audiobox-timeline" + style={{ + top: 0, + height: `100%`, + left: AudioBox.playheadWidth, + width: `calc(100% - ${AudioBox.playheadWidth}px)`, + background: "white", + }} + > + {this.renderTimeline} + </div> + {this.audio} + <div className="audioBox-current-time"> + {this._trimming ? + formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode))) + : formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this._trimStart)))} + </div> + <div className="audioBox-total-time"> + {this._trimming || !this._trimEnd ? + formatTime(Math.round(NumCast(this.duration))) + : formatTime(Math.round(NumCast(this.trimDuration)))} </div> - <div className="time">{formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))}</div> - </div> - : - <button className={`audiobox-record${interactive}`} style={{ backgroundColor: "black" }}> - RECORD - </button>} - </div> : - <div className="audiobox-controls" style={{ pointerEvents: this._isAnyChildContentActive || this.isContentActive() ? "all" : "none" }} > - <div className="audiobox-dictation" /> - <div className="audiobox-player" style={{ height: `${AudioBox.heightPercent}%` }} > - <div className="audiobox-playhead" style={{ width: AudioBox.playheadWidth }} title={this.mediaState === "paused" ? "play" : "pause"} onClick={this.Play}> <FontAwesomeIcon style={{ width: "100%", position: "absolute", left: "0px", top: "5px", borderWidth: "thin", borderColor: "white" }} icon={this.mediaState === "paused" ? "play" : "pause"} size={"1x"} /></div> - <div className="audiobox-timeline" style={{ top: 0, height: `100%`, left: AudioBox.playheadWidth, width: `calc(100% - ${AudioBox.playheadWidth}px)`, background: "white" }}> - {this.renderTimeline} - </div> - {this.audio} - <div className="audioBox-current-time"> - {formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))} - </div> - <div className="audioBox-total-time"> - {formatTime(Math.round(this.duration))} </div> </div> - </div> - } - </div>; + )} + </div> + ); } -}
\ No newline at end of file +} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index ee3266647..2bd79df64 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -91,6 +91,7 @@ export interface DocComponentView { setFocus?: () => void; fieldKey?: string; annotationKey?: string; + getTitle?: () => string; } export interface DocumentViewSharedProps { renderDepth: number; diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 6a7793ff0..8d665b8a6 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -20,11 +20,26 @@ const LabelSchema = createSchema({}); type LabelDocument = makeInterface<[typeof LabelSchema, typeof documentSchema]>; const LabelDocument = makeInterface(LabelSchema, documentSchema); +export interface LabelBoxProps { + label?: string +} + @observer -export class LabelBox extends ViewBoxBaseComponent<FieldViewProps, LabelDocument>(LabelDocument) { +export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxProps), LabelDocument>(LabelDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LabelBox, fieldKey); } + public static LayoutStringWithTitle(fieldType: { name: string }, fieldStr: string, label: string) { + return `<${fieldType.name} fieldKey={'${fieldStr}'} label={'${label}'} {...props} />`; //e.g., "<ImageBox {...props} fieldKey={"data} />" + } private dropDisposer?: DragManager.DragDropDisposer; + componentDidMount() { + this.props.setContentView?.(this); + } + + getTitle() { + return this.props.label || ""; + } + protected createDropTarget = (ele: HTMLDivElement) => { this.dropDisposer?.(); if (ele) { @@ -65,8 +80,8 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps, LabelDocument render() { const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); const missingParams = params?.filter(p => !this.paramsDoc[p]); - params?.map(p => DocListCast(this.paramsDoc[p])); // bcz: really hacky form of prefetching ... - const label = typeof this.rootDoc[this.fieldKey] === "string" ? StrCast(this.rootDoc[this.fieldKey]) : StrCast(this.rootDoc.title); + params?.map(p => DocListCast(this.paramsDoc[p])); // bcz: really hacky form of prefetching ... + const label = this.props.label ? this.props.label : typeof this.rootDoc[this.fieldKey] === "string" ? StrCast(this.rootDoc[this.fieldKey]) : StrCast(this.rootDoc.title); return ( <div className="labelBox-outerDiv" onMouseLeave={action(() => this._mouseOver = false)} |