aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/Utils.ts20
-rw-r--r--src/client/views/AudioWaveform.tsx242
-rw-r--r--src/client/views/ContextMenuItem.tsx4
-rw-r--r--src/client/views/DocumentDecorations.tsx4
-rw-r--r--src/client/views/StyleProvider.tsx4
-rw-r--r--src/client/views/collections/CollectionStackedTimeline.scss172
-rw-r--r--src/client/views/collections/CollectionStackedTimeline.tsx576
-rw-r--r--src/client/views/collections/TabDocView.tsx4
-rw-r--r--src/client/views/collections/collectionLinear/CollectionLinearView.scss21
-rw-r--r--src/client/views/collections/collectionLinear/CollectionLinearView.tsx21
-rw-r--r--src/client/views/nodes/AudioBox.scss320
-rw-r--r--src/client/views/nodes/AudioBox.tsx837
-rw-r--r--src/client/views/nodes/ComparisonBox.tsx4
-rw-r--r--src/client/views/nodes/DocumentView.tsx5
-rw-r--r--src/client/views/nodes/LabelBox.tsx8
-rw-r--r--src/client/views/nodes/VideoBox.scss158
-rw-r--r--src/client/views/nodes/VideoBox.tsx516
-rw-r--r--src/client/views/nodes/trails/PresBox.tsx29
-rw-r--r--src/fields/Doc.ts1
19 files changed, 1650 insertions, 1296 deletions
diff --git a/src/Utils.ts b/src/Utils.ts
index d0d891f77..b280badd7 100644
--- a/src/Utils.ts
+++ b/src/Utils.ts
@@ -514,6 +514,26 @@ export function smoothScroll(duration: number, element: HTMLElement | HTMLElemen
};
animateScroll();
}
+
+export function smoothScrollHorizontal(duration: number, element: HTMLElement | HTMLElement[], to: number) {
+ const elements = (element instanceof HTMLElement ? [element] : element);
+ const starts = elements.map(element => element.scrollLeft);
+ const startDate = new Date().getTime();
+
+ const animateScroll = () => {
+ const currentDate = new Date().getTime();
+ const currentTime = currentDate - startDate;
+ elements.map((element, i) => element.scrollLeft = easeInOutQuad(currentTime, starts[i], to - starts[i], duration));
+
+ if (currentTime < duration) {
+ requestAnimationFrame(animateScroll);
+ } else {
+ elements.forEach(element => element.scrollLeft = to);
+ }
+ };
+ animateScroll();
+}
+
export function addStyleSheet(styleType: string = "text/css") {
const style = document.createElement("style");
style.type = styleType;
diff --git a/src/client/views/AudioWaveform.tsx b/src/client/views/AudioWaveform.tsx
index 8f3b7c2cd..8a3c3c319 100644
--- a/src/client/views/AudioWaveform.tsx
+++ b/src/client/views/AudioWaveform.tsx
@@ -1,6 +1,6 @@
import React = require("react");
import axios from "axios";
-import { action, computed } from "mobx";
+import { action, computed, reaction, IReactionDisposer } from "mobx";
import { observer } from "mobx-react";
import Waveform from "react-audio-waveform";
import { Doc } from "../../fields/Doc";
@@ -10,117 +10,203 @@ import { Cast, NumCast } from "../../fields/Types";
import { numberRange } from "../../Utils";
import "./AudioWaveform.scss";
import { Colors } from "./global/globalEnums";
+import Color = require("color");
export interface AudioWaveformProps {
- duration: number;
+ duration: number; // length of media clip
+ rawDuration: number; // length of underlying media data
mediaPath: string;
layoutDoc: Doc;
- trimming: boolean;
- PanelHeight: () => number;
+ clipStart: number;
+ clipEnd: number;
+ zoomFactor: number;
+ PanelHeight: number;
+ PanelWidth: 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());
+ _disposer: IReactionDisposer | undefined;
+ @computed get waveHeight() { return Math.max(50, this.props.PanelHeight); }
+ @computed get clipStart() { return this.props.clipStart; }
+ @computed get clipEnd() { return this.props.clipEnd; }
+ @computed get zoomFactor() { return this.props.zoomFactor; }
+ @computed get audioBuckets() { return Cast(this.props.layoutDoc[this.audioBucketField(this.clipStart, this.clipEnd, this.zoomFactor)], listSpec("number"), []); }
+
+ audioBucketField = (start: number, end: number, zoomFactor: number) => "audioBuckets/" + "/" + start.toFixed(2).replace(".", "_") + "/" + end.toFixed(2).replace(".", "_") + "/" + (zoomFactor * 10);
+
+ componentWillUnmount() {
+ this._disposer?.();
}
componentDidMount() {
- const audioBuckets = Cast(
- this.props.layoutDoc.audioBuckets,
- listSpec("number"),
- []
- );
- if (!audioBuckets.length) {
- this.props.layoutDoc.audioBuckets = new List<number>([0, 0]); /// "lock" to prevent other views from computing the same data
- setTimeout(this.createWaveformBuckets);
- }
+ this._disposer = reaction(() => ({ clipStart: this.clipStart, clipEnd: this.clipEnd, fieldKey: this.audioBucketField(this.clipStart, this.clipEnd, this.zoomFactor), zoomFactor: this.props.zoomFactor }),
+ ({ clipStart, clipEnd, fieldKey, zoomFactor }) => {
+ if (!this.props.layoutDoc[fieldKey]) {
+ // setting these values here serves as a "lock" to prevent multiple attempts to create the waveform at nerly the same time.
+ const waveform = Cast(this.props.layoutDoc[this.audioBucketField(0, this.props.rawDuration, 1)], listSpec("number"));
+ this.props.layoutDoc[fieldKey] = waveform && new List<number>(waveform.slice(clipStart / this.props.rawDuration * waveform.length, clipEnd / this.props.rawDuration * waveform.length));
+ setTimeout(() => this.createWaveformBuckets(fieldKey, clipStart, clipEnd, zoomFactor));
+ }
+ }, { fireImmediately: true });
+
}
// decodes the audio file into peaks for generating the waveform
- createWaveformBuckets = async () => {
+ createWaveformBuckets = async (fieldKey: string, clipStart: number, clipEnd: number, zoomFactor: number) => {
axios({ url: this.props.mediaPath, responseType: "arraybuffer" }).then(
(response) => {
const context = new window.AudioContext();
context.decodeAudioData(
response.data,
action((buffer) => {
- const decodedAudioData = buffer.getChannelData(0);
+ const rawDecodedAudioData = buffer.getChannelData(0);
+ const startInd = clipStart / this.props.rawDuration;
+ const endInd = clipEnd / this.props.rawDuration;
+ const decodedAudioData = rawDecodedAudioData.slice(Math.floor(startInd * rawDecodedAudioData.length), Math.floor(endInd * rawDecodedAudioData.length));
+ const numBuckets = Math.floor(AudioWaveform.NUMBER_OF_BUCKETS * zoomFactor);
const bucketDataSize = Math.floor(
- decodedAudioData.length / AudioWaveform.NUMBER_OF_BUCKETS
+ decodedAudioData.length / numBuckets
);
const brange = Array.from(Array(bucketDataSize));
- 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
- )
+ const bucketList = numberRange(numBuckets).map(
+ (i: number) =>
+ brange.reduce(
+ (p, x, j) =>
+ Math.abs(
+ Math.max(p, decodedAudioData[i * bucketDataSize + j])
+ ),
+ 0
+ ) / 2
);
+ this.props.layoutDoc[fieldKey] = new List<number>(bucketList);
})
);
}
);
}
-
- @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.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}
- />
- )}
+ {/* <Waveform
+ barWidth={2}
+ width={() => this.props.PanelWidth}
+ height={this.props.PanelHeight}
+ peaks={this.audioBuckets}
+ color={Colors.MEDIUM_BLUE}
+ /> */}
+ <Waveform
+ color={Colors.MEDIUM_BLUE_ALT}
+ height={this.waveHeight}
+ barWidth={200 / this.audioBuckets.length}
+ pos={this.props.duration}
+ duration={this.props.duration}
+ peaks={this.audioBuckets}
+ progressColor={Colors.MEDIUM_BLUE_ALT}
+ />
</div>
);
}
}
+
+
+export interface WaveformProps {
+ barWidth: number;
+ width: () => number;
+ height: () => number;
+ peaks: number[];
+ color: string;
+}
+
+// @observer
+// export class Waveform extends React.Component<WaveformProps> {
+// private _canvas: HTMLCanvasElement | null = null;
+
+// get width() { return this.props.width(); }
+// get height() { return this.props.height(); }
+// get peaks() { return this.props.peaks; }
+
+// componentDidMount() {
+// this.drawBars();
+// }
+
+// drawBars() {
+// const waveCanvasCtx = this._canvas?.getContext("2d");
+
+// if (waveCanvasCtx) {
+// const pixelRatio = window.devicePixelRatio;
+// console.log(pixelRatio);
+
+// const displayWidth = Math.round(this.width);
+// const displayHeight = Math.round(this.height);
+// waveCanvasCtx.canvas.width = this.width;
+// waveCanvasCtx.canvas.height = this.height;
+// waveCanvasCtx.canvas.style.width = `${displayWidth}px`;
+// waveCanvasCtx.canvas.style.height = `${displayHeight}px`;
+
+// waveCanvasCtx.clearRect(0, 0, this.width, this.height);
+
+// const hasMinVals = [].some.call(this.peaks, (val) => val < 0);
+// let filteredPeaks = this.peaks;
+// if (hasMinVals) {
+// // If the first value is negative, add 1 to the filtered indices
+// let indexOffset = 0;
+// if (this.peaks[0] < 0) {
+// indexOffset = 1;
+// }
+// filteredPeaks = [].filter.call(
+// this.peaks,
+// (_, index) => (index + indexOffset) % 2 == 0
+// );
+// }
+
+// const $ = 0.5;
+// const height = this.height;
+// const offsetY = 0;
+// const halfH = this.height / 2;
+// const length = filteredPeaks.length;
+// const bar = this.props.barWidth;
+// const gap = 2;
+// const step = bar + gap;
+
+// let absmax = 1;
+// absmax = this.absMax(filteredPeaks);
+
+// const scale = length / this.width;
+
+// waveCanvasCtx.fillStyle = this.props.color;
+
+// for (let i = 0; i < this.width; i += step) {
+// let h = Math.round(filteredPeaks[Math.floor(i * scale)] / absmax * halfH)
+// if (h === 0) {
+// h = 1
+// }
+// waveCanvasCtx.fillRect(i + $, halfH - h + offsetY, bar + $, h * 2)
+// }
+// }
+// }
+
+// absMax = (values: number[]) => {
+// let max = -Infinity;
+// for (const i in values) {
+// const num = Math.abs(values[i]);
+// if (num > max) {
+// max = num;
+// }
+// }
+
+// return max;
+// }
+
+
+// render() {
+// return this.props.peaks ? (
+// <canvas
+// ref={(instance) => {
+// this._canvas = instance;
+// }}
+// />
+// ) : null
+// }
+// } \ No newline at end of file
diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx
index c3921d846..25d00f701 100644
--- a/src/client/views/ContextMenuItem.tsx
+++ b/src/client/views/ContextMenuItem.tsx
@@ -39,7 +39,7 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select
handleEvent = async (e: React.MouseEvent<HTMLDivElement>) => {
if ("event" in this.props) {
- this.props.closeMenu && this.props.closeMenu();
+ this.props.closeMenu?.();
let batch: UndoManager.Batch | undefined;
if (this.props.undoable !== false) {
batch = UndoManager.StartBatch(`Context menu event: ${this.props.description}`);
@@ -90,7 +90,7 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select
</span>
) : null}
<div className="contextMenu-description">
- {this.props.description.replace(":","")}
+ {this.props.description.replace(":", "")}
</div>
</div>
);
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index d0b5bfe46..657d92b8a 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -439,8 +439,8 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P
const canOpen = SelectionManager.Views().some(docView => !docView.props.Document._stayInCollection && !docView.props.Document.isGroup && !docView.props.Document.hideOpenButton);
const canDelete = SelectionManager.Views().some(docView => {
const collectionAcl = docView.props.ContainingCollectionView ? GetEffectiveAcl(docView.props.ContainingCollectionDoc?.[DataSym]) : AclEdit;
- return (!docView.rootDoc._stayInCollection || docView.rootDoc.isInkMask) &&
- (collectionAcl === AclAdmin || collectionAcl === AclEdit || GetEffectiveAcl(docView.rootDoc) === AclAdmin);
+ //return (!docView.rootDoc._stayInCollection || docView.rootDoc.isInkMask) &&
+ return (collectionAcl === AclAdmin || collectionAcl === AclEdit || GetEffectiveAcl(docView.rootDoc) === AclAdmin);
});
const topBtn = (key: string, icon: string, pointerDown: undefined | ((e: React.PointerEvent) => void), click: undefined | ((e: any) => void), title: string) => (
<Tooltip key={key} title={<div className="dash-tooltip">{title}</div>} placement="top">
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index a2f23ee09..2782574c5 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -108,7 +108,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps
if (!backColor) return undefined;
return lightOrDark(backColor);
case StyleProp.Hidden: return BoolCast(doc?._hidden);
- case StyleProp.BorderRounding: return StrCast(doc?.[fieldKey + "borderRounding"], doc?._viewType === CollectionViewType.Pile ? "50%" : "");
+ case StyleProp.BorderRounding: return StrCast(doc?.[fieldKey + "borderRounding"], StrCast(doc?.borderRounding, doc?._viewType === CollectionViewType.Pile ? "50%" : ""));
case StyleProp.TitleHeight: return 15;
case StyleProp.BorderPath: return comicStyle() && props?.renderDepth && doc?.type !== DocumentType.INK ? { path: wavyBorderPath(props?.PanelWidth?.() || 0, props?.PanelHeight?.() || 0), fill: wavyBorderPath(props?.PanelWidth?.() || 0, props?.PanelHeight?.() || 0, .08), width: 3 } : { path: undefined, width: 0 };
case StyleProp.JitterRotation: return comicStyle() ? random(-1, 1, NumCast(doc?.x), NumCast(doc?.y)) * ((props?.PanelWidth() || 0) > (props?.PanelHeight() || 0) ? 5 : 10) : 0;
@@ -184,7 +184,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps
if (doc?.type !== DocumentType.INK && layer === true) return "all";
return undefined;
case StyleProp.Decorations:
- if (props?.ContainingCollectionDoc?._viewType === CollectionViewType.Freeform) {
+ if (props?.ContainingCollectionDoc?._viewType === CollectionViewType.Freeform || doc?.x !== undefined || doc?.y !== undefined) {
return doc && (isBackground() || selected) && (props?.renderDepth || 0) > 0 &&
((doc.type === DocumentType.COL && doc._viewType !== CollectionViewType.Pile) || [DocumentType.RTF, DocumentType.IMG, DocumentType.INK].includes(doc.type as DocumentType)) ?
<div className="styleProvider-lock" onClick={() => toggleBackground(doc)}>
diff --git a/src/client/views/collections/CollectionStackedTimeline.scss b/src/client/views/collections/CollectionStackedTimeline.scss
index 59c21210a..e8b6817b4 100644
--- a/src/client/views/collections/CollectionStackedTimeline.scss
+++ b/src/client/views/collections/CollectionStackedTimeline.scss
@@ -1,94 +1,110 @@
@import "../global/globalCssVariables.scss";
-.collectionStackedTimeline {
- position: absolute;
- width: 100%;
- height: 100%;
- z-index: 1000;
- overflow: hidden;
- top: 0px;
-
- .collectionStackedTimeline-trim-shade {
- position: absolute;
+.timeline-container {
height: 100%;
- background-color: $dark-gray;
- opacity: 0.3;
- }
+ overflow-x: auto;
+ overflow-y: hidden;
+ border: none;
+ background-color: $white;
+ border: 2px solid $dark-gray;
+ border-width: 0 2px 0 2px;
+}
- .collectionStackedTimeline-trim-controls {
- height: 100%;
+::-webkit-scrollbar {
+ height: 5px;
+}
+
+.collectionStackedTimeline {
position: absolute;
- box-sizing: border-box;
- border: 2px solid $medium-blue;
- display: flex;
- justify-content: space-between;
- max-width: 100%;
+ background: $off-white;
+ z-index: 1000;
+ height: 100%;
- .collectionStackedTimeline-trim-handle {
- background-color: $medium-blue;
- height: 100%;
- width: 5px;
- cursor: ew-resize;
+ .collectionStackedTimeline-trim-shade {
+ position: absolute;
+ height: 100%;
+ background-color: $dark-gray;
+ opacity: 0.3;
+ top: 0;
}
- }
-
- .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-current {
- width: 1px;
- height: 100%;
- background-color: $pink;
- position: absolute;
- top: 0px;
- pointer-events: none;
- }
+ .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%;
+ top: 0;
+ left: 0;
- .collectionStackedTimeline-marker-timeline {
- position: absolute;
- top: 2.5%;
- height: 95%;
- border-radius: 4px;
- &:hover {
- opacity: 1;
+ .collectionStackedTimeline-trim-handle {
+ background-color: $medium-blue;
+ height: 100%;
+ width: 5px;
+ cursor: ew-resize;
+ }
}
- .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-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-resizer {
- right: 0;
+
+ .collectionStackedTimeline-current {
+ width: 1px;
+ height: 100%;
+ background-color: $pink;
+ position: absolute;
+ top: 0px;
+ pointer-events: none;
}
- .collectionStackedTimeline-left-resizer {
- left: 0;
+
+ .collectionStackedTimeline-marker-timeline {
+ position: absolute;
+ top: 2.5%;
+ height: 95%;
+ border-radius: 4px;
+ background: $light-gray;
+ &:hover {
+ opacity: 1;
+ }
+
+ .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;
+ }
+ .collectionStackedTimeline-left-resizer {
+ left: 0;
+ }
}
- }
- .collectionStackedTimeline-waveform {
- position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- pointer-events: none;
- }
+ .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 e09e9aa35..61ddbe70d 100644
--- a/src/client/views/collections/CollectionStackedTimeline.tsx
+++ b/src/client/views/collections/CollectionStackedTimeline.tsx
@@ -4,8 +4,7 @@ import {
computed,
IReactionDisposer,
observable,
- reaction,
- runInAction
+ reaction
} from "mobx";
import { observer } from "mobx-react";
import { computedFn } from "mobx-utils";
@@ -20,9 +19,7 @@ import {
formatTime,
OmitKeys,
returnFalse,
- returnOne,
- setupMoveUpEvents,
- StopEvent
+ returnOne, returnTrue, setupMoveUpEvents, smoothScrollHorizontal, StopEvent
} from "../../../Utils";
import { Docs } from "../../documents/Documents";
import { DocumentManager } from "../../util/DocumentManager";
@@ -32,7 +29,7 @@ import { ScriptingGlobals } from "../../util/ScriptingGlobals";
import { SelectionManager } from "../../util/SelectionManager";
import { SnappingManager } from "../../util/SnappingManager";
import { Transform } from "../../util/Transform";
-import { undoBatch } from "../../util/UndoManager";
+import { undoBatch, UndoManager } from "../../util/UndoManager";
import { AudioWaveform } from "../AudioWaveform";
import { CollectionSubView } from "../collections/CollectionSubView";
import { Colors } from "../global/globalEnums";
@@ -47,7 +44,6 @@ import { LabelBox } from "../nodes/LabelBox";
import "./CollectionStackedTimeline.scss";
export type CollectionStackedTimelineProps = {
- duration: number;
Play: () => void;
Pause: () => void;
playLink: (linkDoc: Doc) => void;
@@ -58,61 +54,50 @@ 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;
+ rawDuration: number;
+ fieldKey: string;
};
+export enum TrimScope {
+ All = 2,
+ Clip = 1,
+ None = 0,
+}
+
@observer
export class CollectionStackedTimeline extends CollectionSubView<CollectionStackedTimelineProps>() {
- @observable static SelectingRegion: CollectionStackedTimeline | undefined =
- undefined;
+ @observable static SelectingRegion: CollectionStackedTimeline | undefined = undefined;
+ @observable public static CurrentlyPlaying: Doc[];
+
static RangeScript: ScriptField;
static LabelScript: ScriptField;
static RangePlayScript: ScriptField;
static LabelPlayScript: ScriptField;
private _timeline: HTMLDivElement | null = null;
+ private _timelineWrapper: HTMLDivElement | null = null;
private _markerStart: number = 0;
- @observable _markerEnd: number = 0;
+ @observable _markerEnd: number | undefined;
+ @observable _trimming: number = TrimScope.None;
+ @observable _trimStart: number = 0;
+ @observable _trimEnd: number = 0;
- get minLength() {
- const rect = this._timeline?.getBoundingClientRect();
- if (rect) {
- return 0.05 * this.duration;
- }
- return 0;
- }
+ @observable _zoomFactor: number = 1;
- get trimStart() {
- return this.props.trimStart;
- }
+ @observable _scroll: number = 0;
- get trimEnd() {
- return this.props.trimEnd;
- }
+ get minTrimLength() { return Math.max(this._timeline?.getBoundingClientRect() ? 0.05 * this.clipDuration : 0, 0.5) }
+ @computed get trimStart() { return this.IsTrimming !== TrimScope.None ? this._trimStart : this.clipStart; }
+ @computed get trimDuration() { return this.trimEnd - this.trimStart; }
+ @computed get trimEnd() { return this.IsTrimming !== TrimScope.None ? this._trimEnd : this.clipEnd; }
- get duration() {
- return this.props.duration;
- }
+ @computed get clipStart() { return this.IsTrimming === TrimScope.All ? 0 : NumCast(this.layoutDoc.clipStart); }
+ @computed get clipDuration() { return this.clipEnd - this.clipStart; }
+ @computed get clipEnd() { return this.IsTrimming === TrimScope.All ? this.props.rawDuration : NumCast(this.layoutDoc.clipEnd, this.props.rawDuration); }
- @computed get currentTime() {
- return NumCast(this.layoutDoc._currentTimecode);
- }
- @computed get selectionContainer() {
- 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}%`,
- }}
- />
- );
- }
+ @computed get currentTime() { return NumCast(this.layoutDoc._currentTimecode); }
+
+ @computed get zoomFactor() { return this._zoomFactor }
constructor(props: any) {
super(props);
@@ -136,28 +121,41 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
componentDidMount() {
document.addEventListener("keydown", this.keyEvents, true);
}
+
+ @action
componentWillUnmount() {
document.removeEventListener("keydown", this.keyEvents, true);
if (CollectionStackedTimeline.SelectingRegion === this) {
- runInAction(
- () => (CollectionStackedTimeline.SelectingRegion = undefined)
- );
+ CollectionStackedTimeline.SelectingRegion = undefined;
}
}
- 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
- );
+ public get IsTrimming() { return this._trimming; }
+
+ @action
+ public StartTrimming(scope: TrimScope) {
+ this._trimStart = this.clipStart;
+ this._trimEnd = this.clipEnd;
+ this._trimming = scope;
+ }
+ @action
+ public StopTrimming() {
+ this.layoutDoc.clipStart = this.trimStart;
+ this.layoutDoc.clipEnd = this.trimEnd;
+ this._trimming = TrimScope.None;
+ }
+
+ @action
+ public setZoom(zoom: number) {
+ this._zoomFactor = zoom;
}
+
+ anchorStart = (anchor: Doc) => NumCast(anchor._timecodeToShow, NumCast(anchor[this.props.startTag]));
+ anchorEnd = (anchor: Doc, val: any = null) => NumCast(anchor._timecodeToHide, NumCast(anchor[this.props.endTag], val) ?? null);
toTimeline = (screen_delta: number, width: number) => {
return Math.max(
- this.trimStart,
- Math.min(this.trimEnd, (screen_delta / width) * this.props.trimDuration + this.trimStart));
+ this.clipStart,
+ Math.min(this.clipEnd, (screen_delta / width) * this.clipDuration + this.clipStart));
}
rangeClickScript = () => CollectionStackedTimeline.RangeScript;
@@ -170,22 +168,39 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
!(e.target instanceof HTMLInputElement) &&
this.props.isSelected(true)
) {
+ const jump = e.shiftKey ? 1 : 0.1;
+ e.stopPropagation();
switch (e.key) {
case " ":
if (!CollectionStackedTimeline.SelectingRegion) {
this._markerStart = this._markerEnd = this.currentTime;
CollectionStackedTimeline.SelectingRegion = this;
} else {
+ this._markerEnd = this.currentTime;
CollectionStackedTimeline.createAnchor(
this.rootDoc,
this.dataDoc,
this.props.fieldKey,
this.props.startTag,
this.props.endTag,
- this.currentTime
+ this._markerStart,
+ this._markerEnd
);
+ this._markerEnd = undefined;
CollectionStackedTimeline.SelectingRegion = undefined;
}
+ break;
+ case "Escape":
+ this._trimStart = this.clipStart;
+ this._trimStart = this.clipEnd;
+ this._trimming = TrimScope.None;
+ break;
+ case "ArrowLeft":
+ this.props.setTime(Math.min(Math.max(this.clipStart, this.currentTime - jump), this.clipEnd));
+ break;
+ case "ArrowRight":
+ this.props.setTime(Math.min(Math.max(this.clipStart, this.currentTime + jump), this.clipEnd));
+ break;
}
}
}
@@ -209,23 +224,19 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
onPointerDownTimeline = (e: React.PointerEvent): void => {
const rect = this._timeline?.getBoundingClientRect();
const clientX = e.clientX;
+ const diff = rect ? clientX - rect?.x : null;
+ const shiftKey = e.shiftKey;
if (rect && this.props.isContentActive()) {
const wasPlaying = this.props.playing();
if (wasPlaying) this.props.Pause();
- const wasSelecting = CollectionStackedTimeline.SelectingRegion === this;
+ var wasSelecting = this._markerEnd !== undefined;
setupMoveUpEvents(
this,
e,
action((e) => {
- if (
- !wasSelecting &&
- CollectionStackedTimeline.SelectingRegion !== this
- ) {
- this._markerStart = this._markerEnd = this.toTimeline(
- clientX - rect.x,
- rect.width
- );
- CollectionStackedTimeline.SelectingRegion = this;
+ if (!wasSelecting) {
+ this._markerStart = this._markerEnd = this.toTimeline(clientX - rect.x, rect.width);
+ wasSelecting = true;
}
this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width);
return false;
@@ -239,9 +250,8 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
}
if (
!isClick &&
- CollectionStackedTimeline.SelectingRegion === this &&
Math.abs(movement[0]) > 15 &&
- !this.props.trimming
+ !this.IsTrimming
) {
const anchor = CollectionStackedTimeline.createAnchor(
this.rootDoc,
@@ -255,11 +265,18 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
setTimeout(() => DocumentManager.Instance.getDocumentView(anchor)?.select(false));
}
(!isClick || !wasSelecting) &&
- (CollectionStackedTimeline.SelectingRegion = undefined);
+ (this._markerEnd = undefined);
}),
(e, doubleTap) => {
- this.props.select(false);
- e.shiftKey &&
+ if (e.button !== 2) {
+ this.props.select(false);
+ !wasPlaying && doubleTap && this.props.Play();
+ }
+ },
+ this.props.isSelected(true) || this.props.isContentActive(),
+ undefined,
+ () => {
+ if (shiftKey) {
CollectionStackedTimeline.createAnchor(
this.rootDoc,
this.dataDoc,
@@ -268,17 +285,9 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
this.props.endTag,
this.currentTime
);
- !wasPlaying && doubleTap && this.props.Play();
- },
- 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)
- );
+ } else {
+ !wasPlaying && this.props.setTime(this.toTimeline(clientX - rect.x, rect.width));
+ }
}
);
}
@@ -294,21 +303,19 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
e,
action((e, [], []) => {
if (rect && this.props.isContentActive()) {
- this.props.setStartTrim(Math.min(
+ this._trimStart = Math.min(
Math.max(
- this.trimStart + (e.movementX / rect.width) * this.duration,
- 0
+ this.trimStart + (e.movementX / rect.width) * this.clipDuration,
+ this.clipStart
),
- this.trimEnd - this.minLength
- ));
+ this.trimEnd - this.minTrimLength
+ );
}
return false;
}),
emptyFunction,
action((e, doubleTap) => {
- if (doubleTap) {
- this.props.setStartTrim(0);
- }
+ doubleTap && (this._trimStart = this.clipStart);
})
);
}
@@ -322,32 +329,59 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
e,
action((e, [], []) => {
if (rect && this.props.isContentActive()) {
- this.props.setEndTrim(Math.max(
+ this._trimEnd = Math.max(
Math.min(
- this.trimEnd + (e.movementX / rect.width) * this.duration,
- this.duration
+ this.trimEnd + (e.movementX / rect.width) * this.clipDuration,
+ this.clipEnd
),
- this.trimStart + this.minLength
- ));
+ this.trimStart + this.minTrimLength
+ );
}
return false;
}),
emptyFunction,
action((e, doubleTap) => {
- if (doubleTap) {
- this.props.setEndTrim(this.duration);
- }
+ doubleTap && (this._trimEnd = this.clipEnd);
})
);
}
@action
+ setScroll = (e: React.UIEvent) => {
+ e.stopPropagation();
+ this._scroll = this._timelineWrapper!.scrollLeft;
+ }
+
+ @action
+ scrollToTime = (time: number) => {
+ if (this._timelineWrapper) {
+ if (time > this.toTimeline(this._scroll + this.props.PanelWidth(), this.timelineContentWidth)) {
+ this._scroll = Math.min(this._scroll + this.props.PanelWidth(), this.timelineContentWidth - this.props.PanelWidth());
+ smoothScrollHorizontal(200, this._timelineWrapper, this._scroll);
+ }
+ else if (time < this.toTimeline(this._scroll, this.timelineContentWidth)) {
+ this._scroll = time / this.timelineContentWidth * this.clipDuration;
+ smoothScrollHorizontal(200, this._timelineWrapper, this._scroll);
+ }
+ }
+ }
+
+ @action
internalDocDrop(e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData, xp: number) {
if (!de.embedKey && this.props.layerProvider?.(this.props.Document) !== false && this.props.Document._isGroup) return false;
if (!super.onInternalDrop(e, de)) return false;
-
// determine x coordinate of drop and assign it to the documents being dragged --- see internalDocDrop of collectionFreeFormView.tsx for how it's done when dropping onto a 2D freeform view
+ const localPt = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y);
+ const x = localPt[0] - docDragData.offset[0];
+ const timelinePt = this.toTimeline(x + this._scroll, this.timelineContentWidth);
+ docDragData.droppedDocuments.forEach(drop => {
+ const anchorEnd = this.anchorEnd(drop);
+ if (anchorEnd !== undefined) {
+ Doc.SetInPlace(drop, drop._timecodeToHide === undefined ? this.props.endTag : "timecodeToHide", timelinePt + anchorEnd - this.anchorStart(drop), false);
+ }
+ Doc.SetInPlace(drop, drop._timecodeToShow === undefined ? this.props.startTag : "timecodeToShow", timelinePt, false);
+ });
return true;
}
@@ -379,10 +413,11 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
hideLinkButton: true,
annotationOn: rootDoc,
_timelineLabel: true,
+ borderRounding: anchorEndTime === undefined ? "100%" : undefined
});
Doc.GetProto(anchor)[startTag] = anchorStartTime;
Doc.GetProto(anchor)[endTag] = anchorEndTime;
- if (Cast(dataDoc[fieldKey], listSpec(Doc), null) !== undefined) {
+ if (Cast(dataDoc[fieldKey], listSpec(Doc), null)) {
Cast(dataDoc[fieldKey], listSpec(Doc), []).push(anchor);
} else {
dataDoc[fieldKey] = new List<Doc>([anchor]);
@@ -396,7 +431,10 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
const endTime = this.anchorEnd(anchorDoc);
if (this.layoutDoc.autoPlayAnchors) {
if (this.props.playing()) this.props.Pause();
- else this.props.playFrom(seekTimeInSeconds, endTime);
+ else {
+ this.props.playFrom(seekTimeInSeconds, endTime);
+ this.scrollToTime(seekTimeInSeconds);
+ }
} else {
if (
seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) &&
@@ -409,6 +447,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
}
} else {
this.props.playFrom(seekTimeInSeconds, endTime);
+ this.scrollToTime(seekTimeInSeconds);
}
}
return { select: true };
@@ -448,11 +487,11 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
m: Doc,
placed: { anchorStartTime: number; anchorEndTime: number; level: number }[]
) => {
- const timelineContentWidth = this.props.PanelWidth();
+ const timelineContentWidth = this.timelineContentWidth;
const x1 = this.anchorStart(m);
const x2 = this.anchorEnd(
m,
- x1 + (10 / timelineContentWidth) * this.duration
+ x1 + (10 / timelineContentWidth) * this.clipDuration
);
let max = 0;
const overlappedLevels = new Set(
@@ -478,14 +517,13 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
}
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;
+ @computed get timelineContentHeight() { return this.props.PanelHeight() * this.dictationHeightPercent / 100; }
+ @computed get timelineContentWidth() { return this.props.PanelWidth() * this.zoomFactor - 4 }; // subtract size of container border
+ dictationScreenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, -this.timelineContentHeight);
+ isContentActive = () => this.props.isSelected() || this.props.isContentActive();
+ currentTimecode = () => this.currentTime;
+
@computed get renderDictation() {
const dictation = Cast(this.dataDoc[this.props.dictationKey], Doc, null);
return !dictation ? null : (
@@ -493,7 +531,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
style={{
position: "absolute",
height: "100%",
- top: this.timelineContentHeight(),
+ top: this.timelineContentHeight,
background: Colors.LIGHT_BLUE,
}}
>
@@ -526,19 +564,33 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
return !this.props.mediaPath ? null : (
<div className="collectionStackedTimeline-waveform">
<AudioWaveform
- duration={this.duration}
+ rawDuration={this.props.rawDuration}
+ duration={this.clipDuration}
mediaPath={this.props.mediaPath}
layoutDoc={this.layoutDoc}
+ clipStart={this.clipStart}
+ clipEnd={this.clipEnd}
+ zoomFactor={this.zoomFactor}
PanelHeight={this.timelineContentHeight}
- trimming={this.props.trimming}
+ PanelWidth={this.timelineContentWidth}
/>
</div>
);
}
+ @computed get selectionContainer() {
+ const markerEnd = CollectionStackedTimeline.SelectingRegion === this ? this.currentTime : this._markerEnd;
+ return markerEnd === undefined ? null : (
+ <div
+ className="collectionStackedTimeline-selector"
+ style={{
+ left: `${((Math.min(this._markerStart, markerEnd) - this.trimStart) / this.trimDuration) * 100}%`,
+ width: `${(Math.abs(this._markerStart - markerEnd) / this.trimDuration) * 100}%`,
+ }}
+ />
+ );
+ }
- currentTimecode = () => this.currentTime;
render() {
- const timelineContentWidth = this.props.PanelWidth();
const overlaps: {
anchorStartTime: number;
anchorEndTime: number;
@@ -549,114 +601,125 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack
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 ref={this.createDashEventsTarget} style={{ pointerEvents: SnappingManager.GetIsDragging() ? "all" : undefined }}>
- <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="timeline-container"
+ style={{ width: this.props.PanelWidth() }}
+ onWheel={e => e.stopPropagation()}
+ onScroll={this.setScroll}
+ ref={wrapper => this._timelineWrapper = wrapper}>
<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
- }%`,
- }}
- >
+ className="collectionStackedTimeline"
+ ref={(timeline: HTMLDivElement | null) => (this._timeline = timeline)}
+ onClick={(e) => this.isContentActive() && StopEvent(e)}
+ onPointerDown={(e) => this.isContentActive() && this.onPointerDownTimeline(e)}
+ style={{ width: this.timelineContentWidth }}>
+
+ {drawAnchors.map((d) => {
+ const start = this.anchorStart(d.anchor);
+ const end = this.anchorEnd(
+ d.anchor,
+ start + (10 / this.timelineContentWidth) * this.clipDuration
+ );
+ if (end < this.clipStart || start > this.clipEnd) return (null);
+ const left = Math.max((start - this.clipStart) / this.clipDuration * this.timelineContentWidth, 0);
+ const top = (d.level / maxLevel) * this.props.PanelHeight();
+ const timespan = Math.max(0, Math.min(end - this.clipStart, this.clipEnd)) - Math.max(0, start - this.clipStart);
+ const width = (timespan / this.clipDuration) * this.timelineContentWidth;
+ const height = this.props.PanelHeight() / maxLevel;
+ return this.props.Document.hideAnchors ? null : (
<div
- className="collectionStackedTimeline-trim-handle"
- onPointerDown={this.trimLeft}
- ></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 - this._scroll}
+ top={top}
+ width={width}
+ height={height}
+ toTimeline={this.toTimeline}
+ layoutDoc={this.layoutDoc}
+ // isDocumentActive={this.props.childDocumentsActive ? this.props.isDocumentActive : this.isContentActive}
+ currentTimecode={this.currentTimecode}
+ _timeline={this._timeline}
+ stackedTimeline={this}
+ trimStart={this.trimStart}
+ trimEnd={this.trimEnd}
+ />
+ </div>
+ );
+ })}
+ {!this.IsTrimming && this.selectionContainer}
+ {/* {this.renderAudioWaveform} */}
+ <AudioWaveform
+ rawDuration={this.props.rawDuration}
+ duration={this.clipDuration}
+ mediaPath={this.props.mediaPath}
+ layoutDoc={this.layoutDoc}
+ clipStart={this.clipStart}
+ clipEnd={this.clipEnd}
+ zoomFactor={this.zoomFactor}
+ PanelHeight={this.timelineContentHeight}
+ PanelWidth={this.timelineContentWidth}
+ />
+ {this.renderDictation}
+
+ <div
+ className="collectionStackedTimeline-current"
+ style={{
+ left: `${((this.currentTime - this.clipStart) / this.clipDuration) * 100}%`,
+ }}
+ />
+
+ {this.IsTrimming !== TrimScope.None && (
+ <>
<div
- className="collectionStackedTimeline-trim-handle"
- onPointerDown={this.trimRight}
+ className="collectionStackedTimeline-trim-shade"
+ style={{ width: `${((this.trimStart - this.clipStart) / this.clipDuration) * 100}%` }}
></div>
- </div>
- <div
- className="collectionStackedTimeline-trim-shade"
- style={{
- left: `${(this.trimEnd / this.duration) * 100}%`,
- width: `${((this.duration - this.trimEnd) / this.duration) * 100
- }%`,
- }}
- ></div>
- </>
- )}
+ <div
+ className="collectionStackedTimeline-trim-controls"
+ style={{
+ left: `${((this.trimStart - this.clipStart) / this.clipDuration) * 100}%`,
+ width: `${((this.trimEnd - this.trimStart) / this.clipDuration) * 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.clipStart) / this.clipDuration) * 100}%`,
+ width: `${((this.clipEnd - this.trimEnd) / this.clipDuration) * 100}%`,
+ }}
+ ></div>
+ </>
+ )}
+ </div>
</div>
- </div>);
+ </div >);
}
}
@@ -675,6 +738,7 @@ interface StackedTimelineAnchorProps {
endTag: string;
renderDepth: number;
layoutDoc: Doc;
+ isDocumentActive?: () => boolean;
ScreenToLocalTransform: () => Transform;
_timeline: HTMLDivElement | null;
focus: DocFocusFunc;
@@ -692,6 +756,12 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps>
super(props);
this._lastTimecode = this.props.currentTimecode();
}
+
+ 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)}`;
+ }
componentDidMount() {
this._disposer = reaction(
() => this.props.currentTimecode(),
@@ -712,6 +782,7 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps>
// 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))*/
+ !this.props.layoutDoc.dontAutoFollowLinks &&
DocListCast(this.props.mark.links).length &&
time > NumCast(this.props.mark[this.props.startTag]) &&
time < NumCast(this.props.mark[this.props.endTag]) &&
@@ -739,41 +810,45 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps>
const rect = (e.target as any).getBoundingClientRect();
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;
+ const changeAnchor = (anchor: Doc, left: boolean, time: number | undefined) => {
+ const timelineOnly = Cast(anchor[this.props.startTag], "number", null) !== undefined;
if (timelineOnly) {
+ if (!left && time !== undefined && time <= NumCast(anchor[this.props.startTag])) time = undefined;
Doc.SetInPlace(
anchor,
left ? this.props.startTag : this.props.endTag,
time,
true
);
+ if (!left) Doc.SetInPlace(anchor, "borderRounding", time !== undefined ? undefined : "100%", true);
}
else {
- left
- ? (anchor._timecodeToShow = time)
- : (anchor._timecodeToHide = time);
+ anchor[left ? "_timecodeToShow" : "_timecodeToHide"] = time;
}
return false;
};
+ var undo: UndoManager.Batch | undefined;
+
setupMoveUpEvents(
this,
e,
- (e) => changeAnchor(anchor, left, newTime(e)),
+ (e) => {
+ if (!undo) undo = UndoManager.StartBatch("drag anchor");
+ this.props.setTime(newTime(e));
+ return changeAnchor(anchor, left, newTime(e));
+ },
(e) => {
this.props.setTime(newTime(e));
this.props._timeline?.releasePointerCapture(e.pointerId);
+ undo?.end();
},
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)}`;
+ contextMenuItems = () => {
+ const resetTitle = { script: ScriptField.MakeFunction(`self.title = "#" + formatToTime(self["${this.props.startTag}"]) + "-" + formatToTime(self["${this.props.endTag}"])`)!, icon: "folder-plus", label: "Reset Title" };
+ return [resetTitle];
}
renderInner = computedFn(function (
@@ -781,10 +856,9 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps>
mark: Doc,
script: undefined | (() => ScriptField),
doublescript: undefined | (() => ScriptField),
- x: number,
- y: number,
- width: number,
- height: number
+ screenXf: () => Transform,
+ width: () => number,
+ height: () => number
) {
const anchor = observable({ view: undefined as any });
const focusFunc = (
@@ -803,42 +877,42 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps>
<DocumentView
key="view"
{...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit}
- ref={action((r: DocumentView | null) => (anchor.view = r))}
+ 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)
- }
+ isDocumentActive={this.props.isDocumentActive}
+ PanelWidth={width}
+ PanelHeight={height}
+ fitWidth={returnTrue}
+ ScreenToLocalTransform={screenXf}
focus={focusFunc}
rootSelected={returnFalse}
onClick={script}
- onDoubleClick={
- this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript
- }
+ onDoubleClick={this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript}
ignoreAutoHeight={false}
hideResizeHandles={true}
bringToFront={emptyFunction}
+ contextMenuItems={this.contextMenuItems}
scriptContext={this.props.stackedTimeline}
/>
),
};
});
+ anchorScreenToLocalXf = () => this.props.ScreenToLocalTransform().translate(-this.props.left, -this.props.top);
+ width = () => this.props.width;
+ height = () => this.props.height;
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
+ this.anchorScreenToLocalXf,
+ this.width,
+ this.height
);
return (
<>
diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx
index 7e57d0e89..d52746d11 100644
--- a/src/client/views/collections/TabDocView.tsx
+++ b/src/client/views/collections/TabDocView.tsx
@@ -220,8 +220,8 @@ export class TabDocView extends React.Component<TabDocViewProps> {
if (!pinProps?.audioRange && duration !== undefined) {
pinDoc.mediaStart = "manual";
pinDoc.mediaStop = "manual";
- pinDoc.presStartTime = 0;
- pinDoc.presEndTime = duration;
+ pinDoc.presStartTime = NumCast(doc.clipStart);
+ pinDoc.presEndTime = NumCast(doc.clipEnd, duration);
}
//save position
if (pinProps?.setPosition || pinDoc.isInkMask) {
diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.scss b/src/client/views/collections/collectionLinear/CollectionLinearView.scss
index 968048e39..e8df192cf 100644
--- a/src/client/views/collections/collectionLinear/CollectionLinearView.scss
+++ b/src/client/views/collections/collectionLinear/CollectionLinearView.scss
@@ -17,7 +17,7 @@
background-color: $medium-blue-alt;
}
- >input:not(:checked)~&.true {
+ > input:not(:checked) ~ &.true {
background-color: transparent;
}
@@ -31,7 +31,7 @@
overflow: visible !important;
}
- >span {
+ > span {
background: $dark-gray;
color: $white;
border-radius: 18px;
@@ -39,6 +39,10 @@
cursor: pointer;
}
+ .audio-title:hover {
+ text-decoration: underline;
+ }
+
.bottomPopup-background {
background: $medium-blue;
display: flex;
@@ -58,6 +62,7 @@
padding-right: 20px;
vertical-align: middle;
font-size: 12.5px;
+ pointer-events: all;
}
.bottomPopup-descriptions {
@@ -86,7 +91,7 @@
color: black;
}
- >label {
+ > label {
pointer-events: all;
cursor: pointer;
background-color: $medium-blue;
@@ -104,20 +109,20 @@
justify-content: center;
transition: 0.2s;
- &:hover{
+ &:hover {
filter: brightness(0.85);
}
}
- >input {
+ > input {
display: none;
}
- >input:not(:checked)~.collectionLinearView-content {
+ > input:not(:checked) ~ .collectionLinearView-content {
display: none;
}
- >input:checked~label {
+ > input:checked ~ label {
transform: rotate(45deg);
transition: transform 0.5s;
cursor: pointer;
@@ -151,4 +156,4 @@
}
}
}
-} \ No newline at end of file
+}
diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx
index 44762dbe3..160134b60 100644
--- a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx
+++ b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx
@@ -7,13 +7,17 @@ import { Doc, HeightSym, Opt, WidthSym } from '../../../../fields/Doc';
import { Id } from '../../../../fields/FieldSymbols';
import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types';
import { emptyFunction, returnEmptyDoclist, returnTrue, Utils } from '../../../../Utils';
+import { DocUtils } from '../../../documents/Documents';
+import { DocumentManager } from "../../../util/DocumentManager";
import { DragManager } from '../../../util/DragManager';
import { Transform } from '../../../util/Transform';
import { Colors, Shadows } from '../../global/globalEnums';
+import { AudioBox } from '../../nodes/AudioBox';
import { DocumentLinksButton } from '../../nodes/DocumentLinksButton';
import { DocumentView } from '../../nodes/DocumentView';
import { LinkDescriptionPopup } from '../../nodes/LinkDescriptionPopup';
import { StyleProp } from '../../StyleProvider';
+import { CollectionStackedTimeline } from '../CollectionStackedTimeline';
import { CollectionSubView } from '../CollectionSubView';
import { CollectionViewType } from '../CollectionView';
import "./CollectionLinearView.scss";
@@ -117,14 +121,14 @@ export class CollectionLinearView extends CollectionSubView() {
}
- getDisplayDoc = (doc: Doc) => {
+ getDisplayDoc = (doc: Doc, preview: boolean = false) => {
const nested = doc._viewType === CollectionViewType.Linear;
const hidden = doc.hidden === true;
let dref: Opt<HTMLDivElement>;
const docXf = () => this.getTransform(dref);
// const scalable = pair.layout.onClick || pair.layout.onDragStart;
- return hidden ? (null) : <div className={`collectionLinearView-docBtn`} key={doc[Id]} ref={r => dref = r || undefined}
+ return hidden ? (null) : <div className={preview ? "preview" : `collectionLinearView-docBtn`} key={doc[Id]} ref={r => dref = r || undefined}
style={{
pointerEvents: "all",
width: nested ? undefined : NumCast(doc._width),
@@ -158,7 +162,8 @@ export class CollectionLinearView extends CollectionSubView() {
docRangeFilters={this.props.docRangeFilters}
searchFilterDocs={this.props.searchFilterDocs}
ContainingCollectionView={undefined}
- ContainingCollectionDoc={undefined} />
+ ContainingCollectionDoc={undefined}
+ hideResizeHandles={true} />
</div>;
}
@@ -222,6 +227,16 @@ export class CollectionLinearView extends CollectionSubView() {
</Tooltip>
</span> : null}
+ {CollectionStackedTimeline.CurrentlyPlaying && CollectionStackedTimeline.CurrentlyPlaying.length != 0 && StrCast(this.layoutDoc.title) === "docked buttons" ? <span className="bottomPopup-background">
+ <span className="bottomPopup-text">
+ Currently playing: {CollectionStackedTimeline.CurrentlyPlaying.map((clip, i) =>
+ <span className="audio-title" onPointerDown={() => {
+ DocumentManager.Instance.jumpToDocument(clip, true);
+ }}>{clip.title + (i == CollectionStackedTimeline.CurrentlyPlaying.length - 1 ? "" : ",")} </span>
+ )}
+ </span>
+ </span> : null}
+
</div>
</div>;
}
diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss
index a6494e540..d40537776 100644
--- a/src/client/views/nodes/AudioBox.scss
+++ b/src/client/views/nodes/AudioBox.scss
@@ -1,222 +1,214 @@
@import "../global/globalCssVariables.scss";
-
-.audiobox-container,
-.audiobox-container-interactive {
+.audiobox-container {
width: 100%;
height: 100%;
position: inherit;
display: flex;
position: relative;
cursor: default;
+}
+
+.audiobox-recorder {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
- .audiobox-buttons {
+ .audiobox-dictation {
+ width: 40px;
+ background: $medium-gray;
+ color: $dark-gray;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &:hover {
+ color: $black;
+ }
+ }
+
+ .audiobox-start-record {
+ color: $white;
+ background: $dark-gray;
display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: $body-text;
width: 100%;
+ height: 100%;
+ gap: 5px;
+
+ &:hover {
+ background: $black;
+ }
+ }
+
+ .recording-controls {
+ display: flex;
+ flex-direction: row;
align-items: center;
+ justify-content: center;
+ gap: 5px;
+ width: 100%;
height: 100%;
+ background: $dark-gray;
+ color: white;
- .audiobox-dictation {
- position: relative;
+ .record-timecode {
+ font-size: $large-header;
+ }
+
+ .record-button {
+ cursor: pointer;
width: 30px;
- height: 100%;
+ height: 30px;
+ border-radius: 50%;
+ background: $dark-gray;
+ display: flex;
align-items: center;
- display: inherit;
- background: $medium-gray;
- left: 0px;
- color: $dark-gray;
+ justify-content: center;
+
+ svg {
+ width: 15px;
+ }
&:hover {
- color: $black;
- cursor: pointer;
+ background: $black;
}
}
}
+}
- .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-file {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background: $dark-gray;
+ width: 100%;
+ height: 100%;
+ color: $white;
- .audiobox-record-interactive,
- .audiobox-record {
- pointer-events: all;
+ .audiobox-button {
+ margin: 2.5px;
cursor: pointer;
- width: 100%;
- height: 100%;
- position: relative;
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ background: $dark-gray;
display: flex;
- flex-direction: row;
align-items: center;
justify-content: center;
- gap: 10px;
- color: white;
- font-weight: bold;
+
+ svg {
+ width: 15px;
+ }
+
+ &:hover {
+ background: $black;
+ }
+ }
+
+ svg {
+ width: 10px;
+ }
+
+ input[type="range"] {
+ width: 50px;
+ -webkit-appearance: none;
+ background: none;
+ margin: 5px;
}
- .audiobox-record {
- pointer-events: none;
+ input[type="range"]:focus {
+ outline: none;
}
- .recording {
- margin-top: auto;
- margin-bottom: auto;
+ input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
- height: 100%;
- position: relative;
- padding-right: 5px;
+ height: 6px;
+ cursor: pointer;
+ box-shadow: 0;
+ background: $light-gray;
+ border-radius: 3px;
+ }
+
+ input[type="range"]::-webkit-slider-thumb {
+ box-shadow: 0;
+ border: 0;
+ height: 10px;
+ width: 10px;
+ border-radius: 10px;
+ background: $medium-blue;
+ cursor: pointer;
+ -webkit-appearance: none;
+ margin-top: -2px;
+ }
+
+ .audiobox-controls {
display: flex;
flex-direction: row;
- justify-content: center;
+ justify-content: space-between;
align-items: center;
- gap: 7px;
- background-color: $medium-blue;
- padding: 10px;
+ width: 100%;
+ height: 30px;
- .time {
- position: relative;
- height: 100%;
- width: 100%;
- font-size: 16px;
- text-align: center;
+ .controls-left {
display: flex;
- justify-content: center;
- align-items: center;
- font-weight: bold;
+ flex-direction: row;
}
- .buttons {
- cursor: pointer;
- position: relative;
- margin-top: auto;
- margin-bottom: auto;
- width: 25px;
- width: 25px;
- padding: 5px;
- color: $dark-gray;
+ .controls-right {
+ display: flex;
+ flex-direction: row;
- &:hover {
- color: $black;
+ .audiobox-button {
+ width: 15px;
+ height: 15px;
+ margin: 0;
+
+ svg {
+ width: 10px;
+ }
}
}
}
- .audiobox-controls {
+ .audiobox-playback {
width: 100%;
height: 100%;
- position: relative;
- display: flex;
- background: $dark-gray;
+ background: $white;
- .audiobox-dictation {
+ .audiobox-timeline {
+ height: calc(100% - 50px);
+ width: 100%;
+ background: $white;
position: absolute;
- width: 40px;
- height: 100%;
- align-items: center;
- display: inherit;
- background: $medium-gray;
- left: 0px;
}
- .audiobox-player {
- margin-top: auto;
- margin-bottom: auto;
+ .audiobox-timeline > div {
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: 30px;
- height: 30px;
- border-radius: 50%;
- background-color: $dark-gray;
- color: $white;
- display: flex;
- align-items: center;
- justify-content: center;
- left: 5px;
-
- &:hover {
- background-color: $black;
- }
-
- svg {
- width: 100%;
- position: absolute;
- border-width: "thin";
- border-color: "white";
- }
- }
-
- .audiobox-dictation {
- position: relative;
- margin-top: auto;
- margin-bottom: auto;
- width: 25px;
- align-items: center;
- display: inherit;
- background: $medium-gray;
- }
-
- .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;
- }
+ height: 100%;
}
}
-}
-@media only screen and (max-device-width: 480px) {
- .audiobox-dictation {
- font-size: 5em;
+ .audiobox-timecodes {
display: flex;
- width: 100;
- justify-content: center;
- flex-direction: column;
+ flex-direction: row;
+ justify-content: space-between;
align-items: center;
- }
-
- .audiobox-container .audiobox-record,
- .audiobox-container-interactive .audiobox-record {
- font-size: 3em;
- }
+ width: 100%;
+ height: 20px;
+ padding: 3px;
+ font-size: $small-text;
- .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;
+ .bottom-controls-middle {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
}
-} \ No newline at end of file
+}
diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx
index 93377f1dc..c2b4d0eee 100644
--- a/src/client/views/nodes/AudioBox.tsx
+++ b/src/client/views/nodes/AudioBox.tsx
@@ -1,49 +1,54 @@
import React = require("react");
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- action,
- computed,
- IReactionDisposer,
- observable,
- reaction,
- runInAction
-} from "mobx";
+import { action, computed, IReactionDisposer, observable, runInAction } from "mobx";
import { observer } from "mobx-react";
import { DateField } from "../../../fields/DateField";
-import { Doc, DocListCast, Opt } from "../../../fields/Doc";
+import { Doc, DocListCast } from "../../../fields/Doc";
+import { documentSchema } from "../../../fields/documentSchemas";
+import { makeInterface } from "../../../fields/Schema";
import { ComputedField } from "../../../fields/ScriptField";
-import { Cast, NumCast } from "../../../fields/Types";
+import { Cast, DateCast, NumCast } from "../../../fields/Types";
import { AudioField, nullAudio } from "../../../fields/URLField";
-import { emptyFunction, formatTime } from "../../../Utils";
+import { emptyFunction, formatTime, OmitKeys, returnFalse, setupMoveUpEvents } from "../../../Utils";
import { DocUtils } from "../../documents/Documents";
import { Networking } from "../../Network";
import { CurrentUserUtils } from "../../util/CurrentUserUtils";
-import { SnappingManager } from "../../util/SnappingManager";
-import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline";
+import { DragManager } from "../../util/DragManager";
+import { undoBatch } from "../../util/UndoManager";
+import { CollectionStackedTimeline, TrimScope } from "../collections/CollectionStackedTimeline";
import { ContextMenu } from "../ContextMenu";
import { ContextMenuProps } from "../ContextMenuItem";
-import {
- ViewBoxAnnotatableComponent,
- ViewBoxAnnotatableProps
-} from "../DocComponent";
-import { Colors } from "../global/globalEnums";
+import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent";
import "./AudioBox.scss";
import { FieldView, FieldViewProps } from "./FieldView";
-import { LinkDocPreview } from "./LinkDocPreview";
declare class MediaRecorder {
constructor(e: any); // whatever MediaRecorder has
}
+
+type AudioDocument = makeInterface<[typeof documentSchema]>;
+const AudioDocument = makeInterface(documentSchema);
+
+enum media_state {
+ PendingRecording = "pendingRecording",
+ Recording = "recording",
+ Paused = "paused",
+ Playing = "playing"
+}
@observer
-export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() {
- 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 SetScrubTime = action((timeInMillisFrom1970: number) => {
+ AudioBox._scrubTime = 0;
+ AudioBox._scrubTime = timeInMillisFrom1970;
+ });
public static Enabled = false;
- static playheadWidth = 40; // width of playhead
- static heightPercent = 75; // height of timeline in percent of height of audioBox.
- static Instance: AudioBox;
+ static topControlsHeight = 30; // width of playhead
+ static bottomControlsHeight = 20; // height of timeline in percent of height of audioBox.
+ @observable static _scrubTime = 0;
+ _dropDisposer?: DragManager.DragDropDisposer;
_disposers: { [name: string]: IReactionDisposer } = {};
_ele: HTMLAudioElement | null = null;
_stackedTimeline = React.createRef<CollectionStackedTimeline>();
@@ -53,74 +58,47 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
_pauseEnd = 0;
_pausedTime = 0;
_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> = NumCast(this.layoutDoc._height);
+ @observable _finished: boolean = false;
+ @observable _volume: number = 1;
+ @observable _muted: boolean = false;
@observable _paused: boolean = false;
- @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);
+ // @observable rawDuration: number = 0; // computed from the length of the audio element when loaded
+ @computed get recordingStart() { return DateCast(this.dataDoc[this.fieldKey + "-recordingStart"])?.date.getTime(); }
+ @computed get rawDuration() { return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); } // bcz: shouldn't be needed since it's computed from audio element
+ // mehek: not 100% sure but i think due to the order in which things are loaded this is necessary ^^
+ // if you get rid of it and set the value to 0 the timeline and waveform will set their bounds incorrectly
+
+ @computed get miniPlayer() { return this.props.PanelHeight() < 50 }
+ @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 mediaState() { return this.layoutDoc.mediaState as media_state; }
+ @computed get path() { // returns the path of the audio file
+ const path = Cast(this.props.Document[this.fieldKey], AudioField, null)?.url.href || "";
+ return path === nullAudio ? "" : path;
}
- @computed get pauseTime() {
- return this._pauseEnd - this._pauseStart;
- } // total time paused to update the correct time
- @computed get heightPercent() {
- return AudioBox.heightPercent;
+ set mediaState(value) { this.layoutDoc.mediaState = value; }
+
+ get timeline() { return this._stackedTimeline.current; } // can't be computed since it's not observable
+
+ componentWillUnmount() {
+ this.removeCurrentlyPlaying();
+ this._dropDisposer?.();
+ Object.values(this._disposers).forEach((disposer) => disposer?.());
+ const ind = DocUtils.ActiveRecordings.indexOf(this);
+ ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1);
}
- constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) {
- super(props);
- AudioBox.Instance = this;
+ @action
+ componentDidMount() {
+ this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link.
- if (this.duration === undefined) {
- runInAction(
- () =>
- (this.Document[this.fieldKey + "-duration"] = this.Document.duration)
- );
+ if (this.path) {
+ this.mediaState = media_state.Paused;
+ this.setPlayheadTime(NumCast(this.layoutDoc.clipStart));
+ } else {
+ this.mediaState = undefined as any as media_state;
}
}
@@ -128,8 +106,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
let la1 = l.anchor1 as Doc;
let la2 = l.anchor2 as Doc;
const linkTime =
- this._stackedTimeline.current?.anchorStart(la2) ||
- this._stackedTimeline.current?.anchorStart(la1) ||
+ this.timeline?.anchorStart(la2) ||
+ this.timeline?.anchorStart(la1) ||
0;
if (Doc.AreProtosEqual(la1, this.dataDoc)) {
la1 = l.anchor2 as Doc;
@@ -139,154 +117,93 @@ 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
- );
- }
-
- componentWillUnmount() {
- Object.values(this._disposers).forEach((disposer) => disposer?.());
- const ind = DocUtils.ActiveRecordings.indexOf(this);
- ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1);
- }
-
- @action
- componentDidMount() {
- this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link.
-
- 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);
- 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
- { fireImmediately: true }
- );
+ 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 === media_state.Recording
+ ? (Date.now() - (this.recordingStart || 0)) / 1000
+ : undefined)
+ ) || this.rootDoc;
}
// 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.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;
+ if (this.mediaState !== media_state.Recording && this._ele) {
this.links
- .map((l) => this.getLinkData(l))
+ .map(l => this.getLinkData(l))
.forEach(({ la1, la2, linkTime }) => {
- if (
- linkTime > NumCast(this.layoutDoc._currentTimecode) &&
- linkTime < htmlEle.currentTime
- ) {
+ if (linkTime > NumCast(this.layoutDoc._currentTimecode) &&
+ linkTime < this._ele!.currentTime) {
Doc.linkFollowHighlight(la1);
}
});
- this.layoutDoc._currentTimecode = htmlEle.currentTime;
-
+ this.layoutDoc._currentTimecode = this._ele.currentTime;
+ this.timeline?.scrollToTime(NumCast(this.layoutDoc._currentTimecode));
}
}
- // pause play back
- Pause = action(() => {
- this._ele!.pause();
- this.mediaState = "paused";
- });
-
- // play audio for documents created during recording
- playFromTime = (absoluteTime: number) => {
- this.recordingStart &&
- this.playFrom((absoluteTime - this.recordingStart) / 1000);
- }
-
// play back the audio from time
@action
- playFrom = (seekTimeInSeconds: number, endTime: number = this._trimEnd, fullPlay: boolean = false) => {
- clearTimeout(this._play);
- if (Number.isNaN(this._ele?.duration)) {
+ playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => {
+ clearTimeout(this._play); // abort any previous clip ending
+ if (Number.isNaN(this._ele?.duration)) { // audio element isn't loaded yet... wait 1/2 second and try again
setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500);
- } else if (this._ele && AudioBox.Enabled) {
- if (seekTimeInSeconds < 0) {
- if (seekTimeInSeconds > -1) {
- setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000);
- } else {
- this.Pause();
- }
- } else if (this._trimStart <= endTime && seekTimeInSeconds <= this._trimEnd) {
- const start = Math.max(this._trimStart, seekTimeInSeconds);
- const end = Math.min(this._trimEnd, endTime);
+ }
+ else if (this.timeline && this._ele && AudioBox.Enabled) {
+ const end = Math.min(this.timeline.trimEnd, endTime ?? this.timeline.trimEnd);
+ const start = Math.max(this.timeline.trimStart, seekTimeInSeconds);
+ if (seekTimeInSeconds >= 0 && this.timeline.trimStart <= end && seekTimeInSeconds <= this.timeline.trimEnd) {
this._ele.currentTime = start;
this._ele.play();
- runInAction(() => (this.mediaState = "playing"));
- if (endTime !== this.duration) {
- this._play = setTimeout(
- () => {
- this._ended = fullPlay ? true : this._ended;
- this.Pause();
- },
- (end - start) * 1000
- ); // use setTimeout to play a specific duration
- }
+ this.mediaState = media_state.Playing;
+ this.addCurrentlyPlaying();
+ this._play = setTimeout(
+ () => {
+ if (fullPlay) this._finished = true;
+ // removes from currently playing if playback has reached end of range marker
+ else this.removeCurrentlyPlaying();
+ this.Pause();
+ },
+ (end - start) * 1000);
} else {
this.Pause();
}
}
}
+ // removes from currently playing display
+ @action
+ removeCurrentlyPlaying = () => {
+ if (CollectionStackedTimeline.CurrentlyPlaying) {
+ const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc.doc as Doc);
+ index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1);
+ }
+ }
+
+ @action
+ addCurrentlyPlaying = () => {
+ if (!CollectionStackedTimeline.CurrentlyPlaying) {
+ CollectionStackedTimeline.CurrentlyPlaying = [];
+ }
+ if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc.doc as Doc) == -1) {
+ CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc.doc as Doc);
+ }
+ }
+
// update the recording time
updateRecordTime = () => {
- if (this.mediaState === "recording") {
+ if (this.mediaState === media_state.Recording) {
setTimeout(this.updateRecordTime, 30);
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;
}
}
}
@@ -295,49 +212,57 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
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.fieldKey + "-recordingStart"] = new DateField();
DocUtils.ActiveRecordings.push(this);
this._recorder.ondataavailable = async (e: any) => {
console.log("Data available", e);
const [{ result }] = await Networking.UploadFilesToServer(e.data);
console.log("Data result", result);
if (!(result instanceof Error)) {
- this.props.Document[this.props.fieldKey] = new AudioField(result.accessPaths.agnostic.client);
+ this.props.Document[this.fieldKey] = new AudioField(result.accessPaths.agnostic.client);
}
};
this._recordStart = new Date().getTime();
- runInAction(() => (this.mediaState = "recording"));
- setTimeout(this.updateRecordTime, 0);
+ runInAction(() => this.mediaState = media_state.Recording);
+ setTimeout(this.updateRecordTime);
this._recorder.start();
- setTimeout(() => this._recorder && this.stopRecording(), 60 * 60 * 1000); // stop after an hour
+ setTimeout(this.stopRecording, 60 * 60 * 1000); // stop after an hour
+ }
+
+ @action
+ stopRecording = () => {
+ if (this._recorder) {
+ this._recorder.stop();
+ this._recorder = undefined;
+ this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000;
+ this.mediaState = media_state.Paused;
+ this._stream?.getAudioTracks()[0].stop();
+ const ind = DocUtils.ActiveRecordings.indexOf(this);
+ ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1);
+ }
}
// 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),
+ description: (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors",
+ event: e => 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),
+ description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered",
+ event: e => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks,
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),
+ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected",
+ event: e => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks,
+ icon: "expand-arrows-alt",
+ });
+ funcs.push({
+ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto" : "Auto") + " play anchors onClick",
+ event: e => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors,
icon: "expand-arrows-alt",
});
ContextMenu.Instance?.addItem({
@@ -347,23 +272,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
});
}
- // 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.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);
- });
-
// button for starting and stopping the recording
- recordClick = (e: React.MouseEvent) => {
+ Record = (e: React.MouseEvent) => {
if (e.button === 0 && !e.ctrlKey) {
this._recorder ? this.stopRecording() : this.recordAudioAnnotation();
e.stopPropagation();
@@ -372,33 +282,44 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
// for play button
Play = (e?: any) => {
- let start;
- if (this._ended || this._ele!.currentTime === this.duration) {
- start = this._trimStart;
- this._ended = false;
- }
- else {
- start = this._ele!.currentTime;
+ e?.stopPropagation?.();
+
+ if (this.timeline && this._ele) {
+ const eleTime = this._ele.currentTime;
+ let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime;
+ if (this._finished) {
+ this._finished = false;
+ start = this.timeline.trimStart;
+ }
+ this.playFrom(start, this.timeline.trimEnd, true);
}
+ }
- this.playFrom(start, this._trimEnd, true);
- e?.stopPropagation?.();
+ // pause play back
+ @action
+ Pause = () => {
+ if (this._ele) {
+ this._ele.pause();
+ this.mediaState = media_state.Paused;
+ if (!this._finished) clearTimeout(this._play);
+ this.removeCurrentlyPlaying();
+ }
}
// 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) +
+ NumCast(this.rootDoc.x),
+ NumCast(this.rootDoc.y) +
+ NumCast(this.layoutDoc._height) +
10,
- NumCast(this.props.Document._width),
- 2 * NumCast(this.props.Document._height)
+ NumCast(this.layoutDoc._width),
+ 2 * NumCast(this.layoutDoc._height)
);
Doc.GetProto(newDoc).recordingSource = this.dataDoc;
Doc.GetProto(newDoc).recordingStart = ComputedField.MakeFunction(
- `self.recordingSource["${this.props.fieldKey}-recordingStart"]`
+ `self.recordingSource["${this.fieldKey}-recordingStart"]`
);
Doc.GetProto(newDoc).mediaState = ComputedField.MakeFunction(
"self.recordingSource.mediaState"
@@ -410,25 +331,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
// ref for updating time
setRef = (e: HTMLAudioElement | null) => {
e?.addEventListener("timeupdate", this.timecodeChanged);
- e?.addEventListener("ended", this.Pause);
+ e?.addEventListener("ended", () => { this._finished = true; 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 : "";
- return path === nullAudio ? "" : path;
- }
-
- // returns the html audio element
- @computed get audio() {
- return <audio ref={this.setRef} className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}>
- <source src={this.path} type="audio/mpeg" />
- Not supported.
- </audio>;
- }
-
// pause the time during recording phase
@action
recordPause = (e: React.MouseEvent) => {
@@ -447,97 +353,204 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
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));
+ this.playFrom(this.timeline?.anchorStart(link) || 0, this.timeline?.anchorEnd(link));
} else {
- this._ele!.currentTime = this.layoutDoc._currentTimecode =
- stack?.anchorStart(link) || 0;
+ this._ele!.currentTime = this.layoutDoc._currentTimecode = this.timeline?.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);
+ const startTime = this.timeline?.anchorStart(la1) || this.timeline?.anchorStart(la2);
+ const endTime = this.timeline?.anchorEnd(la1) || this.timeline?.anchorEnd(la2);
if (startTime !== undefined) {
if (!this.layoutDoc.dontAutoPlayFollowedLinks) {
- endTime
- ? this.playFrom(startTime, endTime)
- : this.playFrom(startTime);
+ this.playFrom(startTime, endTime);
} else {
- this._ele!.currentTime = this.layoutDoc._currentTimecode =
- startTime;
+ 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;
+ timelineWhenChildContentsActiveChanged = (isActive: boolean) =>
+ this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)
+ timelineScreenToLocal = () =>
+ this.props.ScreenToLocalTransform().translate(0, -AudioBox.bottomControlsHeight)
+ setPlayheadTime = (time: number) => this._ele!.currentTime = this.layoutDoc._currentTimecode = time;
+ playing = () => this.mediaState === media_state.Playing;
+ isActiveChild = () => this._isAnyChildContentActive;
+
+ timelineWidth = () => this.props.PanelWidth();
+ timelineHeight = () => (this.props.PanelHeight() - (AudioBox.topControlsHeight + AudioBox.bottomControlsHeight))
+
+ @undoBatch
+ finishTrim = () => { // hides trim controls and displays new clip
+ this.Pause();
+ this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this._ele!.currentTime), this.timeline?.trimStart || 0));
+ this.timeline?.StopTrimming();
+ }
+
+ startTrim = (scope: TrimScope) => {
+ this.Pause();
+ this.timeline?.StartTrimming(scope);
+ }
+
+ onClipPointerDown = (e: React.PointerEvent) => {
+ e.stopPropagation();
+ this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => {
+ if (doubleTap) {
+ this.startTrim(TrimScope.All);
+ } else if (this.timeline) {
+ this.Pause();
+ this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip);
+ }
+ }));
+ }
+
+ zoom = (zoom: number) => {
+ this.timeline?.setZoom(zoom);
}
- // hides trim controls and displays new clip
@action
- finishTrim = () => {
- if (this.mediaState === "playing") {
- this.Pause();
+ setVolume = (volume: number) => {
+ if (this._ele) {
+ this._volume = volume;
+ this._ele.volume = volume;
+ if (this._muted) {
+ this.toggleMute();
+ }
}
- 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;
+ toggleMute = () => {
+ if (this._ele) {
+ this._muted = !this._muted;
+ this._ele.muted = this._muted;
+ }
}
- @action
- setEndTrim = (newEnd: number) => {
- this._trimEnd = newEnd;
+ setupTimelineDrop = (r: HTMLDivElement | null) => {
+ if (r && this.timeline) {
+ this._dropDisposer?.();
+ this._dropDisposer = DragManager.MakeDropTarget(r,
+ (e, de) => {
+ const [xp, yp] = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y);
+ de.complete.docDragData && this.timeline!.internalDocDrop(e, de, de.complete.docDragData, xp);
+ },
+ this.layoutDoc, undefined);
+ }
+ }
+
+ @computed get recordingControls() {
+ return <div className="audiobox-recorder">
+ <div className="audiobox-dictation" onClick={this.onFile}>
+ <FontAwesomeIcon
+ size="2x"
+ icon="file-alt" />
+ </div>
+ {[media_state.Recording, media_state.Playing].includes(this.mediaState) ?
+ <div className="recording-controls" onClick={e => e.stopPropagation()}>
+ <div className="record-button" onClick={this.Record}>
+ <FontAwesomeIcon
+ size="2x"
+ icon="stop" />
+ </div>
+ <div className="record-button" onClick={this._paused ? this.recordPlay : this.recordPause}>
+ <FontAwesomeIcon
+ size="2x"
+ icon={this._paused ? "play" : "pause"} />
+ </div>
+ <div className="record-timecode">
+ {formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))}
+ </div>
+ </div>
+ :
+ <div className="audiobox-start-record">
+ <FontAwesomeIcon icon="microphone" />
+ RECORD
+ </div>}
+ </div>
+ }
+
+ @computed get playbackControls() {
+ return <div className="audiobox-file" style={{
+ pointerEvents: this._isAnyChildContentActive || this.props.isContentActive() ? "all" : "none",
+ flexDirection: this.miniPlayer ? "row" : "column",
+ justifyContent: this.miniPlayer ? "flex-start" : "space-between"
+ }}>
+ <div className="audiobox-controls">
+ <div className="controls-left">
+ <div className="audiobox-button"
+ title={this.mediaState === media_state.Paused ? "play" : "pause"}
+ onPointerDown={this.mediaState === media_state.Paused ? this.Play : (e) => { e.stopPropagation(); this.Pause(); }}>
+ <FontAwesomeIcon icon={this.mediaState === media_state.Paused ? "play" : "pause"} size={"1x"} />
+ </div>
+
+ {!this.miniPlayer &&
+ <div className="audiobox-button"
+ title={this.timeline?.IsTrimming !== TrimScope.None ? "finish" : "trim"}
+ onPointerDown={this.onClipPointerDown}>
+ <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} size={"1x"} />
+ </div>}
+ </div>
+ <div className="controls-right">
+ <div className="audiobox-button"
+ title={this._muted ? "unmute" : "mute"}
+ onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}>
+ <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} />
+ </div>
+ <input type="range" step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume}
+ className="toolbar-slider volume"
+ onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.setVolume(Number(e.target.value)) }}
+ />
+ </div>
+ </div>
+
+ <div className="audiobox-playback" style={{ width: this.miniPlayer ? 0 : "100%" }}>
+ <div className="audiobox-timeline">
+ {this.renderTimeline}
+ </div>
+ </div>
+
+ {this.audio}
+
+ <div className="audiobox-timecodes">
+ <div className="timecode-current">
+ {this.timeline && formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this.timeline.clipStart)))}
+ </div>
+ {!this.miniPlayer &&
+ <div className="bottom-controls-middle">
+ <FontAwesomeIcon icon="search-plus" />
+ <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor}
+ className="toolbar-slider" id="zoom-slider"
+ onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }}
+ />
+ </div>}
+
+ <div className="timecode-duration">
+ {this.timeline && formatTime(Math.round(this.timeline.clipDuration))}
+ </div>
+ </div>
+
+
+ </div>
}
- 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)
- timelineWidth = () => this.props.PanelWidth() - AudioBox.playheadWidth;
@computed get renderTimeline() {
return (
<CollectionStackedTimeline
ref={this._stackedTimeline}
- {...this.props}
+ {...OmitKeys(this.props, ["CollectionFreeFormDocumentView"]).omit}
fieldKey={this.annotationKey}
dictationKey={this.fieldKey + "-dictation"}
mediaPath={this.path}
@@ -547,13 +560,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
focus={DocUtils.DefaultFocus}
bringToFront={emptyFunction}
CollectionView={undefined}
- duration={this.duration}
playFrom={this.playFrom}
- setTime={this.setAnchorTime}
+ setTime={this.setPlayheadTime}
playing={this.playing}
- whenChildContentsActiveChanged={
- this.timelineWhenChildContentsActiveChanged
- }
+ whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged}
moveDocument={this.moveDocument}
addDocument={this.addDocument}
removeDocument={this.removeDocument}
@@ -565,142 +575,33 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
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}
+ rawDuration={this.rawDuration}
/>
);
}
+ // returns the html audio element
+ @computed get audio() {
+ return <audio ref={this.setRef}
+ className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}
+ onLoadedData={action(e =>
+ (this._ele?.duration && this._ele?.duration !== Infinity) &&
+ (this.dataDoc[this.fieldKey + "-duration"] = this._ele.duration)
+ )}
+ >
+ <source src={this.path} type="audio/mpeg" />
+ Not supported.
+ </audio>;
+ }
render() {
- const interactive =
- SnappingManager.GetIsDragging() || this.props.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"
- }}
- 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>
- ) : (
- <div
- className={`audiobox-record${interactive}`}
- style={{ backgroundColor: Colors.DARK_GRAY }}
- >
- <FontAwesomeIcon icon="microphone" />
- RECORD
- </div>
- )}
- </div>
- ) : (
- <div
- className="audiobox-controls"
- style={{
- pointerEvents:
- this._isAnyChildContentActive || this.props.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="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>
- </div>
- )}
- </div>
- );
+ return <div
+ ref={this.setupTimelineDrop}
+ 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 ? this.recordingControls : this.playbackControls}
+ </div>;
}
}
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 5919cd8f2..cbc61ffdb 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -81,8 +81,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatabl
</div>;
};
const displayDoc = (which: string) => {
- const whichDoc = Cast(this.dataDoc[which], Doc, null);
- //if (whichDoc?.type === DocumentType.MARKER)
+ var whichDoc = Cast(this.dataDoc[which], Doc, null);
+ // if (whichDoc?.type === DocumentType.MARKER) whichDoc = Cast(whichDoc.annotationOn, Doc, null);
const targetDoc = Cast(whichDoc?.annotationOn, Doc, null) ?? whichDoc;
return whichDoc ? <>
<DocumentView
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 7092b335c..e7de87ea5 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -746,9 +746,9 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
}
!zorders && cm.addItem({ description: "ZOrder...", subitems: zorderItems, icon: "compass" });
- onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" });
+ !Doc.UserDoc().noviceMode && onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" });
!Doc.UserDoc().noviceMode && onClicks.push({ description: "Toggle Detail", event: this.setToggleDetail, icon: "concierge-bell" });
- onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" });
+ this.props.CollectionFreeFormDocumentView && onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" });
if (!this.Document.annotationOn) {
const options = cm.findByDescription("Options...");
@@ -798,6 +798,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
if (this.props.removeDocument && !Doc.IsSystem(this.rootDoc) && CurrentUserUtils.ActiveDashboard !== this.props.Document) { // need option to gray out menu items ... preferably with a '?' that explains why they're grayed out (eg., no permissions)
moreItems.push({ description: "Close", event: this.deleteClicked, icon: "times" });
}
+ !more && moreItems.length && cm.addItem({ description: "More...", subitems: moreItems, icon: "compass" });
const help = cm.findByDescription("Help...");
const helpItems: ContextMenuProps[] = help && "subitems" in help ? help.subitems : [];
diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx
index 4e0461650..0015f0b71 100644
--- a/src/client/views/nodes/LabelBox.tsx
+++ b/src/client/views/nodes/LabelBox.tsx
@@ -33,7 +33,7 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro
}
getTitle() {
- return this.props.label ? this.props.label : this.rootDoc["title-custom"] ? StrCast(this.rootDoc.title) :
+ return this.rootDoc["title-custom"] ? StrCast(this.rootDoc.title) : this.props.label ? this.props.label :
typeof this.rootDoc[this.fieldKey] === "string" ? StrCast(this.rootDoc[this.fieldKey]) : StrCast(this.rootDoc.title);
}
@@ -47,14 +47,14 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro
get paramsDoc() { return Doc.AreProtosEqual(this.layoutDoc, this.dataDoc) ? this.dataDoc : this.layoutDoc; }
specificContextMenu = (e: React.MouseEvent): void => {
const funcs: ContextMenuProps[] = [];
- funcs.push({
+ !Doc.UserDoc().noviceMode && funcs.push({
description: "Clear Script Params", event: () => {
const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []);
params?.map(p => this.paramsDoc[p] = undefined);
}, icon: "trash"
});
- ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" });
+ funcs.length && ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" });
}
@undoBatch
@@ -87,7 +87,7 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro
style={{ boxShadow: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BoxShadow) }}>
<div className="labelBox-mainButton" style={{
backgroundColor: this.hoverColor,
- fontSize: StrCast(this.layoutDoc._fontSize) || Math.min(18, this.props.PanelHeight() / 2),
+ fontSize: StrCast(this.layoutDoc._fontSize),
fontFamily: StrCast(this.layoutDoc._fontFamily) || "inherit",
letterSpacing: StrCast(this.layoutDoc.letterSpacing),
textTransform: StrCast(this.layoutDoc.textTransform) as any,
diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss
index f0d7bd2f3..3cf10a033 100644
--- a/src/client/views/nodes/VideoBox.scss
+++ b/src/client/views/nodes/VideoBox.scss
@@ -1,4 +1,6 @@
-.mini-viewer{
+@import "../global/globalCssVariables.scss";
+
+.mini-viewer {
cursor: grab;
position: absolute;
right: 10;
@@ -14,22 +16,21 @@
height: 100%;
position: relative;
.videoBox-viewer {
- display:flex;
+ display: flex;
flex-direction: column;
height: 100%;
border-radius: inherit;
- opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger
+ opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger
+ background: $dark-gray;
}
.inkingCanvas-paths-markers {
- opacity : 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround
- }
- .collectionStackedTimeline {
- background: beige;
+ opacity: 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround
}
+
.videoBox-stackPanel {
z-index: -1;
width: 100%;
- position: relative;
+ position: relative;
}
.videoBox-annotationLayer {
@@ -43,8 +44,11 @@
}
}
-.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen,
-.videoBox-content, .videoBox-content-interactive, .videoBox-cont-fullScreen {
+.videoBox-content-YouTube,
+.videoBox-content-YouTube-fullScreen,
+.videoBox-content,
+.videoBox-content-interactive,
+.videoBox-cont-fullScreen {
width: 100%;
z-index: -1; // 0; // logically this should be 0 (or unset) which would give us transparent brush strokes over videos. However, this makes Chrome crawl to a halt
position: absolute;
@@ -62,7 +66,8 @@
left: 0px;
}
-.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen {
+.videoBox-content-YouTube,
+.videoBox-content-YouTube-fullScreen {
height: 100%;
}
@@ -73,25 +78,138 @@
.videoBox-ui {
position: absolute;
flex-direction: row;
- right: 5px;
- top: 5px;
- display: none;
- background-color: rgba(0, 0, 0, 0.6);
+ align-items: center;
+ justify-content: center;
+ display: flex;
+ visibility: none;
+ opacity: 0;
+ background-color: $dark-gray;
+ color: white;
+ border-radius: 100px;
+ top: calc(100% - 20px);
+ left: 50%;
+ transform: translate(-50%, -100%);
+
+ transition: top 0.5s, width 0.5s, opacity 0.2s, visibility 0s;
+ height: 120px;
+ padding: 0 20px;
+
+ .timecode-controls {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ margin: 0 5px;
+ flex-grow: 2;
+ font-size: 32px;
+
+ .timecode {
+ margin: 0 5px;
+ }
+
+ .timeline-slider {
+ margin: 0 20px 0 20px;
+ flex-grow: 2;
+ }
+ }
+
+ .toolbar-slider.volume, .toolbar-slider.zoom {
+ width: 100px;
+ }
+
+ .videobox-button {
+ margin: 5px;
+ cursor: pointer;
+ width: 70px;
+ height: 70px;
+ border-radius: 50%;
+ background: $dark-gray;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: $black;
+ }
+
+ svg {
+ width: 40px;
+ height: 40px;
+ }
+ }
}
-.videoBox-time, .videoBox-snapshot, .videoBox-timelineButton, .videoBox-play, .videoBox-full {
- color : white;
+
+.videoBox-time,
+.videoBox-snapshot,
+.videoBox-timelineButton,
+.videoBox-play,
+.videoBox-full {
+ color: white;
position: relative;
transform-origin: left top;
- pointer-events:all;
+ pointer-events: all;
padding-right: 5px;
cursor: pointer;
&:hover {
- background-color: gray;
+ background-color: $medium-gray;
}
}
.videoBox:hover {
.videoBox-ui {
- display: flex;
+ visibility: visible;
+ opacity: 1;
+ z-index: 10000;
}
+}
+
+.videoBox-content-fullScreen, .videoBox-content-fullScreen-interactive {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &:hover {
+ .videoBox-ui {
+ opacity: 0;
+ }
+ }
+
+ .videoBox-ui:hover {
+ opacity: 1;
+ }
+}
+
+video::-webkit-media-controls {
+ display: none !important;
+}
+
+input[type="range"] {
+ -webkit-appearance: none;
+ background: none;
+ margin: 10px;
+}
+
+input[type="range"]:focus {
+ outline: none;
+}
+
+input[type="range"]::-webkit-slider-runnable-track {
+ width: 100%;
+ height: 20px;
+ cursor: pointer;
+ box-shadow: 0;
+ background: $light-gray;
+ border-radius: 20px;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ box-shadow: 0;
+ border: 0;
+ height: 26px;
+ width: 26px;
+ border-radius: 20px;
+ background: $medium-blue;
+ cursor: pointer;
+ -webkit-appearance: none;
+ margin-top: -3px;
} \ No newline at end of file
diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index 33fdc4935..fafa39deb 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -17,8 +17,9 @@ import { CurrentUserUtils } from "../../util/CurrentUserUtils";
import { DocumentManager } from "../../util/DocumentManager";
import { SelectionManager } from "../../util/SelectionManager";
import { SnappingManager } from "../../util/SnappingManager";
+import { undoBatch } from "../../util/UndoManager";
import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView";
-import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline";
+import { CollectionStackedTimeline, TrimScope } from "../collections/CollectionStackedTimeline";
import { ContextMenu } from "../ContextMenu";
import { ContextMenuProps } from "../ContextMenuItem";
import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent";
@@ -27,20 +28,40 @@ import { MarqueeAnnotator } from "../MarqueeAnnotator";
import { AnchorMenu } from "../pdf/AnchorMenu";
import { StyleProp } from "../StyleProvider";
import { FieldView, FieldViewProps } from './FieldView';
-import { LinkDocPreview } from "./LinkDocPreview";
import "./VideoBox.scss";
+const path = require('path');
+
+type VideoDocument = makeInterface<[typeof documentSchema]>;
+const VideoDocument = makeInterface(documentSchema);
@observer
export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() {
public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); }
+ static async convertDataUri(imageUri: string, returnedFilename: string) {
+ try {
+ const posting = Utils.prepend("/uploadURI");
+ const returnedUri = await rp.post(posting, {
+ body: {
+ uri: imageUri,
+ name: returnedFilename
+ },
+ json: true,
+ });
+ return returnedUri;
+
+ } catch (e) {
+ console.log("VideoBox :" + e);
+ }
+ }
static _youtubeIframeCounter: number = 0;
- static Instance: VideoBox;
- static heightPercent = 60; // height of timeline in percent of height of videoBox.
+ static heightPercent = 80; // height of timeline in percent of height of videoBox.
private _disposers: { [name: string]: IReactionDisposer } = {};
private _youtubePlayer: YT.Player | undefined = undefined;
private _videoRef: HTMLVideoElement | null = null;
+ private _contentRef: HTMLDivElement | null = null;
private _youtubeIframeId: number = -1;
private _youtubeContentCreated = false;
+ private _audioPlayer: HTMLAudioElement | null = null;
private _stackedTimeline = React.createRef<CollectionStackedTimeline>();
private _mainCont: React.RefObject<HTMLDivElement> = React.createRef();
private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef();
@@ -55,44 +76,69 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
@observable _playTimer?: NodeJS.Timeout = undefined;
@observable _fullScreen = false;
@observable _playing = false;
+ @observable _finished: boolean = false;
+ @observable _volume: number = 1;
+ @observable _muted: boolean = false;
+
@computed get links() { return DocListCast(this.dataDoc.links); }
@computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); }
- @computed get duration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); }
+ // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); }
+ @observable rawDuration: number = 0;
+
+ @computed get youtubeVideoId() {
+ const field = Cast(this.dataDoc[this.props.fieldKey], VideoField);
+ return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : "";
+ }
+ // returns the path of the audio file
+ @computed get audiopath() {
+ const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null);
+ const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null);
+ return field?.url.href ?? vfield?.url.href ?? "";
+ }
+ private get timeline() { return this._stackedTimeline.current; }
private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; }
public get player(): HTMLVideoElement | null { return this._videoRef; }
- constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) {
- super(props);
- VideoBox.Instance = this;
- }
-
- getAnchor = () => {
- const timecode = Cast(this.layoutDoc._currentTimecode, "number", null);
- const marquee = AnchorMenu.Instance.GetAnchor?.();
- return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc;
+ componentDidMount() {
+ this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link.
+ if (this.youtubeVideoId) {
+ const youtubeaspect = 400 / 315;
+ const nativeWidth = Doc.NativeWidth(this.layoutDoc);
+ const nativeHeight = Doc.NativeHeight(this.layoutDoc);
+ if (!nativeWidth || !nativeHeight) {
+ if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600);
+ Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect);
+ this.layoutDoc._height = (this.layoutDoc._width || 0) / youtubeaspect;
+ }
+ }
+ this.player && this.setPlayheadTime(0);
}
- videoLoad = () => {
- const aspect = this.player!.videoWidth / this.player!.videoHeight;
- Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth);
- Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight);
- this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect;
- if (Number.isFinite(this.player!.duration)) {
- this.dataDoc[this.fieldKey + "-duration"] = this.player!.duration;
- }
+ componentWillUnmount() {
+ this.removeCurrentlyPlaying();
+ this.Pause();
+ Object.keys(this._disposers).forEach(d => this._disposers[d]?.());
}
@action public Play = (update: boolean = true) => {
this._playing = true;
- try {
- this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime);
- update && this.player?.play();
- update && this._audioPlayer?.play();
- update && this._youtubePlayer?.playVideo();
- this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5));
- } catch (e) {
- console.log("Video Play Exception:", e);
+ const eleTime = this.player?.currentTime || 0;
+ if (this.timeline) {
+ let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime;
+ if (this._finished) {
+ this._finished = false;
+ start = this.timeline.trimStart;
+ }
+ try {
+ this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime);
+ update && this.player && this.playFrom(start, undefined, true);
+ update && this._audioPlayer?.play();
+ update && this._youtubePlayer?.playVideo();
+ this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5));
+ } catch (e) {
+ console.log("Video Play Exception:", e);
+ }
}
this.updateTimecode();
}
@@ -109,6 +155,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
@action public Pause = (update: boolean = true) => {
this._playing = false;
+ this.removeCurrentlyPlaying();
try {
update && this.player?.pause();
update && this._audioPlayer?.pause();
@@ -124,8 +171,15 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
}
@action public FullScreen = () => {
- this._fullScreen = true;
- this.player && this.player.requestFullscreen();
+ if (document.fullscreenElement == this._contentRef) {
+ this._fullScreen = false;
+ this.player && this._contentRef && document.exitFullscreen();
+ }
+ else {
+ this._fullScreen = true;
+ this.player && this._contentRef && this._contentRef.requestFullscreen();
+
+ }
try {
this._youtubePlayer && this.props.addDocTab(this.rootDoc, "add");
} catch (e) {
@@ -176,7 +230,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
}
}
- private createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => {
+ createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => {
const url = !imagePath.startsWith("/") ? Utils.CorsProxy(imagePath) : imagePath;
const width = NumCast(this.layoutDoc._width) || 1;
const height = NumCast(this.layoutDoc._height);
@@ -194,6 +248,22 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
(downX !== undefined && downY !== undefined) && DocumentManager.Instance.getFirstDocumentView(imageSummary)?.startDragging(downX, downY, "move", true));
}
+ getAnchor = () => {
+ const timecode = Cast(this.layoutDoc._currentTimecode, "number", null);
+ const marquee = AnchorMenu.Instance.GetAnchor?.();
+ return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc;
+ }
+
+ videoLoad = action(() => {
+ const aspect = this.player!.videoWidth / this.player!.videoHeight;
+ Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth);
+ Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight);
+ this.layoutDoc._height = (this.layoutDoc._width || 0) / aspect;
+ if (Number.isFinite(this.player!.duration)) {
+ this.rawDuration = this.player!.duration;
+ }
+ })
+
@action
updateTimecode = () => {
this.player && (this.layoutDoc._currentTimecode = this.player.currentTime);
@@ -204,69 +274,24 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
}
}
- componentDidMount() {
- this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link.
- this._disposers.triggerVideo = reaction(
- () => !LinkDocPreview.LinkInfo && this.props.renderDepth !== -1 ? NumCast(this.Document._triggerVideo, null) : undefined,
- time => time !== undefined && setTimeout(() => {
- this.player && this.Play();
- setTimeout(() => this.Document._triggerVideo = undefined, 10);
- }, this.player ? 0 : 250), // wait for mainCont and try again to play
- { fireImmediately: true }
- );
- this._disposers.triggerStop = reaction(
- () => this.props.renderDepth !== -1 && !LinkDocPreview.LinkInfo ? NumCast(this.Document._triggerVideoStop, null) : undefined,
- stop => stop !== undefined && setTimeout(() => {
- this.player && this.Pause();
- setTimeout(() => this.Document._triggerVideoStop = undefined, 10);
- }, this.player ? 0 : 250), // wait for mainCont and try again to play
- { fireImmediately: true }
- );
- if (this.youtubeVideoId) {
- const youtubeaspect = 400 / 315;
- const nativeWidth = Doc.NativeWidth(this.layoutDoc);
- const nativeHeight = Doc.NativeHeight(this.layoutDoc);
- if (!nativeWidth || !nativeHeight) {
- if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600);
- Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect);
- this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect;
- }
- }
- }
-
- componentWillUnmount() {
- this.Pause();
- Object.keys(this._disposers).forEach(d => this._disposers[d]?.());
- }
-
@action
setVideoRef = (vref: HTMLVideoElement | null) => {
this._videoRef = vref;
if (vref) {
this._videoRef!.ontimeupdate = this.updateTimecode;
// @ts-ignore
- vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen);
+ // vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen);
this._disposers.reactionDisposer?.();
this._disposers.reactionDisposer = reaction(() => NumCast(this.layoutDoc._currentTimecode),
time => !this._playing && (vref.currentTime = time), { fireImmediately: true });
}
}
- public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false) {
- try {
- const posting = Utils.prepend("/uploadURI");
- const returnedUri = await rp.post(posting, {
- body: {
- uri: imageUri,
- name: returnedFilename,
- nosuffix
- },
- json: true,
- });
- return returnedUri;
-
- } catch (e) {
- console.log("VideoBox :" + e);
+ @action
+ setContentRef = (cref: HTMLDivElement | null) => {
+ this._contentRef = cref;
+ if (cref) {
+ cref.onfullscreenchange = action((e) => this._fullScreen = (document.fullscreenElement == cref));
}
}
@@ -283,22 +308,19 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true });
}), icon: "expand-arrows-alt"
});
+ subitems.push({ description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered", event: () => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks, icon: "expand-arrows-alt" });
subitems.push({ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", event: () => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, icon: "expand-arrows-alt" });
subitems.push({ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt" });
- subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" });
+ // subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" });
+ // subitems.push({ description: "Start Trim All", event: () => this.startTrim(TrimScope.All), icon: "expand-arrows-alt" });
+ // subitems.push({ description: "Start Trim Clip", event: () => this.startTrim(TrimScope.Clip), icon: "expand-arrows-alt" });
+ // subitems.push({ description: "Stop Trim", event: () => this.finishTrim(), icon: "expand-arrows-alt" });
subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" });
ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" });
}
}
- // returns the path of the audio file
- @computed get audiopath() {
- const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null);
- const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null);
- return field?.url.href ?? vfield?.url.href ?? "";
- }
// ref for updating time
- _audioPlayer: HTMLAudioElement | null = null;
setAudioRef = (e: HTMLAudioElement | null) => this._audioPlayer = e;
@computed get content() {
const field = Cast(this.dataDoc[this.fieldKey], VideoField);
@@ -306,8 +328,9 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
const classname = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive;
return !field ? <div key="loading">Loading</div> :
<div className="videoBox-contentContainer" key="container" style={{ mixBlendMode: "multiply" }}>
- <div className={classname}>
- <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef}
+ <div className={classname} ref={this.setContentRef} onPointerDown={(e) => this._fullScreen && e.stopPropagation()}>
+ {this.uIButtons}
+ <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} style={this._fullScreen ? this.fullScreenSize() : {}}
onCanPlay={this.videoLoad}
controls={VideoBox._nativeControls}
onPlay={() => this.Play()}
@@ -326,11 +349,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
</div>;
}
- @computed get youtubeVideoId() {
- const field = Cast(this.dataDoc[this.props.fieldKey], VideoField);
- return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : "";
- }
-
@action youtubeIframeLoaded = (e: any) => {
if (!this._youtubeContentCreated) {
this._forceCreateYouTubeIFrame = !this._forceCreateYouTubeIFrame;
@@ -340,7 +358,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
this.loadYouTube(e.target);
}
- private loadYouTube = (iframe: any) => {
+ loadYouTube = (iframe: any) => {
let started = true;
const onYoutubePlayerStateChange = (event: any) => runInAction(() => {
if (started && event.data === YT.PlayerState.PLAYING) {
@@ -372,39 +390,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
});
}
}
- private get uIButtons() {
- const curTime = NumCast(this.layoutDoc._currentTimecode);
- const nonNativeControls = [
- <Tooltip title={<div className="dash-tooltip">{"playback"}</div>} key="play" placement="bottom">
- <div className="videoBox-play" onPointerDown={this.onPlayDown} >
- <FontAwesomeIcon icon={this._playing ? "pause" : "play"} size="lg" />
- </div>
- </Tooltip>,
- <Tooltip title={<div className="dash-tooltip">{"timecode"}</div>} key="time" placement="bottom">
- <div className="videoBox-time" onPointerDown={this.onResetDown} >
- <span>{formatTime(curTime)}</span>
- <span style={{ fontSize: 8 }}>{" " + Math.floor((curTime - Math.trunc(curTime)) * 100).toString().padStart(2, "0")}</span>
- </div>
- </Tooltip>,
- <Tooltip title={<div className="dash-tooltip">{"view full screen"}</div>} key="full" placement="bottom">
- <div className="videoBox-full" onPointerDown={this.FullScreen}>
- <FontAwesomeIcon icon="expand" size="lg" />
- </div>
- </Tooltip>];
- return <div className="videoBox-ui">
- {[...(VideoBox._nativeControls ? [] : nonNativeControls),
- <Tooltip title={<div className="dash-tooltip">{"snapshot current frame"}</div>} key="snap" placement="bottom">
- <div className="videoBox-snapshot" onPointerDown={this.onSnapshotDown} >
- <FontAwesomeIcon icon="camera" size="lg" />
- </div>
- </Tooltip>,
- <Tooltip title={<div className="dash-tooltip">{"show annotation timeline"}</div>} key="timeline" placement="bottom">
- <div className="videoBox-timelineButton" onPointerDown={this.onTimelineHdlDown}>
- <FontAwesomeIcon icon="eye" size="lg" />
- </div>
- </Tooltip>,]}
- </div>;
- }
onPlayDown = () => this._playing ? this.Pause() : this.Play();
@@ -421,14 +406,17 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
}, emptyFunction, () => this.Snapshot());
}
- onTimelineHdlDown = action((e: React.PointerEvent) => {
+ @action
+ onTimelineHdlDown = (e: React.PointerEvent) => {
this._clicking = true;
setupMoveUpEvents(this, e,
- action((e: PointerEvent) => {
+ action(encodeURIComponent => {
this._clicking = false;
if (this.props.isContentActive()) {
- const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY);
- this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100));
+ // const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY);
+ // this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100));
+
+ this.layoutDoc._timelineHeightPercent = 80;
}
return false;
}), emptyFunction,
@@ -436,12 +424,13 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
this.layoutDoc._timelineHeightPercent = this.heightPercent !== 100 ? 100 : VideoBox.heightPercent;
setTimeout(action(() => this._clicking = false), 500);
}, this.props.isContentActive(), this.props.isContentActive());
- });
+ }
onResetDown = (e: React.PointerEvent) => {
+ const start = this.timeline?.clipStart || 0;
setupMoveUpEvents(this, e,
- (e: PointerEvent) => {
- this.Seek(Math.max(0, NumCast(this.layoutDoc._currentTimecode) + Math.sign(e.movementX) * 0.0333));
+ e => {
+ this.Seek(Math.max(start, (this.layoutDoc._currentTimecode || 0) + Math.sign(e.movementX) * 0.0333));
e.stopImmediatePropagation();
return false;
},
@@ -449,6 +438,24 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
(e: PointerEvent) => this.layoutDoc._currentTimecode = 0);
}
+ @action
+ removeCurrentlyPlaying = () => {
+ if (CollectionStackedTimeline.CurrentlyPlaying) {
+ const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc.doc as Doc);
+ index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1);
+ }
+ }
+
+ @action
+ addCurrentlyPlaying = () => {
+ if (!CollectionStackedTimeline.CurrentlyPlaying) {
+ CollectionStackedTimeline.CurrentlyPlaying = [];
+ }
+ if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc.doc as Doc) == -1) {
+ CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc.doc as Doc);
+ }
+ }
+
@computed get youtubeContent() {
this._youtubeIframeId = VideoBox._youtubeIframeCounter++;
this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true;
@@ -470,47 +477,205 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
// play back the video from time
@action
- playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => {
+ playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => {
clearTimeout(this._playRegionTimer);
- this._playRegionDuration = endTime - seekTimeInSeconds;
if (Number.isNaN(this.player?.duration)) {
setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500);
- } else if (this.player) {
- if (seekTimeInSeconds < 0) {
- if (seekTimeInSeconds > -1) {
- setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000);
- } else {
- this.Pause();
- }
- } else if (seekTimeInSeconds <= this.player.duration) {
- this.player.currentTime = seekTimeInSeconds;
+ }
+ else if (this.player) {
+ const end = Math.min(this.timeline?.trimEnd ?? this.rawDuration, endTime ?? this.timeline?.trimEnd ?? this.rawDuration);
+ const start = Math.max(this.timeline?.trimStart ?? 0, seekTimeInSeconds);
+ this._playRegionDuration = end - start;
+ if (seekTimeInSeconds >= 0 && (this.timeline?.trimStart || 0) <= end && seekTimeInSeconds <= (this.timeline?.trimEnd || this.rawDuration)) {
+ this.player.currentTime = start;
this._audioPlayer && (this._audioPlayer.currentTime = seekTimeInSeconds);
this.player.play();
this._audioPlayer?.play();
- runInAction(() => this._playing = true);
- if (endTime !== this.duration) {
- this._playRegionTimer = setTimeout(() => this.Pause(), (this._playRegionDuration) * 1000); // use setTimeout to play a specific duration
- }
+ this._playing = true;
+ this.addCurrentlyPlaying();
+ this._playRegionTimer = setTimeout(
+ () => {
+ if (fullPlay) this._finished = true;
+ else this.removeCurrentlyPlaying();
+ this.Pause();
+ }, this._playRegionDuration * 1000);
} else {
this.Pause();
}
}
}
+ // hides trim controls and displays new clip
+ @undoBatch
+ finishTrim = action(() => {
+ this.Pause();
+ this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this.player!.currentTime), this.timeline?.trimStart || 0));
+ this.timeline?.StopTrimming();
+ });
+
+ startTrim = (scope: TrimScope) => {
+ this.Pause();
+ this.timeline?.StartTrimming(scope);
+ }
+
+ onClipPointerDown = (e: React.PointerEvent) => {
+ this.heightPercent >= 100 && this.onTimelineHdlDown(e);
+ this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => {
+ if (doubleTap) {
+ this.startTrim(TrimScope.All);
+ } else if (this.timeline) {
+ this.Pause();
+ this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip);
+ }
+ }));
+ }
+
+ @action
+ setVolume = (volume: number) => {
+ if (this.player) {
+ this._volume = volume;
+ this.player.volume = volume;
+ if (this._muted) {
+ this.toggleMute();
+ }
+ }
+ }
+
+ @action
+ toggleMute = () => {
+ if (this.player) {
+ this._muted = !this._muted;
+ this.player.muted = this._muted;
+ }
+ }
+
+ fullScreenSize() {
+ if (this._videoRef && this._videoRef.videoHeight / this._videoRef.videoWidth > 1) {
+ return { height: "100%" }
+ }
+ else {
+ return { width: "100%" }
+ }
+ }
+
+ zoom = (zoom: number) => {
+ this.timeline?.setZoom(zoom);
+ }
playLink = (doc: Doc) => {
const startTime = Math.max(0, (this._stackedTimeline.current?.anchorStart(doc) || 0));
- const endTime = this._stackedTimeline.current?.anchorEnd(doc);
+ const endTime = this.timeline?.anchorEnd(doc);
if (startTime !== undefined) {
if (!this.layoutDoc.dontAutoPlayFollowedLinks) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime);
else this.Seek(startTime);
}
}
- playing = () => this._playing;
+ marqueeDown = (e: React.PointerEvent) => {
+ if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) {
+ setupMoveUpEvents(this, e, action(e => {
+ MarqueeAnnotator.clearAnnotations(this._savedAnnotations);
+ this._marqueeing = [e.clientX, e.clientY];
+ return true;
+ }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false);
+ }
+ }
+
+ @action
+ finishMarquee = () => {
+ this._marqueeing = undefined;
+ this.props.select(true);
+ }
+
timelineWhenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive));
timelineScreenToLocal = () => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight());
- setAnchorTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time;
+ setPlayheadTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time;
timelineHeight = () => this.props.PanelHeight() * (100 - this.heightPercent) / 100;
+ playing = () => this._playing;
+
+ contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content];
+ scaling = () => this.props.scaling?.() || 1;
+ panelWidth = () => this.props.PanelWidth() * this.heightPercent / 100;
+ panelHeight = () => this.layoutDoc._fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.props.PanelHeight() * this.heightPercent / 100;
+ screenToLocalTransform = () => {
+ const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling();
+ return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent);
+ }
+ marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100;
+ marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0];
+ timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`];
+
+ @computed get uIButtons() {
+ const curTime = (this.layoutDoc._currentTimecode || 0) - (this.timeline?.clipStart || 0);
+ return <div className="videoBox-ui" style={this._fullScreen || this.heightPercent == 100 ? { fontSize: "40px", minWidth: "80%" } : {}}>
+ <div className="videobox-button"
+ title={this._playing ? "play" : "pause"}
+ onPointerDown={this.onPlayDown}>
+ <FontAwesomeIcon icon={this._playing ? "pause" : "play"} />
+ </div>
+
+ {this.timeline && <div className="timecode-controls">
+ <div className="timecode-current">
+ {formatTime(curTime)}
+ </div>
+
+ {this._fullScreen || this.heightPercent == 100 ?
+ <div className="timeline-slider">
+ <input type="range" step="0.1" min={this.timeline.clipStart} max={this.timeline.clipEnd} value={curTime}
+ className="toolbar-slider time-progress"
+ onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.setPlayheadTime(Number(e.target.value)) }}
+ />
+ </div>
+ :
+ <div>/</div>}
+
+ <div className="timecode-end">
+ {formatTime(this.timeline.clipDuration)}
+ </div>
+ </div>}
+
+ <div className="videobox-button"
+ title={"full screen"}
+ onPointerDown={this.onFullDown}>
+ <FontAwesomeIcon icon="expand" />
+ </div>
+
+ {!this._fullScreen && <div className="videobox-button"
+ title={"show timeline"}
+ onPointerDown={this.onTimelineHdlDown}>
+ <FontAwesomeIcon icon="eye" />
+ </div>}
+
+ {!this._fullScreen && <div className="videobox-button"
+ title={this.timeline?.IsTrimming !== TrimScope.None ? "finish trimming" : "start trim"}
+ onPointerDown={this.onClipPointerDown}>
+ <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} />
+ </div>}
+
+ <div className="videobox-button show-slider"
+ title={this._muted ? "unmute" : "mute"}
+ onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}>
+ <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} />
+ </div>
+ <input type="range" step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume}
+ className="toolbar-slider volume"
+ onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.setVolume(Number(e.target.value)) }}
+ />
+
+ {!this._fullScreen && this.heightPercent != 100 &&
+ <>
+ <div className="videobox-button" title="zoom">
+ <FontAwesomeIcon icon="search-plus" />
+ </div>
+ <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor}
+ className="toolbar-slider zoom"
+ onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }}
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }}
+ />
+ </>}
+ </div>
+ }
@computed get renderTimeline() {
return <div className="videoBox-stackPanel" style={{ transition: this.transition, height: `${100 - this.heightPercent}%` }}>
<CollectionStackedTimeline ref={this._stackedTimeline} {...this.props}
@@ -522,12 +687,13 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
endTag={"_timecodeToHide" /* videoEnd */}
bringToFront={emptyFunction}
CollectionView={undefined}
- duration={this.duration}
playFrom={this.playFrom}
- setTime={this.setAnchorTime}
+ setTime={this.setPlayheadTime}
playing={this.playing}
isAnyChildContentActive={this.isAnyChildContentActive}
whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged}
+ moveDocument={this.moveDocument}
+ addDocument={this.addDocument}
removeDocument={this.removeDocument}
ScreenToLocalTransform={this.timelineScreenToLocal}
Play={this.Play}
@@ -543,38 +709,9 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
/>
</div>;
}
-
@computed get annotationLayer() {
return <div className="videoBox-annotationLayer" style={{ transition: this.transition, height: `${this.heightPercent}%` }} ref={this._annotationLayer} />;
}
-
- marqueeDown = (e: React.PointerEvent) => {
- if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) {
- setupMoveUpEvents(this, e, action(e => {
- MarqueeAnnotator.clearAnnotations(this._savedAnnotations);
- this._marqueeing = [e.clientX, e.clientY];
- return true;
- }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false);
- }
- }
-
- finishMarquee = action(() => {
- this._marqueeing = undefined;
- this.props.select(true);
- });
-
- @computed get fitWidth() { return this.props.docViewPath?.().slice(-1)[0].fitWidth; }
- contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content];
- scaling = () => this.props.scaling?.() || 1;
- panelWidth = (): number => this.fitWidth ? this.props.PanelWidth() : (Doc.NativeAspect(this.rootDoc) || 1) * this.panelHeight();
- panelHeight = (): number => this.fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.heightPercent / 100 * this.props.PanelHeight();
- screenToLocalTransform = () => {
- const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling();
- return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent);
- }
- marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100;
- marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0];
- timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`];
render() {
const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding);
const borderRadius = borderRad?.includes("px") ? `${Number(borderRad.split("px")[0]) / this.scaling()}px` : borderRad;
@@ -627,7 +764,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp
mainCont={this._mainCont.current}
/>}
{this.renderTimeline}
- {this.uIButtons}
</div>
</div >);
}
diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx
index 9b74bb618..2e312ee51 100644
--- a/src/client/views/nodes/trails/PresBox.tsx
+++ b/src/client/views/nodes/trails/PresBox.tsx
@@ -201,26 +201,13 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
const targMedia = DocumentManager.Instance.getDocumentView(targetDoc);
targMedia?.ComponentView?.playFrom?.(NumCast(activeItem.presStartTime), NumCast(activeItem.presStartTime) + duration);
}
- // if (targetDoc.type === DocumentType.AUDIO) {
- // if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]);
- // targetDoc._triggerAudio = NumCast(activeItem.presStartTime);
- // this._mediaTimer = [setTimeout(() => targetDoc._audioStop = true, duration * 1000), targetDoc];
- // } else if (targetDoc.type === DocumentType.VID) {
- // targetDoc._triggerVideoStop = true;
- // setTimeout(() => targetDoc._currentTimecode = NumCast(activeItem.presStartTime), 10);
- // setTimeout(() => targetDoc._triggerVideo = true, 20);
- // this._mediaTimer = [setTimeout(() => targetDoc._triggerVideoStop = true, (duration * 1000) + 20), targetDoc];
- // }
}
stopTempMedia = (targetDocField: FieldResult) => {
const targetDoc = Cast(targetDocField, Doc, null);
- if (targetDoc?.type === DocumentType.AUDIO) {
- if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]);
- targetDoc._audioStop = true;
- } else if (targetDoc?.type === DocumentType.VID) {
- if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]);
- targetDoc._triggerVideoStop = true;
+ if ([DocumentType.VID, DocumentType.AUDIO].includes(targetDoc.type as any)) {
+ const targMedia = DocumentManager.Instance.getDocumentView(targetDoc);
+ targMedia?.ComponentView?.Pause?.();
}
}
@@ -1484,6 +1471,8 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
@computed get mediaOptionsDropdown() {
const activeItem: Doc = this.activeItem;
const targetDoc: Doc = this.targetDoc;
+ const clipStart: number = NumCast(activeItem.clipStart);
+ const clipEnd: number = NumCast(activeItem.clipEnd);
const duration = Math.round(NumCast(activeItem[`${Doc.LayoutFieldKey(activeItem)}-duration`]) * 10);
const mediaStopDocInd: number = NumCast(activeItem.mediaStopDoc);
const mediaStopDocStr: string = mediaStopDocInd ? mediaStopDocInd + ". " + this.childDocs[mediaStopDocInd - 1].title : "";
@@ -1529,7 +1518,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
</div>
</div>
<div className="multiThumb-slider">
- <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presEndTime)}
+ <input type="range" step="0.1" min={clipStart} max={clipEnd} value={NumCast(activeItem.presEndTime)}
style={{ gridColumn: 1, gridRow: 1 }}
className={`toolbar-slider ${"end"}`}
id="toolbar-slider"
@@ -1553,7 +1542,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
e.stopPropagation();
activeItem.presEndTime = Number(e.target.value);
}} />
- <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presStartTime)}
+ <input type="range" step="0.1" min={clipStart} max={clipEnd} value={NumCast(activeItem.presStartTime)}
style={{ gridColumn: 1, gridRow: 1 }}
className={`toolbar-slider ${"start"}`}
id="toolbar-slider"
@@ -1579,9 +1568,9 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() {
}} />
</div>
<div className={`slider-headers ${activeItem.presMovement === PresMovement.Pan || activeItem.presMovement === PresMovement.Zoom ? "" : "none"}`}>
- <div className="slider-text">0 s</div>
+ <div className="slider-text">{clipStart} s</div>
<div className="slider-text"></div>
- <div className="slider-text">{duration / 10} s</div>
+ <div className="slider-text">{clipEnd} s</div>
</div>
</div>
<div className="ribbon-final-box">
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 1253cf9c7..642becb46 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -346,6 +346,7 @@ export namespace Doc {
return GetT(doc, "system", "boolean", true);
}
export async function SetInPlace(doc: Doc, key: string, value: Field | undefined, defaultProto: boolean) {
+ if (key.startsWith("_")) key = key.substring(1);
const hasProto = doc.proto instanceof Doc;
const onDeleg = Object.getOwnPropertyNames(doc).indexOf(key) !== -1;
const onProto = hasProto && Object.getOwnPropertyNames(doc.proto).indexOf(key) !== -1;