From dc919272a21ebdd21cae5986db135d893c42fe2d Mon Sep 17 00:00:00 2001 From: bobzel Date: Tue, 26 Jan 2021 16:47:29 -0500 Subject: refactored out timeline code from video and audio into StackedTimeline component. --- src/client/views/GlobalKeyHandler.ts | 15 +- src/client/views/nodes/AudioBox.scss | 6 +- src/client/views/nodes/AudioBox.tsx | 308 ++++----------------- src/client/views/nodes/StackedTimeline.scss | 374 +++++++++++++++++++++++++ src/client/views/nodes/StackedTimeline.tsx | 335 +++++++++++++++++++++++ src/client/views/nodes/VideoBox.tsx | 411 +++++----------------------- 6 files changed, 833 insertions(+), 616 deletions(-) create mode 100644 src/client/views/nodes/StackedTimeline.scss create mode 100644 src/client/views/nodes/StackedTimeline.tsx (limited to 'src') diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 342cdcd95..a07ba0a77 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -1,3 +1,4 @@ +import { random } from "lodash"; import { action, observable } from "mobx"; import { DateField } from "../../fields/DateField"; import { Doc, DocListCast } from "../../fields/Doc"; @@ -14,6 +15,7 @@ import { DragManager } from "../util/DragManager"; import { GroupManager } from "../util/GroupManager"; import { SelectionManager } from "../util/SelectionManager"; import { SharingManager } from "../util/SharingManager"; +import { SnappingManager } from "../util/SnappingManager"; import { undoBatch, UndoManager } from "../util/UndoManager"; import { CollectionDockingView } from "./collections/CollectionDockingView"; import { CollectionFreeFormViewChrome } from "./collections/CollectionMenu"; @@ -22,13 +24,9 @@ import { DocumentDecorations } from "./DocumentDecorations"; import { InkStrokeProperties } from "./InkStrokeProperties"; import { MainView } from "./MainView"; import { DocumentLinksButton } from "./nodes/DocumentLinksButton"; +import { StackedTimeline } from "./nodes/StackedTimeline"; import { AnchorMenu } from "./pdf/AnchorMenu"; -import { SnappingManager } from "../util/SnappingManager"; import { SearchBox } from "./search/SearchBox"; -import { random } from "lodash"; -import { DocumentView } from "./nodes/DocumentView"; -import { AudioBox } from "./nodes/AudioBox"; -import { VideoBox } from "./nodes/VideoBox"; const modifiers = ["control", "meta", "shift", "alt"]; type KeyHandler = (keycode: string, e: KeyboardEvent) => KeyControlInfo | Promise; @@ -123,11 +121,8 @@ export class KeyManager { DragManager.AbortDrag(); } else if (CollectionDockingView.Instance.HasFullScreen) { CollectionDockingView.Instance.CloseFullScreen(); - } else if (VideoBox.SelectingRegion) { - VideoBox.SelectingRegion = undefined; - doDeselect = false; - } else if (AudioBox.SelectingRegion) { - AudioBox.SelectingRegion = undefined; + } else if (StackedTimeline.SelectingRegion) { + StackedTimeline.SelectingRegion = undefined; doDeselect = false; } else { doDeselect = !ContextMenu.Instance.closeMenu(); diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 4a1cee721..4a3bbf8d8 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -341,7 +341,7 @@ .audiobox-marker { position: relative; - height: calc(100% - 8px); + height: 100%; margin-top: 8px; } } @@ -350,14 +350,14 @@ .current-time { position: absolute; font-size: 8; - top: calc(100% - 8px); + top: 100%; left: 30px; color: white; } .total-time { position: absolute; - top: calc(100% - 8px); + top: 100%; font-size: 8; right: 2px; color: white; diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 0faac44a7..b6f4af8a3 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import axios from "axios"; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { computedFn } from "mobx-utils"; import Waveform from "react-audio-waveform"; import { DateField } from "../../../fields/DateField"; import { Doc, DocListCast, Opt } from "../../../fields/Doc"; @@ -13,24 +12,19 @@ import { createSchema, listSpec, makeInterface } from "../../../fields/Schema"; import { ComputedField, ScriptField } from "../../../fields/ScriptField"; import { Cast, NumCast } from "../../../fields/Types"; import { AudioField, nullAudio } from "../../../fields/URLField"; -import { emptyFunction, formatTime, numberRange, returnFalse, setupMoveUpEvents, Utils, OmitKeys } from "../../../Utils"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { formatTime, numberRange, Utils } from "../../../Utils"; +import { DocUtils } from "../../documents/Documents"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; -import { Scripting } from "../../util/Scripting"; -import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent } from "../DocComponent"; -import { DocumentView } from "./DocumentView"; +import "./AudioBox.scss"; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBoxComment } from "./formattedText/FormattedTextBoxComment"; import { LinkDocPreview } from "./LinkDocPreview"; -import "./AudioBox.scss"; -import { Id } from "../../../fields/FieldSymbols"; -import { LabelBox } from "./LabelBox"; - +import { StackedTimeline } from "./StackedTimeline"; declare class MediaRecorder { // whatever MediaRecorder has constructor(e: any); @@ -48,13 +42,11 @@ export class AudioBox extends ViewBoxAnnotatableComponent(); + _stackedTimeline = React.createRef(); _recorder: any; _recordStart = 0; _pauseStart = 0; @@ -62,16 +54,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent(); - _timeline: Opt; - _markerStart: number = 0; - _currAnchor: Opt; - @observable static SelectingRegion: AudioBox | undefined = undefined; @observable static _scrubTime = 0; @observable _markerEnd: number = 0; @observable _position: number = 0; @@ -85,27 +69,17 @@ export class AudioBox extends ViewBoxAnnotatableComponent) { 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); } - - // onClick play scripts - AudioBox.RangeScript = AudioBox.RangeScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.LabelScript = AudioBox.LabelScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.RangePlayScript = AudioBox.RangePlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.LabelPlayScript = AudioBox.LabelPlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; } - anchorStart = (anchor: Doc) => NumCast(anchor.anchorStartTime, NumCast(anchor.audioStart)) - anchorEnd = (anchor: Doc, defaultVal: any = null) => NumCast(anchor.anchorEndTime, NumCast(anchor.audioEnd, defaultVal)) - getLinkData(l: Doc) { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; @@ -118,7 +92,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent { - return this.createAnchor(this._ele?.currentTime || Cast(this.props.Document._currentTimecode, "number", null) || (this.audioState === "recording" ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined)); + return this._stackedTimeline.current?.createAnchor(this._ele?.currentTime || Cast(this.props.Document._currentTimecode, "number", null) || (this.audioState === "recording" ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined)) || this.rootDoc; } componentWillUnmount() { @@ -150,7 +124,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent this.props.renderDepth !== -1 && !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc ? Cast(this.Document._audioStop, "number", null) : undefined, audioStop => audioStop !== undefined && setTimeout(() => { - this._audioRef.current && this.pause(); + this._audioRef.current && this.Pause(); setTimeout(() => this.Document._audioStop = undefined, 10); }, this._audioRef.current ? 0 : 250), // wait for mainCont and try again to play { fireImmediately: true } @@ -162,8 +136,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration); - this.links.map(l => { - const { la1, linkTime } = this.getLinkData(l); + this.links.map(l => this.getLinkData(l)).forEach(({ la1, la2, linkTime }) => { if (linkTime > NumCast(this.layoutDoc._currentTimecode) && linkTime < htmlEle.currentTime) { Doc.linkFollowHighlight(la1); } @@ -173,7 +146,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent { + Pause = action(() => { this._ele!.pause(); this.audioState = "paused"; }); @@ -183,20 +156,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent { - this.playFrom(this.anchorStart(anchorDoc), this.anchorEnd(anchorDoc, this.duration)); - return true; - } - - // play back the audio from time - @action - clickAnchor = (anchorDoc: Doc) => { - if (this.layoutDoc.autoPlay) return this.playOnClick(anchorDoc); - this._ele && (this._ele.currentTime = this.layoutDoc._currentTimecode = this.anchorStart(anchorDoc)); - return true; - } // play back the audio from time @action playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => { @@ -208,17 +167,17 @@ export class AudioBox extends ViewBoxAnnotatableComponent -1) { setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); } else { - this.pause(); + this.Pause(); } } else if (seekTimeInSeconds <= this._ele.duration) { this._ele.currentTime = seekTimeInSeconds; this._ele.play(); runInAction(() => this.audioState = "playing"); if (endTime !== this.duration) { - this._play = setTimeout(() => this.pause(), (endTime - seekTimeInSeconds) * 1000); // use setTimeout to play a specific duration + this._play = setTimeout(() => this.Pause(), (endTime - seekTimeInSeconds) * 1000); // use setTimeout to play a specific duration } } else { - this.pause(); + this.Pause(); } } } @@ -283,9 +242,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent { + Play = (e?: any) => { this.playFrom(this._ele!.paused ? this._ele!.currentTime : -1); - e.stopPropagation(); + e?.stopPropagation?.(); } // creates a text document for dictation @@ -302,15 +261,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent { e?.addEventListener("timeupdate", this.timecodeChanged); - e?.addEventListener("ended", this.pause); + e?.addEventListener("ended", this.Pause); this._ele = e; } - // ref for timeline - timelineRef = (timeline: HTMLDivElement) => { - this._timeline = timeline; - } - // returns the path of the audio file @computed get path() { const field = Cast(this.props.Document[this.props.fieldKey], AudioField); @@ -345,120 +299,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent { - const rect = this._timeline?.getBoundingClientRect();// (e.target as any).getBoundingClientRect(); - if (rect && e.target !== this._audioRef.current && this.active()) { - const wasPaused = this.audioState === "paused"; - this._ele!.currentTime = this.layoutDoc._currentTimecode = (e.clientX - rect.x) / rect.width * this.duration; - wasPaused && this.pause(); - - const toTimeline = (screen_delta: number) => screen_delta / rect.width * this.duration; - this._markerStart = this._markerEnd = toTimeline(e.clientX - rect.x); - AudioBox.SelectingRegion = this; - setupMoveUpEvents(this, e, - action(e => { - this._markerEnd = toTimeline(e.clientX - rect.x); - return false; - }), - action((e, movement) => { - this._markerEnd = toTimeline(e.clientX - rect.x); - if (this._markerEnd < this._markerStart) { - const tmp = this._markerStart; - this._markerStart = this._markerEnd; - this._markerEnd = tmp; - } - AudioBox.SelectingRegion === this && (Math.abs(movement[0]) > 15) && this.createAnchor(this._markerStart, this._markerEnd); - AudioBox.SelectingRegion = undefined; - }), - e => { - this.props.select(false); - e.shiftKey && this.createAnchor(this._ele!.currentTime); - } - , this.props.isSelected(true) || this._isChildActive); - } - } - - @action - createAnchor(anchorStartTime?: number, anchorEndTime?: number) { - if (anchorStartTime === undefined) return this.rootDoc; - const anchor = Docs.Create.LabelDocument({ - title: ComputedField.MakeFunction(`"#" + formatToTime(self.anchorStartTime) + "-" + formatToTime(self.anchorEndTime)`) as any, - useLinkSmallAnchor: true, - hideLinkButton: true, - anchorStartTime, - anchorEndTime, - annotationOn: this.props.Document - }); - if (this.dataDoc[this.annotationKey]) { - this.dataDoc[this.annotationKey].push(anchor); - } else { - this.dataDoc[this.annotationKey] = new List([anchor]); - } - return anchor; - } - - // starting the drag event for anchor resizing - onPointerDown = (e: React.PointerEvent, m: Doc, left: boolean): void => { - this._currAnchor = m; - this._left = left; - this._timeline?.setPointerCapture(e.pointerId); - const toTimeline = (screen_delta: number, width: number) => Math.max(0, Math.min(this.duration, screen_delta / width * this.duration)); - setupMoveUpEvents(this, e, - (e) => { - const rect = (e.target as any).getBoundingClientRect(); - this.changeAnchor(this._currAnchor, toTimeline(e.clientX - rect.x, rect.width)); - return false; - }, - (e) => { - const rect = (e.target as any).getBoundingClientRect(); - this._ele!.currentTime = this.layoutDoc._currentTimecode = toTimeline(e.clientX - rect.x, rect.width); - this._timeline?.releasePointerCapture(e.pointerId); - }, - emptyFunction); - } - - // updates the anchor with the new time - @action - changeAnchor = (anchor: Opt, time: number) => { - anchor && (this._left ? anchor.anchorStartTime = time : anchor.anchorEndTime = time); - } - - // checks if the two anchors are the same with start and end time - isSame = (m1: any, m2: any) => { - return this.anchorStart(m1) === this.anchorStart(m2) && this.anchorEnd(m1) === this.anchorEnd(m2); - } - - // makes sure no anchors overlaps each other by setting the correct position and width - getLevel = (m: Doc, placed: { anchorStartTime: number, anchorEndTime: number, level: number }[]) => { - const timelineContentWidth = this.props.PanelWidth() - AudioBox.playheadWidth; - const x1 = this.anchorStart(m); - 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; - } - })); - 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; - } - - @computed get selectionContainer() { - return AudioBox.SelectingRegion !== this ? (null) :
; - } - // returns the audio waveform @computed get waveform() { const audioBuckets = Cast(this.dataDoc.audioBuckets, listSpec("number"), []); @@ -488,15 +328,11 @@ export class AudioBox extends ViewBoxAnnotatableComponent AudioBox.RangeScript; - labelClickScript = () => AudioBox.LabelScript; - rangePlayScript = () => AudioBox.RangePlayScript; - labelPlayScript = () => AudioBox.LabelPlayScript; - + playing = () => { return this.audioState === "playing"; } playLink = (link: Doc) => { if (link.annotationOn === this.rootDoc) { - if (this.layoutDoc.playOnSelect) this.playFrom(this.anchorStart(link), this.anchorEnd(link)); - else this._ele!.currentTime = this.layoutDoc._currentTimecode = this.anchorStart(link); + if (this.layoutDoc.playOnSelect) this.playFrom(this._stackedTimeline.current?.anchorStart(link) || 0, this._stackedTimeline.current?.anchorEnd(link)); + else this._ele!.currentTime = this.layoutDoc._currentTimecode = (this._stackedTimeline.current?.anchorStart(link) || 0); } else this.links.filter(l => l.anchor1 === link || l.anchor2 === link).forEach(l => { const { la1, la2 } = this.getLinkData(l); @@ -509,50 +345,34 @@ export class AudioBox extends ViewBoxAnnotatableComponent ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { - const anchor = observable({ view: undefined as any }); - return { - anchor, view: anchor.view = r)} - Document={mark} - DataDoc={undefined} - PanelWidth={() => width} - PanelHeight={() => height} - renderDepth={this.props.renderDepth + 1} - focus={() => this.playLink(mark)} - rootSelected={returnFalse} - LayoutTemplate={undefined} - LayoutTemplateString={LabelBox.LayoutString("data")} - ContainingCollectionDoc={this.props.Document} - removeDocument={this.removeDocument} - ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(-x - 4, -y - 3)} - parentActive={(out) => this.props.isSelected(out) || this._isChildActive} - whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} - onClick={script} - onDoubleClick={this.layoutDoc.autoPlay ? undefined : doublescript} - ignoreAutoHeight={false} - bringToFront={emptyFunction} - scriptContext={this} /> - }; - }); - renderAnchor = computedFn(function (this: AudioBox, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { - const inner = this.renderInner(mark, script, doublescript, x, y, width, height); - return <> - {inner.view} - {!inner.anchor.view || !SelectionManager.IsSelected(inner.anchor.view) ? (null) : - <> -
this.onPointerDown(e, mark, true)} /> -
this.onPointerDown(e, mark, false)} /> - } - ; - }); + @computed get renderTimeline() { + return this._ele!.currentTime = this.layoutDoc._currentTimecode = time} + playing={this.playing} + select={this.props.select} + isSelected={this.props.isSelected} + whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} + removeDocument={this.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(0, -(100 - this.heightPercent) / 200 * this.props.PanelHeight())} + isChildActive={() => this._isChildActive} + Play={this.Play} + Pause={this.Pause} + active={this.active} + playLink={this.playLink} + PanelWidth={this.props.PanelWidth} + PanelHeight={() => this.props.PanelHeight() * this.heightPercent / 100 * this.heightPercent / 100}// panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) + />; + } render() { const interactive = SnappingManager.GetIsDragging() || this.active() ? "-interactive" : ""; - const timelineContentWidth = this.props.PanelWidth() - AudioBox.playheadWidth; - const timelineContentHeight = (this.props.PanelHeight() * AudioBox.heightPercent / 100) * AudioBox.heightPercent / 100; // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) - const overlaps: { anchorStartTime: number, anchorEndTime: number, level: number }[] = []; - const drawAnchors = this.anchorDocs.map(anchor => ({ level: this.getLevel(anchor, overlaps), anchor })); - const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; return
-
-
-
+
+
+
{this.waveform}
-
{ e.stopPropagation(); e.preventDefault(); }} - onPointerDown={e => e.button === 0 && !e.ctrlKey && this.onPointerDownTimeline(e)}> - {drawAnchors.map(d => { - const m = d.anchor; - const start = this.anchorStart(m); - const end = this.anchorEnd(m, start + 10 / timelineContentWidth * this.duration); - const left = start / this.duration * timelineContentWidth; - const top = d.level / maxLevel * timelineContentHeight; - const timespan = end - start; - return this.layoutDoc.hideAnchors ? (null) : -
{ this.playFrom(start, this.anchorEnd(m)); e.stopPropagation(); }} > - {this.renderAnchor(m, this.rangeClickScript, this.rangePlayScript, - left + AudioBox.playheadWidth, - (1 - AudioBox.heightPercent / 100) / 2 * this.props.PanelHeight() + top, - timelineContentWidth * timespan / this.duration, - timelineContentHeight / maxLevel)} -
; - })} - {this.selectionContainer} -
{ e.stopPropagation(); e.preventDefault(); }} - style={{ left: `${NumCast(this.layoutDoc._currentTimecode) / this.duration * 100}%`, pointerEvents: "none" }} - /> -
+ {this.renderTimeline} {this.audio}
{formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))} @@ -624,5 +419,4 @@ export class AudioBox extends ViewBoxAnnotatableComponent; } -} -Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/client/views/nodes/StackedTimeline.scss b/src/client/views/nodes/StackedTimeline.scss new file mode 100644 index 000000000..da7310794 --- /dev/null +++ b/src/client/views/nodes/StackedTimeline.scss @@ -0,0 +1,374 @@ +.audiobox-container, +.audiobox-container-interactive { + width: 100%; + height: 100%; + position: inherit; + display: flex; + position: relative; + cursor: default; + + .audiobox-inner { + width:100%; + height: 100%; + } + + .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; + } + + .audiobox-dictation:hover { + color: white; + cursor: pointer; + } + } + + .audiobox-handle { + width: 20px; + height: 100%; + display: inline-block; + } + + .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-record { + pointer-events: all; + width: 100%; + height: 100%; + position: relative; + pointer-events: none; + } + + .audiobox-record-interactive { + pointer-events: all; + width: 100%; + height: 100%; + position: relative; + + + } + + .recording { + margin-top: auto; + margin-bottom: auto; + width: 100%; + height: 100%; + position: relative; + padding-right: 5px; + display: flex; + background-color: red; + + .time { + position: relative; + height: 100%; + width: 100%; + font-size: 20; + text-align: center; + top: 5; + } + + .buttons { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + padding: 5px; + } + + .buttons:hover { + background-color: crimson; + } + } + + .audiobox-controls { + width: 100%; + height: 100%; + 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; + } + + .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; + } + + .audiobox-playhead:hover { + // background-color: black; + // border-radius: 5px; + 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-container { + position: absolute; + width: 10px; + top: 2.5%; + height: 0px; + background: lightblue; + border-radius: 5px; + // box-shadow: black 2px 2px 1px; + opacity: 0.3; + z-index: 500; + border-style: solid; + border-color: darkblue; + border-width: 1px; + } + + .audiobox-current { + width: 1px; + height: 100%; + background-color: red; + position: absolute; + top: 0px; + } + + .waveform { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + z-index: -1000; + bottom: 0; + pointer-events: none; + div { + height: 100% !important; + width: 100% !important; + } + canvas { + height: 100% !important; + width: 100% !important; + } + } + + .audiobox-linker, + .audiobox-linker-mini { + position: absolute; + width: 15px; + min-height: 10px; + height: 15px; + margin-left: -2.55px; + background: gray; + border-radius: 100%; + opacity: 0.9; + box-shadow: black 2px 2px 1px; + + .linkAnchorBox-cont { + position: relative !important; + height: 100% !important; + width: 100% !important; + left: unset !important; + top: unset !important; + } + } + + .audiobox-linker-mini { + width: 8px; + min-height: 8px; + height: 8px; + box-shadow: black 1px 1px 1px; + margin-left: -1; + margin-top: -2; + + .linkAnchorBox-cont { + position: relative !important; + height: 100% !important; + width: 100% !important; + left: unset !important; + top: unset !important; + } + } + + .audiobox-linker:hover, + .audiobox-linker-mini:hover { + opacity: 1; + } + + .audiobox-marker-container, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 10px; + top: 2.5%; + background: gray; + border-radius: 50%; + box-shadow: black 2px 2px 1px; + overflow: visible; + cursor: pointer; + + .audiobox-marker { + position: relative; + height: 100%; + // height: calc(100% - 15px); + width: 100%; + //margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + } + + .audiobox-marker-timeline, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 90%; + top: 2.5%; + border-radius: 5px; + + .left-resizer { + background: dimgrey; + } + .resizer { + background: dimgrey; + } + + .audiobox-marker { + position: relative; + height: calc(100% - 15px); + margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + + .resizer { + position: absolute; + top: 0; + right: 0; + pointer-events: all; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + + .click { + position: relative; + height: 100%; + width: 100%; + z-index: 100; + } + + .left-resizer { + position: absolute; + left: 0; + top : 0; + pointer-events: all; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + } + + .audiobox-marker-timeline:hover, + .audiobox-marker-minicontainer:hover { + opacity: 0.8; + } + + .audiobox-marker-minicontainer { + width: 5px; + border-radius: 1px; + + .audiobox-marker { + position: relative; + height: calc(100% - 8px); + margin-top: 8px; + } + } + } + } + } +} + + +@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 diff --git a/src/client/views/nodes/StackedTimeline.tsx b/src/client/views/nodes/StackedTimeline.tsx new file mode 100644 index 000000000..808ca982d --- /dev/null +++ b/src/client/views/nodes/StackedTimeline.tsx @@ -0,0 +1,335 @@ +import React = require("react"); +import { action, computed, IReactionDisposer, observable } from "mobx"; +import { observer } from "mobx-react"; +import { computedFn } from "mobx-utils"; +import { Doc, DocListCast, Opt } from "../../../fields/Doc"; +import { Id } from "../../../fields/FieldSymbols"; +import { List } from "../../../fields/List"; +import { listSpec } from "../../../fields/Schema"; +import { ComputedField, ScriptField } from "../../../fields/ScriptField"; +import { Cast, NumCast } from "../../../fields/Types"; +import { emptyFunction, formatTime, OmitKeys, returnFalse, setupMoveUpEvents } from "../../../Utils"; +import { Docs } from "../../documents/Documents"; +import { Scripting } from "../../util/Scripting"; +import { SelectionManager } from "../../util/SelectionManager"; +import { Transform } from "../../util/Transform"; +import "./StackedTimeline.scss"; +import { DocumentView, DocumentViewProps } from "./DocumentView"; +import { LabelBox } from "./LabelBox"; + +export interface StackedTimelineProps { + Document: Doc; + dataDoc: Doc; + anchorProps: DocumentViewProps; + renderDepth: number; + annotationKey: string; + duration: number; + Play: () => void; + Pause: () => void; + playLink: (linkDoc: Doc) => void; + playFrom: (seekTimeInSeconds: number, endTime?: number) => void; + playing: () => boolean; + setTime: (time: number) => void; + select: (ctrlKey: boolean) => void; + isSelected: (outsideReaction: boolean) => boolean; + whenActiveChanged: (isActive: boolean) => void; + removeDocument: (doc: Doc | Doc[]) => boolean; + ScreenToLocalTransform: () => Transform; + isChildActive: () => boolean; + active: () => boolean; + PanelWidth: () => number; + PanelHeight: () => number; +} + +@observer +export class StackedTimeline extends React.Component { + static RangeScript: ScriptField; + static LabelScript: ScriptField; + static RangePlayScript: ScriptField; + static LabelPlayScript: ScriptField; + + _disposers: { [name: string]: IReactionDisposer } = {}; + _doubleTime: NodeJS.Timeout | undefined; // bcz: Hack! this must be called _doubleTime since setupMoveDragEvents will use that field name + _ele: HTMLAudioElement | null = null; + _start: number = 0; + _left: boolean = false; + _dragging = false; + _play: any = null; + _audioRef = React.createRef(); + _timeline: Opt; + _markerStart: number = 0; + _currAnchor: Opt; + + @observable static SelectingRegion: StackedTimeline | undefined = undefined; + @observable _markerEnd: number = 0; + @observable _position: number = 0; + @computed get anchorDocs() { return DocListCast(this.props.dataDoc[this.props.annotationKey]); } + @computed get currentTime() { return NumCast(this.props.Document._currentTimecode); } + + constructor(props: Readonly) { + super(props); + // onClick play scripts + StackedTimeline.RangeScript = StackedTimeline.RangeScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; + StackedTimeline.LabelScript = StackedTimeline.LabelScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; + StackedTimeline.RangePlayScript = StackedTimeline.RangePlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; + StackedTimeline.LabelPlayScript = StackedTimeline.LabelPlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; + } + + // for creating key anchors with key events + @action + keyEvents = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + if (!this.props.playing()) return; // can't create if video is not playing + switch (e.key) { + case "x": // currently set to x, but can be a different key + const currTime = this.currentTime; + if (this._start) { + this._markerStart = currTime; + // this._start = false; + // this._visible = true; + } else { + this.createAnchor(this._markerStart, currTime); + // this._start = true; + // this._visible = false; + } + } + } + + anchorStart = (anchor: Doc) => NumCast(anchor.anchorStartTime, NumCast(anchor._timecodeToShow, NumCast(anchor.videoStart))) + anchorEnd = (anchor: Doc, defaultVal: any = null) => NumCast(anchor.anchorEndTime, NumCast(anchor._timecodeToHide, NumCast(anchor.videoEnd, defaultVal))) + + getLinkData(l: Doc) { + let la1 = l.anchor1 as Doc; + let la2 = l.anchor2 as Doc; + const linkTime = NumCast(la2.anchorStartTime, NumCast(la1.anchorStartTime)); + if (Doc.AreProtosEqual(la1, this.props.dataDoc)) { + la1 = l.anchor2 as Doc; + la2 = l.anchor1 as Doc; + } + return { la1, la2, linkTime }; + } + + // ref for timeline + timelineRef = (timeline: HTMLDivElement) => { + this._timeline = timeline; + } + + // updates the anchor with the new time + @action + changeAnchor = (anchor: Opt, time: number) => { + anchor && (this._left ? anchor.anchorStartTime = time : anchor.anchorEndTime = time); + } + + // checks if the two anchors are the same with start and end time + isSame = (m1: any, m2: any) => { + return this.anchorStart(m1) === this.anchorStart(m2) && this.anchorEnd(m1) === this.anchorEnd(m2); + } + + @computed get selectionContainer() { + return StackedTimeline.SelectingRegion !== this ? (null) :
; + } + + // starting the drag event for anchor resizing + @action + onPointerDownTimeline = (e: React.PointerEvent): void => { + const rect = this._timeline?.getBoundingClientRect();// (e.target as any).getBoundingClientRect(); + if (rect && e.target !== this._audioRef.current && this.props.active()) { + const wasPlaying = this.props.playing(); + if (wasPlaying) this.props.Pause(); + else if (!this._doubleTime) { + this._doubleTime = setTimeout(() => { + this._doubleTime = undefined; + this.props.setTime((e.clientX - rect.x) / rect.width * this.props.duration); + }, 300); + } + this._markerStart = this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + StackedTimeline.SelectingRegion = this; + setupMoveUpEvents(this, e, + action(e => { + this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + return false; + }), + action((e, movement) => { + this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + if (this._markerEnd < this._markerStart) { + const tmp = this._markerStart; + this._markerStart = this._markerEnd; + this._markerEnd = tmp; + } + StackedTimeline.SelectingRegion === this && (Math.abs(movement[0]) > 15) && this.createAnchor(this._markerStart, this._markerEnd); + StackedTimeline.SelectingRegion = undefined; + }), + (e, doubleTap) => { + this.props.select(false); + e.shiftKey && this.createAnchor(this.currentTime); + !wasPlaying && doubleTap && this.props.Play(); + } + , this.props.isSelected(true) || this.props.isChildActive()); + } + } + + @action + createAnchor(anchorStartTime?: number, anchorEndTime?: number) { + if (anchorStartTime === undefined) return this.props.Document; + const anchor = Docs.Create.LabelDocument({ + title: ComputedField.MakeFunction(`"#" + formatToTime(self.anchorStartTime) + "-" + formatToTime(self.anchorEndTime)`) as any, + useLinkSmallAnchor: true, + hideLinkButton: true, + anchorStartTime, + anchorEndTime, + annotationOn: this.props.Document + }); + if (Cast(this.props.dataDoc[this.props.annotationKey], listSpec(Doc), null) !== undefined) { + Cast(this.props.dataDoc[this.props.annotationKey], listSpec(Doc), []).push(anchor); + } else { + this.props.dataDoc[this.props.annotationKey] = new List([anchor]); + } + return anchor; + } + + // play back the audio from time + @action + playOnClick = (anchorDoc: Doc) => { + this.props.playFrom(this.anchorStart(anchorDoc), this.anchorEnd(anchorDoc, this.props.duration)); + return { select: true }; + } + + // play back the audio from time + @action + clickAnchor = (anchorDoc: Doc) => { + if (this.props.Document.autoPlay) return this.playOnClick(anchorDoc); + this.props.setTime(this.anchorStart(anchorDoc)); + return { select: true }; + } + + toTimeline = (screen_delta: number, width: number) => Math.max(0, Math.min(this.props.duration, screen_delta / width * this.props.duration)); + // starting the drag event for anchor resizing + onPointerDown = (e: React.PointerEvent, m: Doc, left: boolean): void => { + this._currAnchor = m; + this._left = left; + this._timeline?.setPointerCapture(e.pointerId); + setupMoveUpEvents(this, e, + (e) => { + const rect = (e.target as any).getBoundingClientRect(); + this.changeAnchor(this._currAnchor, this.toTimeline(e.clientX - rect.x, rect.width)); + return false; + }, + (e) => { + const rect = (e.target as any).getBoundingClientRect(); + this.props.setTime(this.toTimeline(e.clientX - rect.x, rect.width)); + this._timeline?.releasePointerCapture(e.pointerId); + }, + emptyFunction); + } + + rangeClickScript = () => StackedTimeline.RangeScript; + labelClickScript = () => StackedTimeline.LabelScript; + rangePlayScript = () => StackedTimeline.RangePlayScript; + labelPlayScript = () => StackedTimeline.LabelPlayScript; + + // makes sure no anchors overlaps each other by setting the correct position and width + 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.props.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; + } + })); + 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; + } + + renderInner = computedFn(function (this: StackedTimeline, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { + const anchor = observable({ view: undefined as any }); + return { + anchor, view: anchor.view = r)} + Document={mark} + DataDoc={undefined} + PanelWidth={() => width} + PanelHeight={() => height} + renderDepth={this.props.renderDepth + 1} + focus={() => this.props.playLink(mark)} + rootSelected={returnFalse} + LayoutTemplate={undefined} + LayoutTemplateString={LabelBox.LayoutString("data")} + ContainingCollectionDoc={this.props.Document} + removeDocument={this.props.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(-x, -y)} + parentActive={(out) => this.props.isSelected(out) || this.props.isChildActive()} + whenActiveChanged={this.props.whenActiveChanged} + onClick={script} + onDoubleClick={this.props.Document.autoPlay ? undefined : doublescript} + ignoreAutoHeight={false} + bringToFront={emptyFunction} + scriptContext={this} /> + }; + }); + renderAnchor = computedFn(function (this: StackedTimeline, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { + const inner = this.renderInner(mark, script, doublescript, x, y, width, height); + return <> + {inner.view} + {!inner.anchor.view || !SelectionManager.IsSelected(inner.anchor.view) ? (null) : + <> +
this.onPointerDown(e, mark, true)} /> +
this.onPointerDown(e, mark, false)} /> + } + ; + }); + + render() { + const timelineContentWidth = this.props.PanelWidth(); + const timelineContentHeight = this.props.PanelHeight(); + const overlaps: { anchorStartTime: number, anchorEndTime: number, level: number }[] = []; + const drawAnchors = this.anchorDocs.map(anchor => ({ level: this.getLevel(anchor, overlaps), anchor })); + const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; + return
{ + if (this.props.isChildActive() || this.props.isSelected(false)) { + e.stopPropagation(); e.preventDefault(); + } + }} + onPointerDown={e => { + if (this.props.isChildActive() || this.props.isSelected(false)) { + e.button === 0 && !e.ctrlKey && this.onPointerDownTimeline(e); + } + }}> + {drawAnchors.map(d => { + const m = d.anchor; + const start = this.anchorStart(m); + const end = this.anchorEnd(m, start + 10 / timelineContentWidth * this.props.duration); + const left = start / this.props.duration * timelineContentWidth; + const top = d.level / maxLevel * timelineContentHeight; + const timespan = end - start; + return this.props.Document.hideAnchors ? (null) : +
{ this.props.playFrom(start, this.anchorEnd(m)); e.stopPropagation(); }} > + {this.renderAnchor(m, this.rangeClickScript, this.rangePlayScript, + left, + top, + timelineContentWidth * timespan / this.props.duration, + timelineContentHeight / maxLevel)} +
; + })} + {this.selectionContainer} +
{ e.stopPropagation(); e.preventDefault(); }} + style={{ left: `${this.currentTime / this.props.duration * 100}%`, pointerEvents: "none" }} + /> +
+ } +} +Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); }); \ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 988dd47d8..c6b9661a2 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -3,35 +3,32 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, IReactionDisposer, observable, reaction, runInAction, untracked } from "mobx"; import { observer } from "mobx-react"; import * as rp from 'request-promise'; -import { Doc, Opt, DocListCast } from "../../../fields/Doc"; +import { Dictionary } from "typescript-collections"; +import { Doc, DocListCast } from "../../../fields/Doc"; +import { documentSchema } from "../../../fields/documentSchemas"; import { InkTool } from "../../../fields/InkField"; +import { List } from "../../../fields/List"; import { createSchema, makeInterface } from "../../../fields/Schema"; -import { Cast, StrCast, NumCast } from "../../../fields/Types"; +import { ComputedField } from "../../../fields/ScriptField"; +import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { VideoField } from "../../../fields/URLField"; -import { Utils, emptyFunction, returnOne, returnZero, OmitKeys, setupMoveUpEvents, returnFalse, returnTrue, formatTime } from "../../../Utils"; +import { emptyFunction, formatTime, OmitKeys, returnOne, Utils, setupMoveUpEvents } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; +import { Networking } from "../../Network"; +import { SelectionManager } from "../../util/SelectionManager"; +import { SnappingManager } from "../../util/SnappingManager"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; +import { MarqueeAnnotator } from "../MarqueeAnnotator"; +import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; -import "./VideoBox.scss"; -import { documentSchema } from "../../../fields/documentSchemas"; -import { Networking } from "../../Network"; -import { SnappingManager } from "../../util/SnappingManager"; -import { SelectionManager } from "../../util/SelectionManager"; -import { ComputedField, ScriptField } from "../../../fields/ScriptField"; -import { List } from "../../../fields/List"; -import { DocumentView } from "./DocumentView"; -import { LinkDocPreview } from "./LinkDocPreview"; import { FormattedTextBoxComment } from "./formattedText/FormattedTextBoxComment"; -import { StyleProp } from "../StyleProvider"; -import { computedFn } from "mobx-utils"; -import { Dictionary } from "typescript-collections"; -import { MarqueeAnnotator } from "../MarqueeAnnotator"; -import { Id } from "../../../fields/FieldSymbols"; -import { LabelBox } from "./LabelBox"; +import { LinkDocPreview } from "./LinkDocPreview"; +import { StackedTimeline } from "./StackedTimeline"; +import "./VideoBox.scss"; const path = require('path'); export const timeSchema = createSchema({ @@ -45,35 +42,22 @@ export class VideoBox extends ViewBoxAnnotatableComponent(); private _mainCont: React.RefObject = React.createRef(); private _annotationLayer: React.RefObject = React.createRef(); - private _play: any = null; - private _timeline: Opt; - private _audioRef = React.createRef(); - private _markerStart: number = 0; - private _left: boolean = false; - private _duration = 0; - private _start: boolean = true; - private _currAnchor: Doc | undefined; + private _playRegionTimer: any = null; + private _playRegionDuration = 0; @observable static _showControls: boolean; - @observable static SelectingRegion: VideoBox | undefined = undefined; @observable _marqueeing: number[] | undefined; @observable _savedAnnotations: Dictionary = new Dictionary(); @observable _screenCapture = false; @observable _visible: boolean = false; - @observable _markerEnd: number = 0; @observable _forceCreateYouTubeIFrame = false; @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @@ -88,19 +72,13 @@ export class VideoBox extends ViewBoxAnnotatableComponent) { super(props); VideoBox.Instance = this; - - // onClick play scripts - VideoBox.RangeScript = VideoBox.RangeScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this, clientX)`, { this: Doc.name, clientX: "number", scriptContext: "any" })!; - VideoBox.LabelScript = VideoBox.LabelScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this, clientX)`, { this: Doc.name, clientX: "number", scriptContext: "any" })!; - VideoBox.RangePlayScript = VideoBox.RangePlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this, clientX)`, { self: Doc.name, clientX: "number", scriptContext: "any" })!; - VideoBox.LabelPlayScript = VideoBox.LabelPlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this, clientX)`, { self: Doc.name, clientX: "number", scriptContext: "any" })!; } anchorStart = (anchor: Doc) => NumCast(anchor.anchorStartTime, NumCast(anchor._timecodeToShow, NumCast(anchor.videoStart))) anchorEnd = (anchor: Doc, defaultVal: any = null) => NumCast(anchor.anchorEndTime, NumCast(anchor._timecodeToHide, NumCast(anchor.videoEnd, defaultVal))) getAnchor = () => { - return this.createAnchor(Cast(this.layoutDoc._currentTimecode, "number", null)); + return this._stackedTimeline.current?.createAnchor(Cast(this.layoutDoc._currentTimecode, "number", null)) || this.rootDoc; } choosePath(url: string) { @@ -115,6 +93,10 @@ export class VideoBox extends ViewBoxAnnotatableComponent { + VideoBox.Instance._stackedTimeline.current?.keyEvents(e); + } + @action public Play = (update: boolean = true) => { document.removeEventListener("keydown", VideoBox.keyEventsWrapper, true); document.addEventListener("keydown", VideoBox.keyEventsWrapper, true); @@ -272,9 +254,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent this._disposers[d]?.()); document.removeEventListener("keydown", VideoBox.keyEventsWrapper, true); } @@ -405,11 +385,12 @@ export class VideoBox extends ViewBoxAnnotatableComponent
, -
+
this.layoutDoc._timelineShow = !this.layoutDoc._timelineShow)} + style={{ + transform: `scale(${this.scaling()})`, + right: this.scaling() * 10 - 10, + bottom: this.scaling() * 10 - 10 + }}>
, VideoBox._showControls ? (null) : [ @@ -424,7 +405,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent this._playing ? this.Pause() : this.Play(); onFullDown = (e: React.PointerEvent) => { @@ -440,24 +420,12 @@ export class VideoBox extends ViewBoxAnnotatableComponent { - this.Pause(); - e.stopPropagation(); - this._isResetClick = 0; - document.addEventListener("pointermove", this.onResetMove, true); - document.addEventListener("pointerup", this.onResetUp, true); - } - - onResetMove = (e: PointerEvent) => { - this._isResetClick += Math.abs(e.movementX) + Math.abs(e.movementY); - this.Seek(Math.max(0, (this.layoutDoc._currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); - e.stopImmediatePropagation(); - } - - @action - onResetUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onResetMove, true); - document.removeEventListener("pointerup", this.onResetUp, true); - this._isResetClick < 10 && (this.layoutDoc._currentTimecode = 0); + setupMoveUpEvents(this, e, (e: PointerEvent) => { + this.Seek(Math.max(0, (this.layoutDoc._currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); + e.stopImmediatePropagation(); + return false; + }, emptyFunction, + (e: PointerEvent) => this.layoutDoc._currentTimecode = 0); } @computed get youtubeContent() { @@ -482,8 +450,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent { - clearTimeout(this._play); - this._duration = endTime - seekTimeInSeconds; + clearTimeout(this._playRegionTimer); + this._playRegionDuration = endTime - seekTimeInSeconds; if (Number.isNaN(this.player?.duration)) { setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); } else if (this.player) { @@ -498,7 +466,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent this._playing = true); if (endTime !== this.duration) { - this._play = setTimeout(() => this.Pause(), (this._duration) * 1000); // use setTimeout to play a specific duration + this._playRegionTimer = setTimeout(() => this.Pause(), (this._playRegionDuration) * 1000); // use setTimeout to play a specific duration } } else { this.Pause(); @@ -506,150 +474,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent this.layoutDoc._timelineShow = !this.layoutDoc._timelineShow - - // ref for timeline - timelineRef = (timeline: HTMLDivElement) => this._timeline = timeline - - // starting the drag event creating a range mark - @action - onPointerDownTimeline = (e: React.PointerEvent): void => { - const rect = this._timeline?.getBoundingClientRect(); - if (rect && e.target !== this._audioRef.current && this.active()) { - const wasPlaying = this._playing; - if (this._playing) this.Pause(); - else if (!this._doubleTime) { - this._doubleTime = setTimeout(() => { - this._doubleTime = undefined; - this.player!.currentTime = this.layoutDoc._currentTimecode = (e.clientX - rect.x) / rect.width * this.duration; - }, 300); - } - - this._markerStart = this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); - VideoBox.SelectingRegion = this; - setupMoveUpEvents(this, e, - action(e => { - this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); - return false; - }), - action((e, movement) => { - if (this._markerEnd < this._markerStart) { - const tmp = this._markerStart; - this._markerStart = this._markerEnd; - this._markerEnd = tmp; - } - VideoBox.SelectingRegion === this && (Math.abs(movement[0]) > 15) && this.createAnchor(this._markerStart, this._markerEnd); - VideoBox.SelectingRegion = undefined; - }), - (e, doubleTap) => { - this.props.select(false); - e.shiftKey && this.createAnchor(this.player!.currentTime); - !wasPlaying && doubleTap && this.Play(); - } - , this.props.isSelected(true) || this._isChildActive); - } - } - - @action - createAnchor(anchorStartTime: number, anchorEndTime?: number) { - const anchor = Docs.Create.LabelDocument({ - title: ComputedField.MakeFunction(`"#" + formatToTime(self.anchorStartTime) + "-" + formatToTime(self.anchorEndTime)`) as any, - useLinkSmallAnchor: true, // bcz: note this also flags that the annotation is not on the video itself, just the timeline - hideLinkButton: true, - anchorStartTime, - anchorEndTime, - annotationOn: this.props.Document - }); - if (this.dataDoc[this.annotationKey + "-timeline"]) { - this.dataDoc[this.annotationKey + "-timeline"].push(anchor); - } else { - this.dataDoc[this.annotationKey + "-timeline"] = new List([anchor]); - } - return anchor; - } - - @action - playOnClick = (anchorDoc: Doc, clientX: number) => { - const seekTimeInSeconds = this.anchorStart(anchorDoc); - const endTime = this.anchorEnd(anchorDoc); - if (this.layoutDoc.autoPlay) { - if (this._playing) this.Pause(); - else this.playFrom(seekTimeInSeconds, endTime); - } else { - if (seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) && endTime > NumCast(this.layoutDoc._currentTimecode)) { - if (!this.layoutDoc.autoPlay && this._playing) { - this.Pause(); - } else { - this.Play(); - } - } else { - this.playFrom(seekTimeInSeconds, endTime); - } - } - return { select: true }; - } - - @action - clickAnchor = (anchorDoc: Doc, clientX: number) => { - const seekTimeInSeconds = this.anchorStart(anchorDoc); - const endTime = this.anchorEnd(anchorDoc); - if (seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) + 1e-4 && endTime > NumCast(this.layoutDoc._currentTimecode) - 1e-4) { - if (this._playing) this.Pause(); - else if (this.layoutDoc.autoPlay) this.Play(); - else if (!this.layoutDoc.autoPlay) { - const rect = this._timeline?.getBoundingClientRect(); - rect && this.Seek(this.toTimeline(clientX - rect.x, rect.width)); - } - } else { - if (this.layoutDoc.autoPlay) this.playFrom(seekTimeInSeconds, endTime); - else this.Seek(seekTimeInSeconds); - } - return { select: true }; - } - - toTimeline = (screen_delta: number, width: number) => Math.max(0, Math.min(this.duration, screen_delta / width * this.duration)); - // starting the drag event for anchor resizing - onPointerDown = (e: React.PointerEvent, m: Doc, left: boolean): void => { - this._currAnchor = m; - this._left = left; - this._timeline?.setPointerCapture(e.pointerId); - setupMoveUpEvents(this, e, - (e: PointerEvent) => { - const rect = (e.target as any).getBoundingClientRect(); - this.changeAnchor(this._currAnchor, this.toTimeline(e.clientX - rect.x, rect.width)); - return false; - }, - (e: PointerEvent) => { - const rect = (e.target as any).getBoundingClientRect(); - this.player!.currentTime = this.layoutDoc._currentTimecode = this.toTimeline(e.clientX - rect.x, rect.width); - this._timeline?.releasePointerCapture(e.pointerId); - }, - emptyFunction); - } - - // makes sure no anchors overlaps each other by setting the correct position and width - getLevel = (m: any, placed: { anchorStartTime: number, videoEnd: number, level: number }[]) => { - const timelineContentWidth = this.props.PanelWidth(); - const x1 = this.anchorStart(m); - 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.videoEnd; - 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, videoEnd: x2, level }); - return level; - } - playLink = (doc: Doc) => { const startTime = NumCast(doc.anchorStartTime, NumCast(doc._timecodeToShow)); const endTime = NumCast(doc.anchorEndTime, NumCast(doc._timecodeToHide, null)); @@ -658,145 +482,40 @@ export class VideoBox extends ViewBoxAnnotatableComponent ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number, annotationKey: string) { - const anchor = observable({ view: undefined as any }); - return { - anchor, view: anchor.view = r)} - Document={mark} - DataDoc={undefined} - PanelWidth={() => width} - PanelHeight={() => height} - renderDepth={this.props.renderDepth + 1} - focus={() => this.playLink(mark)} - rootSelected={returnFalse} - LayoutTemplate={undefined} - LayoutTemplateString={LabelBox.LayoutString("data")} - ContainingCollectionDoc={this.props.Document} - removeDocument={(doc: Doc | Doc[]) => this.removeDocument(doc, annotationKey)} - ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(-x, -y)} - parentActive={(out) => this.props.isSelected(out) || this._isChildActive} - whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} - onClick={script} - onDoubleClick={doublescript} - ignoreAutoHeight={false} - bringToFront={emptyFunction} - scriptContext={this} /> - }; - }); - - renderAnchor = computedFn(function (this: VideoBox, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number, annotationKey: string) { - const inner = this.renderInner(mark, script, doublescript, x, y, width, height, annotationKey); - return <> - {inner.view} - {!inner.anchor.view || !SelectionManager.IsSelected(inner.anchor.view) ? (null) : - <> -
this.onPointerDown(e, mark, true)} /> -
this.onPointerDown(e, mark, false)} /> - } - ; - }); // returns the timeline @computed get renderTimeline() { - const timelineContentWidth = this.props.PanelWidth(); - const timelineContentHeight = this.props.PanelHeight() * (100 - this.heightPercent) / 100; - const overlaps: { anchorStartTime: number, videoEnd: number, level: number }[] = []; - const drawAnchors: { level: number, anchor: Doc }[] = this.anchorDocs.map(anchor => ({ level: this.getLevel(anchor, overlaps), anchor })); - const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; - return !this.layoutDoc._timelineShow ? (null) : -
{ - if (this._isChildActive || this.props.isSelected()) { - e.stopPropagation(); e.preventDefault(); - } - }} - onPointerDown={e => { - if (this._isChildActive || this.props.isSelected()) { - e.button === 0 && !e.ctrlKey && this.onPointerDownTimeline(e); - } - }}> - {drawAnchors.map(d => { - const m = d.anchor; - const start = this.anchorStart(m); - const end = this.anchorEnd(m, start + 10 / timelineContentWidth * this.duration); - const left = start / this.duration * timelineContentWidth; - const top = d.level / maxLevel * timelineContentHeight; - const timespan = end - start; - return this.layoutDoc.hideAnchors ? (null) : -
{ this.playFrom(start, this.anchorEnd(m)); e.stopPropagation(); }} > - {this.renderAnchor(m, this.rangeClickScript, this.rangePlayScript, - left, - top + (this.props.PanelHeight() - timelineContentHeight), - timelineContentWidth * timespan / this.duration, - timelineContentHeight / maxLevel, this.annotationKey + (m.anchorStartTime !== undefined ? "-timeline" : ""))} -
; - })} - {this.selectionContainer} -
{ e.stopPropagation(); e.preventDefault(); }} - style={{ left: `${NumCast(this.layoutDoc._currentTimecode) / this.duration * 100}%`, pointerEvents: "none" }} - /> -
; - } - - // updates the anchor with the new time - @action - changeAnchor = (anchor: Opt, time: number) => { - if (anchor) { - const timelineOnly = Cast(anchor.anchorStartTime, "number", null) !== undefined; - if (timelineOnly) this._left ? anchor.anchorStartTime = time : anchor.anchorEndTime = time; - else this._left ? anchor._timecodeToShow = time : anchor._timecodeToHide = time; - } - } - - // checks if the two anchors are the same with start and end time - isSame = (m1: any, m2: any) => { - return m1.anchorStartTime === m2.anchorStartTime && m1.anchorEndTime === m2.anchorEndTime && m1._timecodeToShow === m2._timecodeToShow && m1._timecodeToHide === m2._timecodeToHide; - } - - // returns the blue container when dragging - @computed get selectionContainer() { - return VideoBox.SelectingRegion !== this ? (null) :
; - } - - static keyEventsWrapper = (e: KeyboardEvent) => { - VideoBox.Instance.keyEvents(e); - } - - // for creating key anchors with key events - @action - keyEvents = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement) return; - if (!this._playing) return; // can't create if video is not playing - switch (e.key) { - case "x": // currently set to x, but can be a different key - const currTime = this.player!.currentTime; - if (this._start) { - this._markerStart = this.player!.currentTime; - this._start = false; - this._visible = true; - } else { - this.createAnchor(this._markerStart, currTime); - this._start = true; - this._visible = false; - } - } + return
+ this.player!.currentTime = this.layoutDoc._currentTimecode = time} + playing={() => this._playing} + select={this.props.select} + isSelected={this.props.isSelected} + whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} + removeDocument={this.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight())} + isChildActive={() => this._isChildActive} + Play={this.Play} + Pause={this.Pause} + active={this.active} + playLink={this.playLink} + PanelWidth={this.props.PanelWidth} + PanelHeight={() => this.props.PanelHeight() * (100 - this.heightPercent) / 100} + /> +
; } - rangeClickScript = () => VideoBox.RangeScript; - labelClickScript = () => VideoBox.LabelScript; - rangePlayScript = () => VideoBox.RangePlayScript; - labelPlayScript = () => VideoBox.LabelPlayScript; - contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; @computed get annotationLayer() { - return
; + return
; } marqueeDown = action((e: React.PointerEvent) => { -- cgit v1.2.3-70-g09d2