diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts index 3c96725b1f55f131c0c0e5d0928ea216b50fcd38..5d2f310e4990e14406f48f5cc033c4c23fa48063 100644 --- a/src/mol-plugin/behavior/static/state.ts +++ b/src/mol-plugin/behavior/static/state.ts @@ -158,9 +158,7 @@ export function Snapshots(ctx: PluginContext) { PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => { const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); // fetch(url, { referrer: 'no-referrer' }); - const snapshot = ctx.state.snapshots.setRemoteSnapshot(json.data); - if (!snapshot) return; - return ctx.state.setSnapshot(snapshot); + await ctx.state.snapshots.setRemoteSnapshot(json.data); }); PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => { diff --git a/src/mol-plugin/skin/base/icons.scss b/src/mol-plugin/skin/base/icons.scss index 9568465c71aaa966452887374998e0e26b207d60..8f35f30ba64d1d1722b8920aefeb5590d244b406 100644 --- a/src/mol-plugin/skin/base/icons.scss +++ b/src/mol-plugin/skin/base/icons.scss @@ -164,4 +164,24 @@ .msp-icon-switch:before { content: "\e896"; +} + +.msp-icon-play:before { + content: "\e897"; +} + +.msp-icon-stop:before { + content: "\e898"; +} + +.msp-icon-pause:before { + content: "\e899"; +} + +.msp-icon-left-open:before { + content: "\e87c"; +} + +.msp-icon-right-open:before { + content: "\e87d"; } \ No newline at end of file diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts index 28ca1986e8724f8482c4de17802479dd2fdd67e8..2a21d0dc0a5de0c3ba8e9c89fffbcbd0698866f4 100644 --- a/src/mol-plugin/state.ts +++ b/src/mol-plugin/state.ts @@ -25,7 +25,7 @@ class PluginState { readonly behaviorState: State; readonly animation: PluginAnimationManager; readonly cameraSnapshots = new CameraSnapshotManager(); - readonly snapshots = new PluginStateSnapshotManager(); + readonly snapshots: PluginStateSnapshotManager; readonly behavior = { kind: this.ev.behavior<PluginState.Kind>('data'), @@ -89,6 +89,7 @@ class PluginState { } constructor(private plugin: import('./context').PluginContext) { + this.snapshots = new PluginStateSnapshotManager(plugin); this.dataState = State.create(new SO.Root({ }), { globalContext: plugin }); this.behaviorState = State.create(new PluginBehavior.Root({ }), { globalContext: plugin, rootProps: { isLocked: true } }); diff --git a/src/mol-plugin/state/snapshots.ts b/src/mol-plugin/state/snapshots.ts index 19623a610b052a75440c2e33488cdf5f5b79121b..a27a547dbcdb8b926cc2f624e3379e83ad93d7a8 100644 --- a/src/mol-plugin/state/snapshots.ts +++ b/src/mol-plugin/state/snapshots.ts @@ -8,14 +8,20 @@ import { List } from 'immutable'; import { UUID } from 'mol-util'; import { PluginState } from '../state'; import { PluginComponent } from 'mol-plugin/component'; +import { PluginContext } from 'mol-plugin/context'; export { PluginStateSnapshotManager } class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | undefined, entries: List<PluginStateSnapshotManager.Entry>, - entryMap: Map<string, PluginStateSnapshotManager.Entry> + isPlaying: boolean, + nextSnapshotDelayInMs: number }> { + static DefaultNextSnapshotDelayInMs = 1500; + + private entryMap = new Map<string, PluginStateSnapshotManager.Entry>(); + readonly events = { changed: this.ev() }; @@ -28,14 +34,14 @@ class PluginStateSnapshotManager extends PluginComponent<{ getEntry(id: string | undefined) { if (!id) return; - return this.state.entryMap.get(id); + return this.entryMap.get(id); } remove(id: string) { - const e = this.state.entryMap.get(id); + const e = this.entryMap.get(id); if (!e) return; - this.state.entryMap.delete(id); + this.entryMap.delete(id); this.updateState({ current: this.state.current === id ? void 0 : this.state.current, entries: this.state.entries.delete(this.getIndex(e)) @@ -44,7 +50,7 @@ class PluginStateSnapshotManager extends PluginComponent<{ } add(e: PluginStateSnapshotManager.Entry) { - this.state.entryMap.set(e.snapshot.id, e); + this.entryMap.set(e.snapshot.id, e); this.updateState({ current: e.snapshot.id, entries: this.state.entries.push(e) }); this.events.changed.next(); } @@ -56,7 +62,7 @@ class PluginStateSnapshotManager extends PluginComponent<{ const idx = this.getIndex(old); // The id changes here! const e = PluginStateSnapshotManager.Entry(snapshot, old.name, old.description); - this.state.entryMap.set(snapshot.id, e); + this.entryMap.set(snapshot.id, e); this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(idx, e) }); this.events.changed.next(); } @@ -82,7 +88,7 @@ class PluginStateSnapshotManager extends PluginComponent<{ clear() { if (this.state.entries.size === 0) return; - this.state.entryMap.clear(); + this.entryMap.clear(); this.updateState({ current: void 0, entries: List<PluginStateSnapshotManager.Entry>() }); this.events.changed.next(); } @@ -115,11 +121,11 @@ class PluginStateSnapshotManager extends PluginComponent<{ return this.state.entries.get(idx).snapshot.id; } - setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): PluginState.Snapshot | undefined { + async setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): Promise<PluginState.Snapshot | undefined> { this.clear(); const entries = List<PluginStateSnapshotManager.Entry>().asMutable() for (const e of snapshot.entries) { - this.state.entryMap.set(e.snapshot.id, e); + this.entryMap.set(e.snapshot.id, e); entries.push(e); } const current = snapshot.current @@ -127,11 +133,20 @@ class PluginStateSnapshotManager extends PluginComponent<{ : snapshot.entries.length > 0 ? snapshot.entries[0].snapshot.id : void 0; - this.updateState({ current, entries: entries.asImmutable() }); + this.updateState({ + current, + entries: entries.asImmutable(), + isPlaying: false, + nextSnapshotDelayInMs: snapshot.playback ? snapshot.playback.nextSnapshotDelayInMs : PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs + }); this.events.changed.next(); if (!current) return; - const ret = this.getEntry(current); - return ret && ret.snapshot; + const entry = this.getEntry(current); + const next = entry && entry.snapshot; + if (!next) return; + await this.plugin.state.setSnapshot(next); + if (snapshot.playback && snapshot.playback.isPlaying) this.play(); + return next; } getRemoteSnapshot(options?: { name?: string, description?: string }): PluginStateSnapshotManager.RemoteSnapshot { @@ -141,12 +156,52 @@ class PluginStateSnapshotManager extends PluginComponent<{ name: options && options.name, description: options && options.description, current: this.state.current, + playback: { + isPlaying: this.state.isPlaying, + nextSnapshotDelayInMs: this.state.nextSnapshotDelayInMs + }, entries: this.state.entries.valueSeq().toArray() }; } - constructor() { - super({ current: void 0, entries: List(), entryMap: new Map() }); + private timeoutHandle: any = void 0; + private next = async () => { + this.timeoutHandle = void 0; + const next = this.getNextId(this.state.current, 1); + if (!next || next === this.state.current) { + this.stop(); + return; + } + const snapshot = this.setCurrent(next)!; + await this.plugin.state.setSnapshot(snapshot); + this.timeoutHandle = setTimeout(this.next, this.state.nextSnapshotDelayInMs); + }; + + play() { + this.updateState({ isPlaying: true }); + this.next(); + } + + stop() { + this.updateState({ isPlaying: false }); + if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle); + this.timeoutHandle = void 0; + this.events.changed.next(); + } + + togglePlay() { + if (this.state.isPlaying) this.stop(); + else this.play(); + } + + constructor(private plugin: PluginContext) { + super({ + current: void 0, + entries: List(), + isPlaying: false, + nextSnapshotDelayInMs: PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs + }); + // TODO make nextSnapshotDelayInMs editable } } @@ -167,6 +222,10 @@ namespace PluginStateSnapshotManager { name?: string, description?: string, current: UUID | undefined, + playback: { + isPlaying: boolean, + nextSnapshotDelayInMs: number, + }, entries: Entry[] } } \ No newline at end of file diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx index 0179aac3adc522a32522a031941874ebc5f8c56e..fba79579b48fe8838daad958088053925973aee3 100644 --- a/src/mol-plugin/ui/controls.tsx +++ b/src/mol-plugin/ui/controls.tsx @@ -109,18 +109,22 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus this.update(e.target.value); } - prev = () => { + prev = () => { const s = this.plugin.state.snapshots; const id = s.getNextId(s.state.current, -1); if (id) this.update(id); } - next = () => { + next = () => { const s = this.plugin.state.snapshots; const id = s.getNextId(s.state.current, 1); if (id) this.update(id); } + togglePlay = () => { + this.plugin.state.snapshots.togglePlay(); + } + render() { const snapshots = this.plugin.state.snapshots; const count = snapshots.state.entries.size; @@ -130,14 +134,18 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus } const current = snapshots.state.current; + const isPlaying = snapshots.state.isPlaying; + + // TODO: better handle disabled state return <div className='msp-state-snapshot-viewport-controls'> - <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy}> + <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy || isPlaying}> {!current && <option key='none' value='none'></option>} {snapshots.state.entries.valueSeq().map((e, i) => <option key={e!.snapshot.id} value={e!.snapshot.id}>{`[${i! + 1}/${count}]`} {e!.name || new Date(e!.timestamp).toLocaleString()}</option>)} </select> - <IconButton icon='model-prev' title='Previous State' onClick={this.prev} disabled={this.state.isBusy} /> - <IconButton icon='model-next' title='Next State' onClick={this.next} disabled={this.state.isBusy} /> + <IconButton icon='left-open' title='Previous State' onClick={this.prev} disabled={this.state.isBusy || isPlaying} /> + <IconButton icon='right-open' title='Next State' onClick={this.next} disabled={this.state.isBusy || isPlaying} /> + <IconButton icon={isPlaying ? 'pause' : 'play'} title={isPlaying ? 'Pause' : 'Play'} onClick={this.togglePlay} /> </div>; } } diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx index 27a0e67840aa2ee5552b4d7fd63d7c8eed0e5187..b96361b9c51783e1c789fb736af1e79b5a6eee34 100644 --- a/src/mol-plugin/ui/state.tsx +++ b/src/mol-plugin/ui/state.tsx @@ -139,7 +139,7 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { render() { const current = this.plugin.state.snapshots.state.current; return <ul style={{ listStyle: 'none' }} className='msp-state-list'> - {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}> + {this.plugin.state.snapshots.state.entries.map(e =><li key={e!.snapshot.id}> <button data-id={e!.snapshot.id} className='msp-btn msp-btn-block msp-form-control' onClick={this.apply}> <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>{e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>{e!.description}</small> </button>