@fmsim/board 2.0.0-beta.60 → 2.0.0-beta.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +1473 -1406
- package/dist/src/ox-board-viewer.d.ts +21 -2
- package/dist/src/ox-board-viewer.js +84 -14
- package/dist/src/ox-board-viewer.js.map +1 -1
- package/dist/src/ox-playback-controls.d.ts +13 -6
- package/dist/src/ox-playback-controls.js +57 -64
- package/dist/src/ox-playback-controls.js.map +1 -1
- package/dist/src/playback/playback-engine.d.ts +18 -7
- package/dist/src/playback/playback-engine.js +58 -31
- package/dist/src/playback/playback-engine.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
|
@@ -15,7 +15,9 @@ const ONE_HOUR = 3600000;
|
|
|
15
15
|
* @fires playback-pause - 일시정지 요청
|
|
16
16
|
* @fires playback-resume - 재개 요청
|
|
17
17
|
* @fires playback-stop - 중지 요청
|
|
18
|
-
* @fires playback-seek - 시크 요청 { toTime }
|
|
18
|
+
* @fires playback-seek - 드래그 릴리즈 시 시크 요청 { toTime }
|
|
19
|
+
* @fires playback-seek-start - 드래그 첫 input 시 즉시 발행 (엔진 타이머·애니메이션 정지 신호, detail 없음)
|
|
20
|
+
* @fires playback-seek-preview - 드래그 중 디바운스된 스크러빙 요청 { toTime }
|
|
19
21
|
* @fires playback-speed - 배속 변경 요청 { speed }
|
|
20
22
|
*/
|
|
21
23
|
let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitElement {
|
|
@@ -33,7 +35,7 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
33
35
|
this._startTime = PlaybackControls_1._floorToHour(new Date(Date.now() - ONE_HOUR));
|
|
34
36
|
this._seeking = false;
|
|
35
37
|
this._seekPreviewTime = null;
|
|
36
|
-
this._speeds = [1,
|
|
38
|
+
this._speeds = [1, 2, 4];
|
|
37
39
|
}
|
|
38
40
|
get _endTime() {
|
|
39
41
|
return new Date(this._startTime.getTime() + ONE_HOUR);
|
|
@@ -51,14 +53,16 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
51
53
|
}
|
|
52
54
|
disconnectedCallback() {
|
|
53
55
|
super.disconnectedCallback();
|
|
54
|
-
this.
|
|
56
|
+
this._cancelPreviewDebounce();
|
|
55
57
|
}
|
|
56
58
|
updated(changes) {
|
|
57
59
|
if (changes.has('timeRange') && this.timeRange) {
|
|
58
60
|
this._startTime = PlaybackControls_1._floorToHour(this.timeRange.from);
|
|
59
61
|
}
|
|
62
|
+
// 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.
|
|
63
|
+
// drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).
|
|
60
64
|
if (changes.has('currentTime') && this.currentTime && !this._seeking) {
|
|
61
|
-
this.
|
|
65
|
+
this._syncFromEngine();
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
render() {
|
|
@@ -151,16 +155,23 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
151
155
|
const pad = (n) => String(n).padStart(2, '0');
|
|
152
156
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
153
157
|
}
|
|
154
|
-
|
|
158
|
+
/**
|
|
159
|
+
* 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여
|
|
160
|
+
* _currentPlayTime 및 _seekValue 동기화.
|
|
161
|
+
*/
|
|
162
|
+
_syncFromEngine() {
|
|
155
163
|
if (!this.currentTime || this._seeking)
|
|
156
164
|
return;
|
|
157
|
-
const
|
|
165
|
+
const currentMs = new Date(this.currentTime).getTime();
|
|
166
|
+
if (!Number.isFinite(currentMs))
|
|
167
|
+
return;
|
|
168
|
+
this._currentPlayTime = new Date(currentMs);
|
|
158
169
|
const from = this._startTime.getTime();
|
|
159
170
|
const to = this._endTime.getTime();
|
|
160
171
|
const range = to - from;
|
|
161
172
|
if (range <= 0)
|
|
162
173
|
return;
|
|
163
|
-
this._seekValue = Math.max(0, Math.min(1000, Math.round(((
|
|
174
|
+
this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)));
|
|
164
175
|
}
|
|
165
176
|
_onDateChange(e) {
|
|
166
177
|
const value = e.target.value;
|
|
@@ -193,7 +204,6 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
193
204
|
this._startTime = newStart;
|
|
194
205
|
this._seekValue = 0;
|
|
195
206
|
this._currentPlayTime = new Date(newStart);
|
|
196
|
-
this._stopTimer();
|
|
197
207
|
if (wasPlaying) {
|
|
198
208
|
// play 중: 새 시간에서 자동 재시작
|
|
199
209
|
this._onStart();
|
|
@@ -205,15 +215,18 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
205
215
|
bubbles: true,
|
|
206
216
|
composed: true
|
|
207
217
|
}));
|
|
208
|
-
this._updateSeekFromPlayTime();
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
_onStart() {
|
|
212
|
-
|
|
221
|
+
// 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)
|
|
222
|
+
const ratio = this._seekValue / 1000;
|
|
223
|
+
const from = this._startTime.getTime();
|
|
224
|
+
const to = this._endTime.getTime();
|
|
225
|
+
const fromTime = new Date(from + (to - from) * ratio);
|
|
226
|
+
this._currentPlayTime = fromTime;
|
|
213
227
|
this.playbackState = 'playing';
|
|
214
|
-
this._startTimer();
|
|
215
228
|
this.dispatchEvent(new CustomEvent('playback-start', {
|
|
216
|
-
detail: { fromTime
|
|
229
|
+
detail: { fromTime, speed: this.speed },
|
|
217
230
|
bubbles: true,
|
|
218
231
|
composed: true
|
|
219
232
|
}));
|
|
@@ -221,33 +234,44 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
221
234
|
_onTogglePlayPause() {
|
|
222
235
|
if (this.playbackState === 'playing') {
|
|
223
236
|
this.playbackState = 'paused';
|
|
224
|
-
this._stopTimer();
|
|
225
237
|
this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }));
|
|
226
238
|
}
|
|
227
239
|
else {
|
|
228
240
|
this.playbackState = 'playing';
|
|
229
|
-
this._startTimer();
|
|
230
241
|
this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }));
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
_onStop() {
|
|
234
|
-
this.
|
|
245
|
+
this._cancelPreviewDebounce();
|
|
235
246
|
this._currentPlayTime = null;
|
|
236
247
|
this._seekValue = 0;
|
|
237
248
|
this.playbackState = 'idle';
|
|
238
249
|
this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }));
|
|
239
250
|
}
|
|
240
251
|
_onSeekInput(e) {
|
|
252
|
+
// 드래그 시작(첫 input) 탐지: 엔진 타이머 정지 신호 즉시 발행
|
|
253
|
+
const justStartedDrag = !this._seeking;
|
|
241
254
|
this._seeking = true;
|
|
242
255
|
this._seekValue = Number(e.target.value);
|
|
243
256
|
// 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)
|
|
244
257
|
const ratio = this._seekValue / 1000;
|
|
245
258
|
const from = this._startTime.getTime();
|
|
246
259
|
const to = this._endTime.getTime();
|
|
247
|
-
|
|
260
|
+
const previewTime = new Date(from + (to - from) * ratio);
|
|
261
|
+
this._seekPreviewTime = previewTime;
|
|
262
|
+
if (justStartedDrag) {
|
|
263
|
+
// 즉시 이벤트 — 재생 타이머 정지해서 scene 이 엔진 시간으로 갱신되는 것을 막음.
|
|
264
|
+
this.dispatchEvent(new CustomEvent('playback-seek-start', {
|
|
265
|
+
bubbles: true,
|
|
266
|
+
composed: true
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
// 디바운스된 playback-seek-preview 이벤트 — 드래그 중 snapshot 스크러빙
|
|
270
|
+
this._schedulePreview(previewTime);
|
|
248
271
|
}
|
|
249
272
|
_onSeekChange(e) {
|
|
250
273
|
this._seeking = false;
|
|
274
|
+
this._cancelPreviewDebounce();
|
|
251
275
|
const ratio = Number(e.target.value) / 1000;
|
|
252
276
|
const from = this._startTime.getTime();
|
|
253
277
|
const to = this._endTime.getTime();
|
|
@@ -259,10 +283,22 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
259
283
|
bubbles: true,
|
|
260
284
|
composed: true
|
|
261
285
|
}));
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
286
|
+
}
|
|
287
|
+
_schedulePreview(previewTime) {
|
|
288
|
+
this._cancelPreviewDebounce();
|
|
289
|
+
this._previewDebounce = window.setTimeout(() => {
|
|
290
|
+
this._previewDebounce = undefined;
|
|
291
|
+
this.dispatchEvent(new CustomEvent('playback-seek-preview', {
|
|
292
|
+
detail: { toTime: previewTime },
|
|
293
|
+
bubbles: true,
|
|
294
|
+
composed: true
|
|
295
|
+
}));
|
|
296
|
+
}, PlaybackControls_1.PREVIEW_DEBOUNCE_MS);
|
|
297
|
+
}
|
|
298
|
+
_cancelPreviewDebounce() {
|
|
299
|
+
if (this._previewDebounce != null) {
|
|
300
|
+
clearTimeout(this._previewDebounce);
|
|
301
|
+
this._previewDebounce = undefined;
|
|
266
302
|
}
|
|
267
303
|
}
|
|
268
304
|
_renderBufferBars() {
|
|
@@ -288,50 +324,6 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
|
|
|
288
324
|
bubbles: true,
|
|
289
325
|
composed: true
|
|
290
326
|
}));
|
|
291
|
-
// 재생 중이면 타이머 재시작 (새 속도 반영)
|
|
292
|
-
if (this.playbackState === 'playing') {
|
|
293
|
-
this._stopTimer();
|
|
294
|
-
this._startTimer();
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
// --- 타이머: 초 단위 진행 ---
|
|
298
|
-
_startTimer() {
|
|
299
|
-
this._stopTimer();
|
|
300
|
-
const intervalMs = 1000 / this.speed;
|
|
301
|
-
this._timer = window.setInterval(() => {
|
|
302
|
-
if (!this._currentPlayTime)
|
|
303
|
-
return;
|
|
304
|
-
const next = new Date(this._currentPlayTime.getTime() + 1000);
|
|
305
|
-
// 종료 시점 초과 시 정지
|
|
306
|
-
if (next.getTime() > this._endTime.getTime()) {
|
|
307
|
-
this._onStop();
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
this._currentPlayTime = next;
|
|
311
|
-
if (!this._seeking)
|
|
312
|
-
this._updateSeekFromPlayTime();
|
|
313
|
-
this.dispatchEvent(new CustomEvent('playback-tick', {
|
|
314
|
-
detail: { currentTime: this._currentPlayTime },
|
|
315
|
-
bubbles: true,
|
|
316
|
-
composed: true
|
|
317
|
-
}));
|
|
318
|
-
}, intervalMs);
|
|
319
|
-
}
|
|
320
|
-
_stopTimer() {
|
|
321
|
-
if (this._timer) {
|
|
322
|
-
clearInterval(this._timer);
|
|
323
|
-
this._timer = undefined;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
_updateSeekFromPlayTime() {
|
|
327
|
-
if (!this._currentPlayTime)
|
|
328
|
-
return;
|
|
329
|
-
const from = this._startTime.getTime();
|
|
330
|
-
const to = this._endTime.getTime();
|
|
331
|
-
const range = to - from;
|
|
332
|
-
if (range <= 0)
|
|
333
|
-
return;
|
|
334
|
-
this._seekValue = Math.max(0, Math.min(1000, Math.round(((this._currentPlayTime.getTime() - from) / range) * 1000)));
|
|
335
327
|
}
|
|
336
328
|
};
|
|
337
329
|
PlaybackControls.styles = css `
|
|
@@ -557,6 +549,7 @@ PlaybackControls.styles = css `
|
|
|
557
549
|
text-align: center;
|
|
558
550
|
}
|
|
559
551
|
`;
|
|
552
|
+
PlaybackControls.PREVIEW_DEBOUNCE_MS = 200;
|
|
560
553
|
__decorate([
|
|
561
554
|
property({ type: String })
|
|
562
555
|
], PlaybackControls.prototype, "playbackState", void 0);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ox-playback-controls.js","sourceRoot":"","sources":["../../src/ox-playback-controls.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@material/web/iconbutton/icon-button.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\ntype PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nconst ONE_HOUR = 3600000\n\n/**\n * 플레이백 컨트롤 오버레이 바 (2행 구조)\n *\n * 1행: 시작 시점 설정 (datetime + ±1h 버튼) + 배속 선택\n * 2행: 재생 제어 (play/pause/stop) + 1시간 범위 시크바 + 닫기\n *\n * @fires playback-start - 플레이백 시작 요청 { fromTime, speed }\n * @fires playback-pause - 일시정지 요청\n * @fires playback-resume - 재개 요청\n * @fires playback-stop - 중지 요청\n * @fires playback-seek - 시크 요청 { toTime }\n * @fires playback-speed - 배속 변경 요청 { speed }\n */\n@customElement('ox-playback-controls')\nexport class PlaybackControls extends LitElement {\n static styles = css`\n :host {\n display: flex;\n justify-content: center;\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n z-index: 1001;\n pointer-events: none;\n }\n\n .control-bar {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 16px;\n margin: 12px;\n max-width: 560px;\n width: 100%;\n background: rgba(0, 0, 0, 0.75);\n backdrop-filter: blur(8px);\n border-radius: 12px;\n color: #fff;\n pointer-events: auto;\n font-size: 13px;\n box-sizing: border-box;\n }\n\n /* 1행: 시점 설정 + 배속 */\n .row-time {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n md-icon-button {\n --md-icon-button-icon-color: #fff;\n --md-icon-button-hover-icon-color: #fff;\n --md-icon-button-pressed-icon-color: #fff;\n --md-icon-button-focus-icon-color: #fff;\n --md-icon-button-state-layer-color: rgba(255, 255, 255, 0.2);\n }\n\n md-icon-button:hover {\n background: rgba(255, 255, 255, 0.15);\n border-radius: 50%;\n }\n\n .row-time md-icon-button {\n --md-icon-button-icon-size: 18px;\n --md-icon-button-container-width: 32px;\n --md-icon-button-container-height: 32px;\n }\n\n .date-input {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 8px;\n outline: none;\n color-scheme: dark;\n width: 130px;\n }\n\n .date-input:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .hour-select {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 6px;\n outline: none;\n color-scheme: dark;\n cursor: pointer;\n }\n\n .hour-select:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .time-label {\n font-size: 12px;\n opacity: 0.7;\n white-space: nowrap;\n }\n\n .spacer {\n flex: 1;\n }\n\n .speed-group {\n display: flex;\n gap: 2px;\n }\n\n .speed-btn {\n padding: 4px 8px;\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 4px;\n background: transparent;\n color: #fff;\n font-size: 12px;\n cursor: pointer;\n white-space: nowrap;\n }\n\n .speed-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n }\n\n .speed-btn.active {\n background: rgba(255, 255, 255, 0.3);\n border-color: #fff;\n }\n\n /* 2행: 재생 제어 + 시크바 */\n .row-seek {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .row-seek md-icon-button {\n --md-icon-button-icon-size: 20px;\n --md-icon-button-container-width: 36px;\n --md-icon-button-container-height: 36px;\n }\n\n .row-seek md-icon-button.primary {\n --md-icon-button-icon-size: 24px;\n --md-icon-button-container-width: 40px;\n --md-icon-button-container-height: 40px;\n }\n\n .row-seek md-icon {\n font-variation-settings: 'FILL' 1;\n }\n\n .timeline {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .seek-track {\n position: relative;\n width: 100%;\n height: 12px;\n }\n\n .seek-rail {\n position: absolute;\n top: 4px;\n left: 0;\n right: 0;\n height: 4px;\n background: rgba(255, 255, 255, 0.15);\n border-radius: 2px;\n }\n\n .buffer-bar {\n position: absolute;\n top: 4px;\n height: 4px;\n background: rgba(255, 255, 255, 0.4);\n border-radius: 2px;\n pointer-events: none;\n z-index: 1;\n }\n\n .seek-bar {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 12px;\n -webkit-appearance: none;\n appearance: none;\n background: transparent;\n outline: none;\n cursor: pointer;\n z-index: 2;\n margin: 0;\n }\n\n .seek-bar::-webkit-slider-runnable-track {\n height: 12px;\n background: transparent;\n }\n\n .seek-bar::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n }\n\n .seek-labels {\n display: flex;\n justify-content: space-between;\n font-size: 11px;\n opacity: 0.8;\n }\n\n .current-time {\n font-size: 12px;\n opacity: 0.9;\n white-space: nowrap;\n min-width: 60px;\n text-align: center;\n }\n `\n\n @property({ type: String }) playbackState: PlaybackState = 'idle'\n @property({ type: Number }) speed: number = 1\n @property({ type: String }) currentTime: string = ''\n @property({ type: Array }) bufferedRanges: { from: number; to: number }[] = []\n\n /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */\n @property({ type: Object }) timeRange: { from: Date; to: Date } = {\n from: new Date(Date.now() - ONE_HOUR),\n to: new Date()\n }\n\n @state() private _startTime: Date = PlaybackControls._floorToHour(new Date(Date.now() - ONE_HOUR))\n @state() declare private _seekValue: number\n @state() declare private _currentPlayTime: Date | null\n @state() private _seeking = false\n @state() private _seekPreviewTime: Date | null = null\n\n private _speeds = [1, 1.5, 2]\n private _timer?: number\n\n private get _endTime(): Date {\n return new Date(this._startTime.getTime() + ONE_HOUR)\n }\n\n private static _floorToHour(date: Date): Date {\n const d = new Date(date)\n d.setMinutes(0, 0, 0)\n return d\n }\n\n connectedCallback() {\n super.connectedCallback()\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n this._seekValue = 0\n this._currentPlayTime = null\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this._stopTimer()\n }\n\n updated(changes: Map<string, any>) {\n if (changes.has('timeRange') && this.timeRange) {\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n }\n if (changes.has('currentTime') && this.currentTime && !this._seeking) {\n this._updateSeekPosition()\n }\n }\n\n render() {\n const isPlaying = this.playbackState === 'playing'\n const isPaused = this.playbackState === 'paused'\n const isActive = isPlaying || isPaused\n\n return html`\n <div class=\"control-bar\">\n <!-- 1행: 시점 설정 + 배속 -->\n <div class=\"row-time\">\n <md-icon-button @click=${this._shiftTime(-1)} title=\"-1h\">\n <md-icon>chevron_left</md-icon>\n </md-icon-button>\n\n <input\n type=\"date\"\n class=\"date-input\"\n .value=${this._toDateString(this._startTime)}\n @change=${this._onDateChange}\n />\n\n <select class=\"hour-select\" .value=${String(this._startTime.getHours())} @change=${this._onHourChange}>\n ${Array.from({ length: 24 }, (_, i) => html`\n <option value=${i} ?selected=${this._startTime.getHours() === i}>\n ${String(i).padStart(2, '0')}:00\n </option>\n `)}\n </select>\n\n <md-icon-button @click=${this._shiftTime(1)} title=\"+1h\">\n <md-icon>chevron_right</md-icon>\n </md-icon-button>\n\n <span class=\"time-label\">~ ${String(this._endTime.getHours()).padStart(2, '0')}:00</span>\n\n <span class=\"spacer\"></span>\n\n <div class=\"speed-group\">\n ${this._speeds.map(\n s => html`\n <button class=\"speed-btn ${this.speed === s ? 'active' : ''}\" @click=${() => this._onSpeedChange(s)}>\n ×${s}\n </button>\n `\n )}\n </div>\n </div>\n\n <!-- 2행: 재생 제어 + 시크바 + 닫기 -->\n <div class=\"row-seek\">\n <md-icon-button class=\"primary\" @click=${isActive ? this._onTogglePlayPause : this._onStart}>\n <md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>\n </md-icon-button>\n\n <div class=\"timeline\">\n <div class=\"seek-track\">\n <div class=\"seek-rail\"></div>\n ${this._renderBufferBars()}\n <input\n type=\"range\"\n class=\"seek-bar\"\n min=\"0\"\n max=\"1000\"\n .value=${String(this._seekValue)}\n @input=${this._onSeekInput}\n @change=${this._onSeekChange}\n />\n </div>\n <div class=\"seek-labels\">\n <span>${this._formatTime(this._startTime)}</span>\n <span>${this._formatTime(this._endTime)}</span>\n </div>\n </div>\n\n <span class=\"current-time\">\n ${this._seeking && this._seekPreviewTime\n ? this._formatTime(this._seekPreviewTime)\n : this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}\n </span>\n\n <md-icon-button @click=${this._onStop}>\n <md-icon>close</md-icon>\n </md-icon-button>\n </div>\n </div>\n `\n }\n\n private _formatTime(date: Date): string {\n return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })\n }\n\n private _toDateString(date: Date): string {\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n }\n\n private _updateSeekPosition() {\n if (!this.currentTime || this._seeking) return\n const current = new Date(this.currentTime).getTime()\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((current - from) / range) * 1000)))\n }\n\n private _onDateChange(e: Event) {\n const value = (e.target as HTMLInputElement).value\n if (!value) return\n const [year, month, day] = value.split('-').map(Number)\n const newStart = new Date(this._startTime)\n newStart.setFullYear(year, month - 1, day)\n this._shiftStartTime(newStart)\n }\n\n private _onHourChange(e: Event) {\n const hour = Number((e.target as HTMLSelectElement).value)\n const newStart = new Date(this._startTime)\n newStart.setHours(hour, 0, 0, 0)\n this._shiftStartTime(newStart)\n }\n\n private _shiftTime(hours: number) {\n return () => {\n const newStart = new Date(this._startTime.getTime() + hours * ONE_HOUR)\n this._shiftStartTime(newStart)\n }\n }\n\n /**\n * 시작 시간 변경 시: 재생/일시정지 중이면 새 시간에서 재시작.\n * 정지 상태면 시간만 변경.\n */\n private _shiftStartTime(newStart: Date) {\n const wasPlaying = this.playbackState === 'playing'\n const wasPaused = this.playbackState === 'paused'\n\n this._startTime = newStart\n this._seekValue = 0\n this._currentPlayTime = new Date(newStart)\n this._stopTimer()\n\n if (wasPlaying) {\n // play 중: 새 시간에서 자동 재시작\n this._onStart()\n } else if (wasPaused) {\n // pause 중: 엔진에 새 시간 전달 + 데이터 fetch, 하지만 자동 재생 안 함\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: new Date(newStart) },\n bubbles: true,\n composed: true\n })\n )\n this._updateSeekFromPlayTime()\n }\n }\n\n private _onStart() {\n this._currentPlayTime = new Date(this._startTime)\n this.playbackState = 'playing'\n this._startTimer()\n\n this.dispatchEvent(\n new CustomEvent('playback-start', {\n detail: { fromTime: this._startTime, speed: this.speed },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private _onTogglePlayPause() {\n if (this.playbackState === 'playing') {\n this.playbackState = 'paused'\n this._stopTimer()\n this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }))\n } else {\n this.playbackState = 'playing'\n this._startTimer()\n this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }))\n }\n }\n\n private _onStop() {\n this._stopTimer()\n this._currentPlayTime = null\n this._seekValue = 0\n this.playbackState = 'idle'\n this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }))\n }\n\n private _onSeekInput(e: Event) {\n this._seeking = true\n this._seekValue = Number((e.target as HTMLInputElement).value)\n\n // 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n this._seekPreviewTime = new Date(from + (to - from) * ratio)\n }\n\n private _onSeekChange(e: Event) {\n this._seeking = false\n const ratio = Number((e.target as HTMLInputElement).value) / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const targetTime = new Date(from + (to - from) * ratio)\n\n this._seekPreviewTime = null\n this._currentPlayTime = targetTime\n\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: targetTime },\n bubbles: true,\n composed: true\n })\n )\n\n // 재생 중이었으면 새 시점에서 타이머 계속\n if (this.playbackState === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n private _renderBufferBars() {\n if (!this.bufferedRanges?.length) return ''\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return ''\n\n return this.bufferedRanges.map(r => {\n const left = Math.max(0, (r.from - from) / range) * 100\n const right = Math.min(1, (r.to - from) / range) * 100\n const width = right - left\n return html`<div class=\"buffer-bar\" style=\"left:${left}%;width:${width}%\"></div>`\n })\n }\n\n private _onSpeedChange(speed: number) {\n this.speed = speed\n\n this.dispatchEvent(\n new CustomEvent('playback-speed', {\n detail: { speed },\n bubbles: true,\n composed: true\n })\n )\n\n // 재생 중이면 타이머 재시작 (새 속도 반영)\n if (this.playbackState === 'playing') {\n this._stopTimer()\n this._startTimer()\n }\n }\n\n // --- 타이머: 초 단위 진행 ---\n\n private _startTimer() {\n this._stopTimer()\n\n const intervalMs = 1000 / this.speed\n this._timer = window.setInterval(() => {\n if (!this._currentPlayTime) return\n\n const next = new Date(this._currentPlayTime.getTime() + 1000)\n\n // 종료 시점 초과 시 정지\n if (next.getTime() > this._endTime.getTime()) {\n this._onStop()\n return\n }\n\n this._currentPlayTime = next\n if (!this._seeking) this._updateSeekFromPlayTime()\n\n this.dispatchEvent(\n new CustomEvent('playback-tick', {\n detail: { currentTime: this._currentPlayTime },\n bubbles: true,\n composed: true\n })\n )\n }, intervalMs)\n }\n\n private _stopTimer() {\n if (this._timer) {\n clearInterval(this._timer)\n this._timer = undefined\n }\n }\n\n private _updateSeekFromPlayTime() {\n if (!this._currentPlayTime) return\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((this._currentPlayTime.getTime() - from) / range) * 1000)))\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ox-playback-controls.js","sourceRoot":"","sources":["../../src/ox-playback-controls.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@material/web/iconbutton/icon-button.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\ntype PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nconst ONE_HOUR = 3600000\n\n/**\n * 플레이백 컨트롤 오버레이 바 (2행 구조)\n *\n * 1행: 시작 시점 설정 (datetime + ±1h 버튼) + 배속 선택\n * 2행: 재생 제어 (play/pause/stop) + 1시간 범위 시크바 + 닫기\n *\n * @fires playback-start - 플레이백 시작 요청 { fromTime, speed }\n * @fires playback-pause - 일시정지 요청\n * @fires playback-resume - 재개 요청\n * @fires playback-stop - 중지 요청\n * @fires playback-seek - 드래그 릴리즈 시 시크 요청 { toTime }\n * @fires playback-seek-start - 드래그 첫 input 시 즉시 발행 (엔진 타이머·애니메이션 정지 신호, detail 없음)\n * @fires playback-seek-preview - 드래그 중 디바운스된 스크러빙 요청 { toTime }\n * @fires playback-speed - 배속 변경 요청 { speed }\n */\n@customElement('ox-playback-controls')\nexport class PlaybackControls extends LitElement {\n static styles = css`\n :host {\n display: flex;\n justify-content: center;\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n z-index: 1001;\n pointer-events: none;\n }\n\n .control-bar {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 10px 16px;\n margin: 12px;\n max-width: 560px;\n width: 100%;\n background: rgba(0, 0, 0, 0.75);\n backdrop-filter: blur(8px);\n border-radius: 12px;\n color: #fff;\n pointer-events: auto;\n font-size: 13px;\n box-sizing: border-box;\n }\n\n /* 1행: 시점 설정 + 배속 */\n .row-time {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n md-icon-button {\n --md-icon-button-icon-color: #fff;\n --md-icon-button-hover-icon-color: #fff;\n --md-icon-button-pressed-icon-color: #fff;\n --md-icon-button-focus-icon-color: #fff;\n --md-icon-button-state-layer-color: rgba(255, 255, 255, 0.2);\n }\n\n md-icon-button:hover {\n background: rgba(255, 255, 255, 0.15);\n border-radius: 50%;\n }\n\n .row-time md-icon-button {\n --md-icon-button-icon-size: 18px;\n --md-icon-button-container-width: 32px;\n --md-icon-button-container-height: 32px;\n }\n\n .date-input {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 8px;\n outline: none;\n color-scheme: dark;\n width: 130px;\n }\n\n .date-input:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .hour-select {\n background: rgba(255, 255, 255, 0.15);\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 6px;\n color: #fff;\n font-size: 13px;\n padding: 4px 6px;\n outline: none;\n color-scheme: dark;\n cursor: pointer;\n }\n\n .hour-select:focus {\n border-color: rgba(255, 255, 255, 0.6);\n }\n\n .time-label {\n font-size: 12px;\n opacity: 0.7;\n white-space: nowrap;\n }\n\n .spacer {\n flex: 1;\n }\n\n .speed-group {\n display: flex;\n gap: 2px;\n }\n\n .speed-btn {\n padding: 4px 8px;\n border: 1px solid rgba(255, 255, 255, 0.3);\n border-radius: 4px;\n background: transparent;\n color: #fff;\n font-size: 12px;\n cursor: pointer;\n white-space: nowrap;\n }\n\n .speed-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n }\n\n .speed-btn.active {\n background: rgba(255, 255, 255, 0.3);\n border-color: #fff;\n }\n\n /* 2행: 재생 제어 + 시크바 */\n .row-seek {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .row-seek md-icon-button {\n --md-icon-button-icon-size: 20px;\n --md-icon-button-container-width: 36px;\n --md-icon-button-container-height: 36px;\n }\n\n .row-seek md-icon-button.primary {\n --md-icon-button-icon-size: 24px;\n --md-icon-button-container-width: 40px;\n --md-icon-button-container-height: 40px;\n }\n\n .row-seek md-icon {\n font-variation-settings: 'FILL' 1;\n }\n\n .timeline {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .seek-track {\n position: relative;\n width: 100%;\n height: 12px;\n }\n\n .seek-rail {\n position: absolute;\n top: 4px;\n left: 0;\n right: 0;\n height: 4px;\n background: rgba(255, 255, 255, 0.15);\n border-radius: 2px;\n }\n\n .buffer-bar {\n position: absolute;\n top: 4px;\n height: 4px;\n background: rgba(255, 255, 255, 0.4);\n border-radius: 2px;\n pointer-events: none;\n z-index: 1;\n }\n\n .seek-bar {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 12px;\n -webkit-appearance: none;\n appearance: none;\n background: transparent;\n outline: none;\n cursor: pointer;\n z-index: 2;\n margin: 0;\n }\n\n .seek-bar::-webkit-slider-runnable-track {\n height: 12px;\n background: transparent;\n }\n\n .seek-bar::-webkit-slider-thumb {\n -webkit-appearance: none;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: #fff;\n cursor: pointer;\n }\n\n .seek-labels {\n display: flex;\n justify-content: space-between;\n font-size: 11px;\n opacity: 0.8;\n }\n\n .current-time {\n font-size: 12px;\n opacity: 0.9;\n white-space: nowrap;\n min-width: 60px;\n text-align: center;\n }\n `\n\n @property({ type: String }) playbackState: PlaybackState = 'idle'\n @property({ type: Number }) speed: number = 1\n @property({ type: String }) currentTime: string = ''\n @property({ type: Array }) bufferedRanges: { from: number; to: number }[] = []\n\n /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */\n @property({ type: Object }) timeRange: { from: Date; to: Date } = {\n from: new Date(Date.now() - ONE_HOUR),\n to: new Date()\n }\n\n @state() private _startTime: Date = PlaybackControls._floorToHour(new Date(Date.now() - ONE_HOUR))\n @state() declare private _seekValue: number\n @state() declare private _currentPlayTime: Date | null\n @state() private _seeking = false\n @state() private _seekPreviewTime: Date | null = null\n\n private _speeds = [1, 2, 4]\n /** 드래그 중 playback-seek-preview 이벤트 디바운스 타이머 */\n private _previewDebounce?: number\n\n private get _endTime(): Date {\n return new Date(this._startTime.getTime() + ONE_HOUR)\n }\n\n private static _floorToHour(date: Date): Date {\n const d = new Date(date)\n d.setMinutes(0, 0, 0)\n return d\n }\n\n connectedCallback() {\n super.connectedCallback()\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n this._seekValue = 0\n this._currentPlayTime = null\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this._cancelPreviewDebounce()\n }\n\n updated(changes: Map<string, any>) {\n if (changes.has('timeRange') && this.timeRange) {\n this._startTime = PlaybackControls._floorToHour(this.timeRange.from)\n }\n // 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.\n // drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).\n if (changes.has('currentTime') && this.currentTime && !this._seeking) {\n this._syncFromEngine()\n }\n }\n\n render() {\n const isPlaying = this.playbackState === 'playing'\n const isPaused = this.playbackState === 'paused'\n const isActive = isPlaying || isPaused\n\n return html`\n <div class=\"control-bar\">\n <!-- 1행: 시점 설정 + 배속 -->\n <div class=\"row-time\">\n <md-icon-button @click=${this._shiftTime(-1)} title=\"-1h\">\n <md-icon>chevron_left</md-icon>\n </md-icon-button>\n\n <input\n type=\"date\"\n class=\"date-input\"\n .value=${this._toDateString(this._startTime)}\n @change=${this._onDateChange}\n />\n\n <select class=\"hour-select\" .value=${String(this._startTime.getHours())} @change=${this._onHourChange}>\n ${Array.from({ length: 24 }, (_, i) => html`\n <option value=${i} ?selected=${this._startTime.getHours() === i}>\n ${String(i).padStart(2, '0')}:00\n </option>\n `)}\n </select>\n\n <md-icon-button @click=${this._shiftTime(1)} title=\"+1h\">\n <md-icon>chevron_right</md-icon>\n </md-icon-button>\n\n <span class=\"time-label\">~ ${String(this._endTime.getHours()).padStart(2, '0')}:00</span>\n\n <span class=\"spacer\"></span>\n\n <div class=\"speed-group\">\n ${this._speeds.map(\n s => html`\n <button class=\"speed-btn ${this.speed === s ? 'active' : ''}\" @click=${() => this._onSpeedChange(s)}>\n ×${s}\n </button>\n `\n )}\n </div>\n </div>\n\n <!-- 2행: 재생 제어 + 시크바 + 닫기 -->\n <div class=\"row-seek\">\n <md-icon-button class=\"primary\" @click=${isActive ? this._onTogglePlayPause : this._onStart}>\n <md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>\n </md-icon-button>\n\n <div class=\"timeline\">\n <div class=\"seek-track\">\n <div class=\"seek-rail\"></div>\n ${this._renderBufferBars()}\n <input\n type=\"range\"\n class=\"seek-bar\"\n min=\"0\"\n max=\"1000\"\n .value=${String(this._seekValue)}\n @input=${this._onSeekInput}\n @change=${this._onSeekChange}\n />\n </div>\n <div class=\"seek-labels\">\n <span>${this._formatTime(this._startTime)}</span>\n <span>${this._formatTime(this._endTime)}</span>\n </div>\n </div>\n\n <span class=\"current-time\">\n ${this._seeking && this._seekPreviewTime\n ? this._formatTime(this._seekPreviewTime)\n : this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}\n </span>\n\n <md-icon-button @click=${this._onStop}>\n <md-icon>close</md-icon>\n </md-icon-button>\n </div>\n </div>\n `\n }\n\n private _formatTime(date: Date): string {\n return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })\n }\n\n private _toDateString(date: Date): string {\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n }\n\n /**\n * 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여\n * _currentPlayTime 및 _seekValue 동기화.\n */\n private _syncFromEngine() {\n if (!this.currentTime || this._seeking) return\n const currentMs = new Date(this.currentTime).getTime()\n if (!Number.isFinite(currentMs)) return\n\n this._currentPlayTime = new Date(currentMs)\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return\n this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)))\n }\n\n private _onDateChange(e: Event) {\n const value = (e.target as HTMLInputElement).value\n if (!value) return\n const [year, month, day] = value.split('-').map(Number)\n const newStart = new Date(this._startTime)\n newStart.setFullYear(year, month - 1, day)\n this._shiftStartTime(newStart)\n }\n\n private _onHourChange(e: Event) {\n const hour = Number((e.target as HTMLSelectElement).value)\n const newStart = new Date(this._startTime)\n newStart.setHours(hour, 0, 0, 0)\n this._shiftStartTime(newStart)\n }\n\n private _shiftTime(hours: number) {\n return () => {\n const newStart = new Date(this._startTime.getTime() + hours * ONE_HOUR)\n this._shiftStartTime(newStart)\n }\n }\n\n /**\n * 시작 시간 변경 시: 재생/일시정지 중이면 새 시간에서 재시작.\n * 정지 상태면 시간만 변경.\n */\n private _shiftStartTime(newStart: Date) {\n const wasPlaying = this.playbackState === 'playing'\n const wasPaused = this.playbackState === 'paused'\n\n this._startTime = newStart\n this._seekValue = 0\n this._currentPlayTime = new Date(newStart)\n\n if (wasPlaying) {\n // play 중: 새 시간에서 자동 재시작\n this._onStart()\n } else if (wasPaused) {\n // pause 중: 엔진에 새 시간 전달 + 데이터 fetch, 하지만 자동 재생 안 함\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: new Date(newStart) },\n bubbles: true,\n composed: true\n })\n )\n }\n }\n\n private _onStart() {\n // 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const fromTime = new Date(from + (to - from) * ratio)\n\n this._currentPlayTime = fromTime\n this.playbackState = 'playing'\n\n this.dispatchEvent(\n new CustomEvent('playback-start', {\n detail: { fromTime, speed: this.speed },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private _onTogglePlayPause() {\n if (this.playbackState === 'playing') {\n this.playbackState = 'paused'\n this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }))\n } else {\n this.playbackState = 'playing'\n this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }))\n }\n }\n\n private _onStop() {\n this._cancelPreviewDebounce()\n this._currentPlayTime = null\n this._seekValue = 0\n this.playbackState = 'idle'\n this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }))\n }\n\n private _onSeekInput(e: Event) {\n // 드래그 시작(첫 input) 탐지: 엔진 타이머 정지 신호 즉시 발행\n const justStartedDrag = !this._seeking\n this._seeking = true\n this._seekValue = Number((e.target as HTMLInputElement).value)\n\n // 드래그 중 preview 시간 표시 (실제 seek은 하지 않음)\n const ratio = this._seekValue / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const previewTime = new Date(from + (to - from) * ratio)\n this._seekPreviewTime = previewTime\n\n if (justStartedDrag) {\n // 즉시 이벤트 — 재생 타이머 정지해서 scene 이 엔진 시간으로 갱신되는 것을 막음.\n this.dispatchEvent(\n new CustomEvent('playback-seek-start', {\n bubbles: true,\n composed: true\n })\n )\n }\n\n // 디바운스된 playback-seek-preview 이벤트 — 드래그 중 snapshot 스크러빙\n this._schedulePreview(previewTime)\n }\n\n private _onSeekChange(e: Event) {\n this._seeking = false\n this._cancelPreviewDebounce()\n\n const ratio = Number((e.target as HTMLInputElement).value) / 1000\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const targetTime = new Date(from + (to - from) * ratio)\n\n this._seekPreviewTime = null\n this._currentPlayTime = targetTime\n\n this.dispatchEvent(\n new CustomEvent('playback-seek', {\n detail: { toTime: targetTime },\n bubbles: true,\n composed: true\n })\n )\n }\n\n private static readonly PREVIEW_DEBOUNCE_MS = 200\n\n private _schedulePreview(previewTime: Date) {\n this._cancelPreviewDebounce()\n this._previewDebounce = window.setTimeout(() => {\n this._previewDebounce = undefined\n this.dispatchEvent(\n new CustomEvent('playback-seek-preview', {\n detail: { toTime: previewTime },\n bubbles: true,\n composed: true\n })\n )\n }, PlaybackControls.PREVIEW_DEBOUNCE_MS)\n }\n\n private _cancelPreviewDebounce() {\n if (this._previewDebounce != null) {\n clearTimeout(this._previewDebounce)\n this._previewDebounce = undefined\n }\n }\n\n private _renderBufferBars() {\n if (!this.bufferedRanges?.length) return ''\n\n const from = this._startTime.getTime()\n const to = this._endTime.getTime()\n const range = to - from\n if (range <= 0) return ''\n\n return this.bufferedRanges.map(r => {\n const left = Math.max(0, (r.from - from) / range) * 100\n const right = Math.min(1, (r.to - from) / range) * 100\n const width = right - left\n return html`<div class=\"buffer-bar\" style=\"left:${left}%;width:${width}%\"></div>`\n })\n }\n\n private _onSpeedChange(speed: number) {\n this.speed = speed\n\n this.dispatchEvent(\n new CustomEvent('playback-speed', {\n detail: { speed },\n bubbles: true,\n composed: true\n })\n )\n }\n}\n"]}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - 10분 단위로 데이터를 fetch하여 버퍼에 적재
|
|
5
5
|
* - 남은 데이터 1분 이하 시 다음 10분 prefetch
|
|
6
|
-
* - seek 시 기존 버퍼
|
|
6
|
+
* - seek 시 기존 버퍼 폐기 후 fresh fetch (보드 리셋 없음 — snapshot 이 모든 data 덮어씀)
|
|
7
|
+
* - seekPreview: count=1 snapshot 만 요청해 scene 에 적용 (드래그 스크러빙)
|
|
8
|
+
* - pauseForSeek / seekTo 가 scene.suppressAnimations 를 ON/OFF 토글
|
|
7
9
|
* - setInterval 기반 프레임 진행 (배속 반영)
|
|
8
10
|
*/
|
|
9
11
|
import type { PlaybackScenario } from '@fmsim/api';
|
|
@@ -48,8 +50,10 @@ export declare class PlaybackEngine {
|
|
|
48
50
|
get scenarioNames(): string[];
|
|
49
51
|
/**
|
|
50
52
|
* fetch 함수 주입 (페이지에서 제공)
|
|
53
|
+
* - 2-arg 호출: 청크 프리페치 (includeSnapshot 기본 true)
|
|
54
|
+
* - 4-arg 호출: 프리뷰용 snapshot-only (count=1)
|
|
51
55
|
*/
|
|
52
|
-
setFetchFunction(fn: (startTime: string, endTime
|
|
56
|
+
setFetchFunction(fn: (startTime: string, endTime?: string, includeSnapshot?: boolean, count?: number) => Promise<any>): void;
|
|
53
57
|
/**
|
|
54
58
|
* 청크 기반 시작: fromTime에서 10분 분량을 가져와 재생 시작
|
|
55
59
|
*/
|
|
@@ -66,15 +70,22 @@ export declare class PlaybackEngine {
|
|
|
66
70
|
stop(): void;
|
|
67
71
|
setSpeed(speed: number): void;
|
|
68
72
|
/**
|
|
69
|
-
* seek — 기존 버퍼
|
|
73
|
+
* seek — 기존 버퍼 폐기 + 새 위치에서 snapshot+delta fresh fetch.
|
|
74
|
+
* 보드 상태 리셋 없음 — snapshot 이 모든 컴포넌트 data 를 덮어쓰므로 불필요.
|
|
75
|
+
* (드래그로 들어온 경우 pauseForSeek 에서 이미 suppressAnimations = true)
|
|
70
76
|
*/
|
|
71
77
|
seekTo(time: Date): Promise<void>;
|
|
72
78
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* 2) 모든 컴포넌트의 data를 null로 초기화 (이전 위치 잔상 제거)
|
|
79
|
+
* 드래그 시작: 재생 타이머 정지 + 애니메이션 억제 ON.
|
|
80
|
+
* 논리 상태(_state)는 유지 — seekTo 가 상태 기반으로 타이머 복원.
|
|
76
81
|
*/
|
|
77
|
-
|
|
82
|
+
pauseForSeek(): void;
|
|
83
|
+
/**
|
|
84
|
+
* seekPreview — 드래그 중 호출. 해당 시간의 snapshot 1프레임만 요청해서
|
|
85
|
+
* 현재 보드에 적용. 버퍼/상태 변경 없음.
|
|
86
|
+
* (pauseForSeek 에서 이미 suppressAnimations = true 가 걸려 있음)
|
|
87
|
+
*/
|
|
88
|
+
seekPreview(time: Date): Promise<void>;
|
|
78
89
|
on<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void;
|
|
79
90
|
off<K extends keyof PlaybackEventMap>(event: K, listener: PlaybackListener<K>): void;
|
|
80
91
|
dispose(): void;
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - 10분 단위로 데이터를 fetch하여 버퍼에 적재
|
|
5
5
|
* - 남은 데이터 1분 이하 시 다음 10분 prefetch
|
|
6
|
-
* - seek 시 기존 버퍼
|
|
6
|
+
* - seek 시 기존 버퍼 폐기 후 fresh fetch (보드 리셋 없음 — snapshot 이 모든 data 덮어씀)
|
|
7
|
+
* - seekPreview: count=1 snapshot 만 요청해 scene 에 적용 (드래그 스크러빙)
|
|
8
|
+
* - pauseForSeek / seekTo 가 scene.suppressAnimations 를 ON/OFF 토글
|
|
7
9
|
* - setInterval 기반 프레임 진행 (배속 반영)
|
|
8
10
|
*/
|
|
9
11
|
const CHUNK_DURATION = 10 * 60 * 1000; // 10분
|
|
@@ -28,6 +30,8 @@ export class PlaybackEngine {
|
|
|
28
30
|
get scenarioNames() { return this._scenarioNames; }
|
|
29
31
|
/**
|
|
30
32
|
* fetch 함수 주입 (페이지에서 제공)
|
|
33
|
+
* - 2-arg 호출: 청크 프리페치 (includeSnapshot 기본 true)
|
|
34
|
+
* - 4-arg 호출: 프리뷰용 snapshot-only (count=1)
|
|
31
35
|
*/
|
|
32
36
|
setFetchFunction(fn) {
|
|
33
37
|
this._fetchFn = fn;
|
|
@@ -99,6 +103,7 @@ export class PlaybackEngine {
|
|
|
99
103
|
this._frames = [];
|
|
100
104
|
this._bufferedEnd = 0;
|
|
101
105
|
this._prefetching = false;
|
|
106
|
+
this._speed = 1; // 다음 세션이 기본 1x 로 시작하도록 리셋
|
|
102
107
|
this._setState('stopped');
|
|
103
108
|
}
|
|
104
109
|
setSpeed(speed) {
|
|
@@ -109,7 +114,9 @@ export class PlaybackEngine {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
/**
|
|
112
|
-
* seek — 기존 버퍼
|
|
117
|
+
* seek — 기존 버퍼 폐기 + 새 위치에서 snapshot+delta fresh fetch.
|
|
118
|
+
* 보드 상태 리셋 없음 — snapshot 이 모든 컴포넌트 data 를 덮어쓰므로 불필요.
|
|
119
|
+
* (드래그로 들어온 경우 pauseForSeek 에서 이미 suppressAnimations = true)
|
|
113
120
|
*/
|
|
114
121
|
async seekTo(time) {
|
|
115
122
|
const wasPlaying = this._state === 'playing';
|
|
@@ -119,8 +126,6 @@ export class PlaybackEngine {
|
|
|
119
126
|
this._currentIndex = 0;
|
|
120
127
|
this._bufferedEnd = 0;
|
|
121
128
|
this._prefetching = false;
|
|
122
|
-
// 보드 상태 리셋 — 이전 위치의 carrier 등 동적 컴포넌트 정리
|
|
123
|
-
await this._resetScene();
|
|
124
129
|
if (this._fetchFn && this._totalRange) {
|
|
125
130
|
this._setState('loading');
|
|
126
131
|
const chunkEnd = Math.min(time.getTime() + CHUNK_DURATION, this._totalRange.to);
|
|
@@ -151,42 +156,58 @@ export class PlaybackEngine {
|
|
|
151
156
|
else if (this._frames.length > 0) {
|
|
152
157
|
this._setState('paused');
|
|
153
158
|
}
|
|
159
|
+
// 드래그 종료 — 애니메이션 억제 OFF
|
|
160
|
+
if (this._scene)
|
|
161
|
+
this._scene.suppressAnimations = false;
|
|
154
162
|
}
|
|
155
163
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* 2) 모든 컴포넌트의 data를 null로 초기화 (이전 위치 잔상 제거)
|
|
164
|
+
* 드래그 시작: 재생 타이머 정지 + 애니메이션 억제 ON.
|
|
165
|
+
* 논리 상태(_state)는 유지 — seekTo 가 상태 기반으로 타이머 복원.
|
|
159
166
|
*/
|
|
160
|
-
|
|
167
|
+
pauseForSeek() {
|
|
168
|
+
this._stopTimer();
|
|
169
|
+
if (this._scene)
|
|
170
|
+
this._scene.suppressAnimations = true;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* seekPreview — 드래그 중 호출. 해당 시간의 snapshot 1프레임만 요청해서
|
|
174
|
+
* 현재 보드에 적용. 버퍼/상태 변경 없음.
|
|
175
|
+
* (pauseForSeek 에서 이미 suppressAnimations = true 가 걸려 있음)
|
|
176
|
+
*/
|
|
177
|
+
async seekPreview(time) {
|
|
161
178
|
var _a, _b, _c;
|
|
162
|
-
|
|
163
|
-
if (!root)
|
|
179
|
+
if (!this._fetchFn || !this._scene)
|
|
164
180
|
return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
try {
|
|
182
|
+
// count=1 로 해당 시점의 snapshot 한 프레임만 요청
|
|
183
|
+
const result = await this._fetchFn(this._formatTime(time), undefined, true, // includeSnapshot
|
|
184
|
+
1 // count
|
|
185
|
+
);
|
|
186
|
+
// await 동안 scene 이 detach 된 경우 무시 (stop/dispose)
|
|
187
|
+
if (!this._scene)
|
|
188
|
+
return;
|
|
189
|
+
if (!(result === null || result === void 0 ? void 0 : result.status) || !result.playback)
|
|
190
|
+
return;
|
|
191
|
+
const scenarios = result.playback.scenarios;
|
|
192
|
+
if (!(scenarios === null || scenarios === void 0 ? void 0 : scenarios.length))
|
|
193
|
+
return;
|
|
194
|
+
for (const scenario of scenarios) {
|
|
195
|
+
const frame = (_a = scenario.snapshots) === null || _a === void 0 ? void 0 : _a[0];
|
|
196
|
+
if (!(frame === null || frame === void 0 ? void 0 : frame.data))
|
|
197
|
+
continue;
|
|
198
|
+
for (const record of frame.data) {
|
|
199
|
+
if (!(record === null || record === void 0 ? void 0 : record.NAME))
|
|
200
|
+
continue;
|
|
201
|
+
const components = ((_c = (_b = this._scene) === null || _b === void 0 ? void 0 : _b.findAllById) === null || _c === void 0 ? void 0 : _c.call(_b, record.NAME)) || [];
|
|
202
|
+
for (const component of components) {
|
|
203
|
+
component.data = record;
|
|
173
204
|
}
|
|
174
|
-
catch (_a) { }
|
|
175
205
|
}
|
|
176
|
-
|
|
177
|
-
if ('lastWaypoint' in c)
|
|
178
|
-
c.lastWaypoint = undefined;
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
if ((_b = root.carrierManager) === null || _b === void 0 ? void 0 : _b.dispose) {
|
|
182
|
-
root.carrierManager.dispose();
|
|
206
|
+
}
|
|
183
207
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
root.transitionEffect.dispose();
|
|
208
|
+
catch (e) {
|
|
209
|
+
console.error('[PlaybackEngine] seekPreview error:', e);
|
|
187
210
|
}
|
|
188
|
-
// 진행 중인 transition/animation이 완전히 끝나고, 캐리어 오브젝트가 완전히 제거되기를 기다림
|
|
189
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
190
211
|
}
|
|
191
212
|
on(event, listener) {
|
|
192
213
|
if (!this._listeners.has(event))
|
|
@@ -199,6 +220,8 @@ export class PlaybackEngine {
|
|
|
199
220
|
}
|
|
200
221
|
dispose() {
|
|
201
222
|
this._stopTimer();
|
|
223
|
+
if (this._scene)
|
|
224
|
+
this._scene.suppressAnimations = false;
|
|
202
225
|
this._scene = null;
|
|
203
226
|
this._frames = [];
|
|
204
227
|
this._listeners.clear();
|
|
@@ -209,6 +232,9 @@ export class PlaybackEngine {
|
|
|
209
232
|
return;
|
|
210
233
|
try {
|
|
211
234
|
const result = await this._fetchFn(this._formatTime(new Date(fromMs)), this._formatTime(new Date(toMs)));
|
|
235
|
+
// stop 후 응답이 돌아온 경우 무시 (잔여 diff 프레임 방지)
|
|
236
|
+
if (this._state === 'stopped')
|
|
237
|
+
return;
|
|
212
238
|
if ((result === null || result === void 0 ? void 0 : result.status) && result.playback) {
|
|
213
239
|
this._appendScenarios(result.playback.scenarios);
|
|
214
240
|
this._bufferedEnd = Math.max(this._bufferedEnd, toMs);
|
|
@@ -311,6 +337,7 @@ export class PlaybackEngine {
|
|
|
311
337
|
}
|
|
312
338
|
_applyFrame(index) {
|
|
313
339
|
var _a, _b;
|
|
340
|
+
// detach 후(=stop 경로) scene 이 null 이면 no-op
|
|
314
341
|
const frame = this._frames[index];
|
|
315
342
|
if (!frame || !this._scene)
|
|
316
343
|
return;
|