@operato/board 10.0.0-beta.27 → 10.0.0-beta.29

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.
@@ -1,36 +1,56 @@
1
1
  import gql from 'graphql-tag';
2
- import { subscribe } from '@operato/graphql';
2
+ import { PlaybackBuffer } from './playback-buffer.js';
3
3
  /**
4
- * PlaybackProvider
4
+ * PlaybackProvider — YouTube 스트리밍 방식의 청크 기반 플레이백
5
5
  *
6
- * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.
7
- * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,
8
- * 이를 받아서 등록된 컴포넌트에 전달한다.
9
- *
10
- * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로
11
- * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.
6
+ * - 10분 단위로 데이터를 fetch하여 버퍼에 적재
7
+ * - 남은 데이터 1분 이하 시 다음 10분 prefetch
8
+ * - seek 기존 버퍼 전체 폐기 후 fresh fetch
9
+ * - requestAnimationFrame 기반 재생 루프
12
10
  */
13
11
  export class PlaybackProvider {
14
12
  constructor(onStatusChange) {
15
13
  this._components = new Map();
16
- this._subscription = null;
14
+ this._buffer = null;
17
15
  this._state = 'idle';
18
16
  this._speed = 1;
19
- this._currentTime = '';
17
+ this._playHead = 0;
18
+ this._lastFrameTime = 0;
19
+ this._rafId = 0;
20
+ this._lastDistributedSnapshot = null;
21
+ this._totalRange = null;
22
+ this._playLoop = (now) => {
23
+ if (this._state !== 'playing' || !this._buffer)
24
+ return;
25
+ const delta = (now - this._lastFrameTime) * this._speed;
26
+ this._lastFrameTime = now;
27
+ this._playHead += delta;
28
+ // 전체 범위 끝 도달 체크
29
+ if (this._totalRange && this._playHead >= this._totalRange.to.getTime()) {
30
+ this._playHead = this._totalRange.to.getTime();
31
+ this._state = 'stopped';
32
+ this._notifyStatus();
33
+ return;
34
+ }
35
+ // 현재 스냅샷 배포
36
+ const snap = this._buffer.getSnapshotAt(this._playHead);
37
+ if (snap && snap !== this._lastDistributedSnapshot) {
38
+ this._distributeSnapshot(snap.data);
39
+ this._lastDistributedSnapshot = snap;
40
+ }
41
+ // prefetch 체크 (비동기, 루프 블로킹 안 함)
42
+ this._buffer.checkPrefetch(this._playHead);
43
+ this._notifyStatus();
44
+ // 다음 프레임
45
+ this._rafId = requestAnimationFrame(this._playLoop);
46
+ };
20
47
  this._onStatusChange = onStatusChange;
21
48
  }
22
- get state() {
23
- return this._state;
24
- }
25
- get speed() {
26
- return this._speed;
27
- }
28
- get currentTime() {
29
- return this._currentTime;
30
- }
49
+ get state() { return this._state; }
50
+ get speed() { return this._speed; }
51
+ get currentTime() { return this._playHead ? new Date(this._playHead).toISOString() : ''; }
31
52
  /**
32
53
  * DataSubscriptionProvider.subscribe 구현
33
- * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.
34
54
  */
35
55
  async subscribe(tag, component) {
36
56
  if (!this._components.has(tag)) {
@@ -50,94 +70,85 @@ export class PlaybackProvider {
50
70
  };
51
71
  }
52
72
  /**
53
- * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.
73
+ * 플레이백 시작
54
74
  */
55
- async start(fromTime, speed = 1) {
56
- var _a;
57
- // 기존 구독이 있으면 정리
58
- (_a = this._subscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
75
+ async start(fromTime, speed = 1, totalRange) {
76
+ this.stop();
59
77
  this._speed = speed;
78
+ this._totalRange = totalRange || { from: fromTime, to: new Date(fromTime.getTime() + 3600000) };
79
+ // 버퍼 생성 + 초기 로드
80
+ this._buffer = new PlaybackBuffer(this._createFetcher(), this._totalRange);
81
+ await this._buffer.loadInitial(fromTime.getTime());
82
+ this._playHead = fromTime.getTime();
60
83
  this._state = 'playing';
61
- this._subscription = await subscribe({
62
- query: gql `
63
- subscription Playback($fromTime: String!, $speed: Float!) {
64
- playback(fromTime: $fromTime, speed: $speed) {
65
- timestamp
66
- data
67
- }
68
- }
69
- `,
70
- variables: {
71
- fromTime: fromTime.toISOString(),
72
- speed
73
- }
74
- }, {
75
- next: ({ data }) => {
76
- if (!(data === null || data === void 0 ? void 0 : data.playback))
77
- return;
78
- const { timestamp, data: snapshotData } = data.playback;
79
- this._currentTime = timestamp;
80
- // 스냅샷 데이터를 tag별로 분배
81
- this._distributeSnapshot(snapshotData);
82
- this._notifyStatus();
83
- },
84
- error: (err) => {
85
- console.error('[PlaybackProvider] subscription error:', err);
86
- this._state = 'stopped';
87
- this._notifyStatus();
88
- },
89
- complete: () => {
90
- this._state = 'stopped';
91
- this._notifyStatus();
92
- }
93
- });
84
+ this._lastFrameTime = performance.now();
85
+ this._startPlayLoop();
94
86
  this._notifyStatus();
95
87
  }
96
88
  /**
97
89
  * 일시정지
98
90
  */
99
- async pause() {
91
+ pause() {
100
92
  if (this._state !== 'playing')
101
93
  return;
102
94
  this._state = 'paused';
103
- // 백엔드에 pause 명령 전송 (mutation)
104
- await this._sendCommand('pause');
95
+ this._stopPlayLoop();
105
96
  this._notifyStatus();
106
97
  }
107
98
  /**
108
99
  * 재개
109
100
  */
110
- async resume() {
101
+ resume() {
111
102
  if (this._state !== 'paused')
112
103
  return;
113
104
  this._state = 'playing';
114
- await this._sendCommand('resume');
105
+ this._lastFrameTime = performance.now();
106
+ this._startPlayLoop();
115
107
  this._notifyStatus();
116
108
  }
117
109
  /**
118
- * 특정 시점으로 이동
110
+ * seek 기존 버퍼 폐기 + 새 위치에서 fresh fetch
119
111
  */
120
112
  async seek(toTime) {
121
- await this._sendCommand('seek', { toTime: toTime.toISOString() });
122
- this._currentTime = toTime.toISOString();
113
+ if (!this._buffer)
114
+ return;
115
+ const wasPlaying = this._state === 'playing';
116
+ this._stopPlayLoop();
117
+ // 버퍼 전체 폐기 + 새 위치 로드
118
+ await this._buffer.seek(toTime.getTime());
119
+ this._playHead = toTime.getTime();
120
+ this._lastDistributedSnapshot = null;
121
+ // 새 위치의 첫 스냅샷 즉시 배포
122
+ const snap = this._buffer.getSnapshotAt(this._playHead);
123
+ if (snap) {
124
+ this._distributeSnapshot(snap.data);
125
+ this._lastDistributedSnapshot = snap;
126
+ }
127
+ if (wasPlaying) {
128
+ this._state = 'playing';
129
+ this._lastFrameTime = performance.now();
130
+ this._startPlayLoop();
131
+ }
123
132
  this._notifyStatus();
124
133
  }
125
134
  /**
126
135
  * 배속 변경
127
136
  */
128
- async setSpeed(speed) {
137
+ setSpeed(speed) {
129
138
  this._speed = speed;
130
- await this._sendCommand('speed', { speed });
131
139
  this._notifyStatus();
132
140
  }
133
141
  /**
134
- * 플레이백 중지 및 구독 해제
142
+ * 중지
135
143
  */
136
144
  stop() {
137
145
  var _a;
138
- (_a = this._subscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
139
- this._subscription = null;
146
+ this._stopPlayLoop();
147
+ (_a = this._buffer) === null || _a === void 0 ? void 0 : _a.clear();
148
+ this._buffer = null;
140
149
  this._state = 'stopped';
150
+ this._playHead = 0;
151
+ this._lastDistributedSnapshot = null;
141
152
  this._notifyStatus();
142
153
  }
143
154
  /**
@@ -145,12 +156,23 @@ export class PlaybackProvider {
145
156
  */
146
157
  dispose() {
147
158
  this.stop();
159
+ this._state = 'idle';
148
160
  this._components.clear();
149
161
  }
162
+ // --- 재생 루프 ---
163
+ _startPlayLoop() {
164
+ this._rafId = requestAnimationFrame(this._playLoop);
165
+ }
166
+ _stopPlayLoop() {
167
+ if (this._rafId) {
168
+ cancelAnimationFrame(this._rafId);
169
+ this._rafId = 0;
170
+ }
171
+ }
172
+ // --- 데이터 배포 ---
150
173
  _distributeSnapshot(snapshotData) {
151
174
  if (!snapshotData || typeof snapshotData !== 'object')
152
175
  return;
153
- // snapshotData: { tag1: value1, tag2: value2, ... }
154
176
  for (const [tag, value] of Object.entries(snapshotData)) {
155
177
  const components = this._components.get(tag);
156
178
  if (components) {
@@ -160,31 +182,76 @@ export class PlaybackProvider {
160
182
  }
161
183
  }
162
184
  }
163
- async _sendCommand(command, params = {}) {
164
- try {
165
- const { client } = await import('@operato/graphql');
166
- await client.mutate({
167
- mutation: gql `
168
- mutation PlaybackControl($command: String!, $params: String) {
169
- playbackControl(command: $command, params: $params)
170
- }
171
- `,
172
- variables: {
173
- command,
174
- params: JSON.stringify(params)
185
+ // --- 데이터 fetcher ---
186
+ _createFetcher() {
187
+ return async (fromTime, toTime) => {
188
+ var _a;
189
+ try {
190
+ const { client } = await import('@operato/graphql');
191
+ const response = await client.query({
192
+ query: gql `
193
+ query PlaybackChunk($startTime: String!, $endTime: String!) {
194
+ playback(startTime: $startTime, endTime: $endTime) {
195
+ status
196
+ playback {
197
+ scenarios {
198
+ scenarioName
199
+ snapshots {
200
+ type
201
+ time
202
+ data
203
+ }
204
+ }
175
205
  }
176
- });
177
- }
178
- catch (e) {
179
- console.error('[PlaybackProvider] command failed:', command, e);
206
+ }
207
+ }
208
+ `,
209
+ variables: {
210
+ startTime: fromTime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''),
211
+ endTime: toTime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '')
212
+ },
213
+ fetchPolicy: 'no-cache'
214
+ });
215
+ return this._parsePlaybackResponse((_a = response.data) === null || _a === void 0 ? void 0 : _a.playback);
216
+ }
217
+ catch (e) {
218
+ console.error('[PlaybackProvider] chunk fetch error:', e);
219
+ return [];
220
+ }
221
+ };
222
+ }
223
+ /**
224
+ * 백엔드 응답을 PlaybackSnapshot[] 형식으로 변환
225
+ */
226
+ _parsePlaybackResponse(response) {
227
+ var _a;
228
+ if (!(response === null || response === void 0 ? void 0 : response.status) || !((_a = response === null || response === void 0 ? void 0 : response.playback) === null || _a === void 0 ? void 0 : _a.scenarios))
229
+ return [];
230
+ const snapshotMap = new Map();
231
+ for (const scenario of response.playback.scenarios) {
232
+ for (const record of scenario.snapshots) {
233
+ const time = new Date(record.time.replace(' ', 'T')).getTime();
234
+ const data = typeof record.data === 'string' ? JSON.parse(record.data) : record.data;
235
+ if (!snapshotMap.has(time)) {
236
+ snapshotMap.set(time, {});
237
+ }
238
+ const merged = snapshotMap.get(time);
239
+ // scenarioName을 tag로 사용
240
+ merged[scenario.scenarioName] = data;
241
+ }
180
242
  }
243
+ return Array.from(snapshotMap.entries())
244
+ .map(([timestamp, data]) => ({ timestamp, data }))
245
+ .sort((a, b) => a.timestamp - b.timestamp);
181
246
  }
247
+ // --- 상태 통보 ---
182
248
  _notifyStatus() {
183
- var _a;
249
+ var _a, _b;
184
250
  (_a = this._onStatusChange) === null || _a === void 0 ? void 0 : _a.call(this, {
185
251
  state: this._state,
186
- currentTime: this._currentTime,
187
- speed: this._speed
252
+ currentTime: this._playHead ? new Date(this._playHead).toISOString() : '',
253
+ speed: this._speed,
254
+ bufferedRanges: (_b = this._buffer) === null || _b === void 0 ? void 0 : _b.getBufferedRanges()
188
255
  });
189
256
  }
190
257
  }
@@ -1 +1 @@
1
- {"version":3,"file":"playback-subscription.js","sourceRoot":"","sources":["../../../src/graphql/playback-subscription.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene'\n\nimport gql from 'graphql-tag'\nimport { subscribe } from '@operato/graphql'\n\nexport type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackStatus {\n state: PlaybackState\n currentTime: string\n speed: number\n}\n\nexport interface PlaybackConfig {\n /** 플레이백 가능한 시간 범위 */\n timeRange?: { from: Date; to: Date }\n}\n\n/**\n * PlaybackProvider\n *\n * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.\n * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,\n * 이를 받아서 등록된 컴포넌트에 전달한다.\n *\n * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로\n * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.\n */\nexport class PlaybackProvider implements DataSubscriptionProvider {\n private _components: Map<string, Set<Component>> = new Map()\n private _subscription: { unsubscribe: () => void } | null = null\n private _state: PlaybackState = 'idle'\n private _speed: number = 1\n private _currentTime: string = ''\n private _onStatusChange?: (status: PlaybackStatus) => void\n\n constructor(onStatusChange?: (status: PlaybackStatus) => void) {\n this._onStatusChange = onStatusChange\n }\n\n get state() {\n return this._state\n }\n\n get speed() {\n return this._speed\n }\n\n get currentTime() {\n return this._currentTime\n }\n\n /**\n * DataSubscriptionProvider.subscribe 구현\n * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.\n */\n async subscribe(tag: string, component: Component) {\n if (!this._components.has(tag)) {\n this._components.set(tag, new Set())\n }\n this._components.get(tag)!.add(component)\n\n return {\n unsubscribe: () => {\n const components = this._components.get(tag)\n if (components) {\n components.delete(component)\n if (components.size === 0) {\n this._components.delete(tag)\n }\n }\n }\n }\n }\n\n /**\n * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.\n */\n async start(fromTime: Date, speed: number = 1) {\n // 기존 구독이 있으면 정리\n this._subscription?.unsubscribe()\n\n this._speed = speed\n this._state = 'playing'\n\n this._subscription = await subscribe(\n {\n query: gql`\n subscription Playback($fromTime: String!, $speed: Float!) {\n playback(fromTime: $fromTime, speed: $speed) {\n timestamp\n data\n }\n }\n `,\n variables: {\n fromTime: fromTime.toISOString(),\n speed\n }\n },\n {\n next: ({ data }: { data: any }) => {\n if (!data?.playback) return\n\n const { timestamp, data: snapshotData } = data.playback\n this._currentTime = timestamp\n\n // 스냅샷 데이터를 tag별로 분배\n this._distributeSnapshot(snapshotData)\n\n this._notifyStatus()\n },\n error: (err: any) => {\n console.error('[PlaybackProvider] subscription error:', err)\n this._state = 'stopped'\n this._notifyStatus()\n },\n complete: () => {\n this._state = 'stopped'\n this._notifyStatus()\n }\n }\n )\n\n this._notifyStatus()\n }\n\n /**\n * 일시정지\n */\n async pause() {\n if (this._state !== 'playing') return\n\n this._state = 'paused'\n\n // 백엔드에 pause 명령 전송 (mutation)\n await this._sendCommand('pause')\n this._notifyStatus()\n }\n\n /**\n * 재개\n */\n async resume() {\n if (this._state !== 'paused') return\n\n this._state = 'playing'\n\n await this._sendCommand('resume')\n this._notifyStatus()\n }\n\n /**\n * 특정 시점으로 이동\n */\n async seek(toTime: Date) {\n await this._sendCommand('seek', { toTime: toTime.toISOString() })\n this._currentTime = toTime.toISOString()\n this._notifyStatus()\n }\n\n /**\n * 배속 변경\n */\n async setSpeed(speed: number) {\n this._speed = speed\n await this._sendCommand('speed', { speed })\n this._notifyStatus()\n }\n\n /**\n * 플레이백 중지 및 구독 해제\n */\n stop() {\n this._subscription?.unsubscribe()\n this._subscription = null\n this._state = 'stopped'\n this._notifyStatus()\n }\n\n /**\n * DataSubscriptionProvider.dispose 구현\n */\n dispose() {\n this.stop()\n this._components.clear()\n }\n\n private _distributeSnapshot(snapshotData: any) {\n if (!snapshotData || typeof snapshotData !== 'object') return\n\n // snapshotData: { tag1: value1, tag2: value2, ... }\n for (const [tag, value] of Object.entries(snapshotData)) {\n const components = this._components.get(tag)\n if (components) {\n for (const component of components) {\n component.data = value\n }\n }\n }\n }\n\n private async _sendCommand(command: string, params: Record<string, any> = {}) {\n try {\n const { client } = await import('@operato/graphql')\n await client.mutate({\n mutation: gql`\n mutation PlaybackControl($command: String!, $params: String) {\n playbackControl(command: $command, params: $params)\n }\n `,\n variables: {\n command,\n params: JSON.stringify(params)\n }\n })\n } catch (e) {\n console.error('[PlaybackProvider] command failed:', command, e)\n }\n }\n\n private _notifyStatus() {\n this._onStatusChange?.({\n state: this._state,\n currentTime: this._currentTime,\n speed: this._speed\n })\n }\n}\n"]}
1
+ {"version":3,"file":"playback-subscription.js","sourceRoot":"","sources":["../../../src/graphql/playback-subscription.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene'\nimport gql from 'graphql-tag'\nimport { PlaybackBuffer, type PlaybackSnapshot, type ChunkFetcher } from './playback-buffer.js'\n\nexport type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackStatus {\n state: PlaybackState\n currentTime: string\n speed: number\n bufferedRanges?: { from: number; to: number }[]\n}\n\nexport interface PlaybackConfig {\n /** 플레이백 가능한 시간 범위 */\n timeRange?: { from: Date; to: Date }\n}\n\n/**\n * PlaybackProvider — YouTube 스트리밍 방식의 청크 기반 플레이백\n *\n * - 10분 단위로 데이터를 fetch하여 버퍼에 적재\n * - 남은 데이터 1분 이하 시 다음 10분 prefetch\n * - seek 시 기존 버퍼 전체 폐기 후 fresh fetch\n * - requestAnimationFrame 기반 재생 루프\n */\nexport class PlaybackProvider implements DataSubscriptionProvider {\n private _components: Map<string, Set<Component>> = new Map()\n private _buffer: PlaybackBuffer | null = null\n private _state: PlaybackState = 'idle'\n private _speed: number = 1\n private _playHead: number = 0\n private _lastFrameTime: number = 0\n private _rafId: number = 0\n private _lastDistributedSnapshot: PlaybackSnapshot | null = null\n private _onStatusChange?: (status: PlaybackStatus) => void\n private _totalRange: { from: Date; to: Date } | null = null\n\n constructor(onStatusChange?: (status: PlaybackStatus) => void) {\n this._onStatusChange = onStatusChange\n }\n\n get state() { return this._state }\n get speed() { return this._speed }\n get currentTime() { return this._playHead ? new Date(this._playHead).toISOString() : '' }\n\n /**\n * DataSubscriptionProvider.subscribe 구현\n */\n async subscribe(tag: string, component: Component) {\n if (!this._components.has(tag)) {\n this._components.set(tag, new Set())\n }\n this._components.get(tag)!.add(component)\n\n return {\n unsubscribe: () => {\n const components = this._components.get(tag)\n if (components) {\n components.delete(component)\n if (components.size === 0) {\n this._components.delete(tag)\n }\n }\n }\n }\n }\n\n /**\n * 플레이백 시작\n */\n async start(fromTime: Date, speed: number = 1, totalRange?: { from: Date; to: Date }) {\n this.stop()\n\n this._speed = speed\n this._totalRange = totalRange || { from: fromTime, to: new Date(fromTime.getTime() + 3600000) }\n\n // 버퍼 생성 + 초기 로드\n this._buffer = new PlaybackBuffer(this._createFetcher(), this._totalRange)\n await this._buffer.loadInitial(fromTime.getTime())\n\n this._playHead = fromTime.getTime()\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n\n this._startPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * 일시정지\n */\n pause() {\n if (this._state !== 'playing') return\n this._state = 'paused'\n this._stopPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * 재개\n */\n resume() {\n if (this._state !== 'paused') return\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n this._startPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * seek — 기존 버퍼 폐기 + 새 위치에서 fresh fetch\n */\n async seek(toTime: Date) {\n if (!this._buffer) return\n\n const wasPlaying = this._state === 'playing'\n this._stopPlayLoop()\n\n // 버퍼 전체 폐기 + 새 위치 로드\n await this._buffer.seek(toTime.getTime())\n this._playHead = toTime.getTime()\n this._lastDistributedSnapshot = null\n\n // 새 위치의 첫 스냅샷 즉시 배포\n const snap = this._buffer.getSnapshotAt(this._playHead)\n if (snap) {\n this._distributeSnapshot(snap.data)\n this._lastDistributedSnapshot = snap\n }\n\n if (wasPlaying) {\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n this._startPlayLoop()\n }\n\n this._notifyStatus()\n }\n\n /**\n * 배속 변경\n */\n setSpeed(speed: number) {\n this._speed = speed\n this._notifyStatus()\n }\n\n /**\n * 중지\n */\n stop() {\n this._stopPlayLoop()\n this._buffer?.clear()\n this._buffer = null\n this._state = 'stopped'\n this._playHead = 0\n this._lastDistributedSnapshot = null\n this._notifyStatus()\n }\n\n /**\n * DataSubscriptionProvider.dispose 구현\n */\n dispose() {\n this.stop()\n this._state = 'idle'\n this._components.clear()\n }\n\n // --- 재생 루프 ---\n\n private _startPlayLoop() {\n this._rafId = requestAnimationFrame(this._playLoop)\n }\n\n private _stopPlayLoop() {\n if (this._rafId) {\n cancelAnimationFrame(this._rafId)\n this._rafId = 0\n }\n }\n\n private _playLoop = (now: number) => {\n if (this._state !== 'playing' || !this._buffer) return\n\n const delta = (now - this._lastFrameTime) * this._speed\n this._lastFrameTime = now\n this._playHead += delta\n\n // 전체 범위 끝 도달 체크\n if (this._totalRange && this._playHead >= this._totalRange.to.getTime()) {\n this._playHead = this._totalRange.to.getTime()\n this._state = 'stopped'\n this._notifyStatus()\n return\n }\n\n // 현재 스냅샷 배포\n const snap = this._buffer.getSnapshotAt(this._playHead)\n if (snap && snap !== this._lastDistributedSnapshot) {\n this._distributeSnapshot(snap.data)\n this._lastDistributedSnapshot = snap\n }\n\n // prefetch 체크 (비동기, 루프 블로킹 안 함)\n this._buffer.checkPrefetch(this._playHead)\n\n this._notifyStatus()\n\n // 다음 프레임\n this._rafId = requestAnimationFrame(this._playLoop)\n }\n\n // --- 데이터 배포 ---\n\n private _distributeSnapshot(snapshotData: any) {\n if (!snapshotData || typeof snapshotData !== 'object') return\n\n for (const [tag, value] of Object.entries(snapshotData)) {\n const components = this._components.get(tag)\n if (components) {\n for (const component of components) {\n component.data = value\n }\n }\n }\n }\n\n // --- 데이터 fetcher ---\n\n private _createFetcher(): ChunkFetcher {\n return async (fromTime: Date, toTime: Date): Promise<PlaybackSnapshot[]> => {\n try {\n const { client } = await import('@operato/graphql')\n const response = await client.query({\n query: gql`\n query PlaybackChunk($startTime: String!, $endTime: String!) {\n playback(startTime: $startTime, endTime: $endTime) {\n status\n playback {\n scenarios {\n scenarioName\n snapshots {\n type\n time\n data\n }\n }\n }\n }\n }\n `,\n variables: {\n startTime: fromTime.toISOString().replace('T', ' ').replace(/\\.\\d+Z$/, ''),\n endTime: toTime.toISOString().replace('T', ' ').replace(/\\.\\d+Z$/, '')\n },\n fetchPolicy: 'no-cache'\n })\n\n return this._parsePlaybackResponse(response.data?.playback)\n } catch (e) {\n console.error('[PlaybackProvider] chunk fetch error:', e)\n return []\n }\n }\n }\n\n /**\n * 백엔드 응답을 PlaybackSnapshot[] 형식으로 변환\n */\n private _parsePlaybackResponse(response: any): PlaybackSnapshot[] {\n if (!response?.status || !response?.playback?.scenarios) return []\n\n const snapshotMap = new Map<number, Record<string, any>>()\n\n for (const scenario of response.playback.scenarios) {\n for (const record of scenario.snapshots) {\n const time = new Date(record.time.replace(' ', 'T')).getTime()\n const data = typeof record.data === 'string' ? JSON.parse(record.data) : record.data\n\n if (!snapshotMap.has(time)) {\n snapshotMap.set(time, {})\n }\n const merged = snapshotMap.get(time)!\n // scenarioName을 tag로 사용\n merged[scenario.scenarioName] = data\n }\n }\n\n return Array.from(snapshotMap.entries())\n .map(([timestamp, data]) => ({ timestamp, data }))\n .sort((a, b) => a.timestamp - b.timestamp)\n }\n\n // --- 상태 통보 ---\n\n private _notifyStatus() {\n this._onStatusChange?.({\n state: this._state,\n currentTime: this._playHead ? new Date(this._playHead).toISOString() : '',\n speed: this._speed,\n bufferedRanges: this._buffer?.getBufferedRanges()\n })\n }\n}\n"]}
@@ -20,6 +20,10 @@ export declare class PlaybackControls extends LitElement {
20
20
  playbackState: PlaybackState;
21
21
  speed: number;
22
22
  currentTime: string;
23
+ bufferedRanges: {
24
+ from: number;
25
+ to: number;
26
+ }[];
23
27
  /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */
24
28
  timeRange: {
25
29
  from: Date;
@@ -27,6 +31,8 @@ export declare class PlaybackControls extends LitElement {
27
31
  };
28
32
  private _startTime;
29
33
  private _seekValue;
34
+ private _seeking;
35
+ private _seekPreviewTime;
30
36
  private _speeds;
31
37
  private get _endTime();
32
38
  private static _floorToHour;
@@ -39,10 +45,12 @@ export declare class PlaybackControls extends LitElement {
39
45
  private _onDateChange;
40
46
  private _onHourChange;
41
47
  private _shiftTime;
48
+ private _shiftStartTime;
42
49
  private _onStart;
43
50
  private _onTogglePlayPause;
44
51
  private _onStop;
45
52
  private _onSeekInput;
46
53
  private _onSeekChange;
54
+ private _renderBufferBars;
47
55
  private _onSpeedChange;
48
56
  }
@@ -24,6 +24,7 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
24
24
  this.playbackState = 'idle';
25
25
  this.speed = 1;
26
26
  this.currentTime = '';
27
+ this.bufferedRanges = [];
27
28
  /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */
28
29
  this.timeRange = {
29
30
  from: new Date(Date.now() - ONE_HOUR),
@@ -31,6 +32,8 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
31
32
  };
32
33
  this._startTime = PlaybackControls_1._floorToHour(new Date(Date.now() - ONE_HOUR));
33
34
  this._seekValue = 0;
35
+ this._seeking = false;
36
+ this._seekPreviewTime = null;
34
37
  this._speeds = [1, 2, 4, 8];
35
38
  }
36
39
  get _endTime() {
@@ -49,7 +52,7 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
49
52
  if (changes.has('timeRange') && this.timeRange) {
50
53
  this._startTime = PlaybackControls_1._floorToHour(this.timeRange.from);
51
54
  }
52
- if (changes.has('currentTime') && this.currentTime) {
55
+ if (changes.has('currentTime') && this.currentTime && !this._seeking) {
53
56
  this._updateSeekPosition();
54
57
  }
55
58
  }
@@ -115,15 +118,19 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
115
118
  `}
116
119
 
117
120
  <div class="timeline">
118
- <input
119
- type="range"
120
- class="seek-bar"
121
- min="0"
122
- max="1000"
123
- .value=${String(this._seekValue)}
124
- @input=${this._onSeekInput}
125
- @change=${this._onSeekChange}
126
- />
121
+ <div class="seek-track">
122
+ <div class="seek-rail"></div>
123
+ ${this._renderBufferBars()}
124
+ <input
125
+ type="range"
126
+ class="seek-bar"
127
+ min="0"
128
+ max="1000"
129
+ .value=${String(this._seekValue)}
130
+ @input=${this._onSeekInput}
131
+ @change=${this._onSeekChange}
132
+ />
133
+ </div>
127
134
  <div class="seek-labels">
128
135
  <span>${this._formatTime(this._startTime)}</span>
129
136
  <span>${this._formatTime(this._endTime)}</span>
@@ -131,7 +138,9 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
131
138
  </div>
132
139
 
133
140
  <span class="current-time">
134
- ${this.currentTime ? this._formatTime(new Date(this.currentTime)) : '--:--:--'}
141
+ ${this._seeking && this._seekPreviewTime
142
+ ? this._formatTime(this._seekPreviewTime)
143
+ : this.currentTime ? this._formatTime(new Date(this.currentTime)) : '--:--:--'}
135
144
  </span>
136
145
 
137
146
  <md-icon-button @click=${this._onStop}>
@@ -149,7 +158,7 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
149
158
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
150
159
  }
151
160
  _updateSeekPosition() {
152
- if (!this.currentTime)
161
+ if (!this.currentTime || this._seeking)
153
162
  return;
154
163
  const current = new Date(this.currentTime).getTime();
155
164
  const from = this._startTime.getTime();
@@ -166,19 +175,35 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
166
175
  const [year, month, day] = value.split('-').map(Number);
167
176
  const t = new Date(this._startTime);
168
177
  t.setFullYear(year, month - 1, day);
169
- this._startTime = t;
178
+ this._shiftStartTime(t);
170
179
  }
171
180
  _onHourChange(e) {
172
181
  const hour = Number(e.target.value);
173
182
  const t = new Date(this._startTime);
174
183
  t.setHours(hour, 0, 0, 0);
175
- this._startTime = t;
184
+ this._shiftStartTime(t);
176
185
  }
177
186
  _shiftTime(hours) {
178
187
  return () => {
179
- this._startTime = new Date(this._startTime.getTime() + hours * ONE_HOUR);
188
+ this._shiftStartTime(new Date(this._startTime.getTime() + hours * ONE_HOUR));
180
189
  };
181
190
  }
191
+ _shiftStartTime(newStart) {
192
+ const wasPlaying = this.playbackState === 'playing';
193
+ const wasPaused = this.playbackState === 'paused';
194
+ this._startTime = newStart;
195
+ this._seekValue = 0;
196
+ if (wasPlaying) {
197
+ this._onStart();
198
+ }
199
+ else if (wasPaused) {
200
+ this.dispatchEvent(new CustomEvent('playback-seek', {
201
+ detail: { toTime: new Date(newStart) },
202
+ bubbles: true,
203
+ composed: true
204
+ }));
205
+ }
206
+ }
182
207
  _onStart() {
183
208
  this.dispatchEvent(new CustomEvent('playback-start', {
184
209
  detail: { fromTime: this._startTime, speed: this.speed },
@@ -198,19 +223,42 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
198
223
  this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }));
199
224
  }
200
225
  _onSeekInput(e) {
226
+ this._seeking = true;
201
227
  this._seekValue = Number(e.target.value);
228
+ const ratio = this._seekValue / 1000;
229
+ const from = this._startTime.getTime();
230
+ const to = this._endTime.getTime();
231
+ this._seekPreviewTime = new Date(from + (to - from) * ratio);
202
232
  }
203
233
  _onSeekChange(e) {
234
+ this._seeking = false;
204
235
  const ratio = Number(e.target.value) / 1000;
205
236
  const from = this._startTime.getTime();
206
237
  const to = this._endTime.getTime();
207
238
  const targetTime = new Date(from + (to - from) * ratio);
239
+ this._seekPreviewTime = null;
208
240
  this.dispatchEvent(new CustomEvent('playback-seek', {
209
241
  detail: { toTime: targetTime },
210
242
  bubbles: true,
211
243
  composed: true
212
244
  }));
213
245
  }
246
+ _renderBufferBars() {
247
+ var _a;
248
+ if (!((_a = this.bufferedRanges) === null || _a === void 0 ? void 0 : _a.length))
249
+ return '';
250
+ const from = this._startTime.getTime();
251
+ const to = this._endTime.getTime();
252
+ const range = to - from;
253
+ if (range <= 0)
254
+ return '';
255
+ return this.bufferedRanges.map(r => {
256
+ const left = Math.max(0, (r.from - from) / range) * 100;
257
+ const right = Math.min(1, (r.to - from) / range) * 100;
258
+ const width = right - left;
259
+ return html `<div class="buffer-bar" style="left:${left}%;width:${width}%"></div>`;
260
+ });
261
+ }
214
262
  _onSpeedChange(speed) {
215
263
  this.dispatchEvent(new CustomEvent('playback-speed', {
216
264
  detail: { speed },
@@ -351,6 +399,10 @@ PlaybackControls.styles = css `
351
399
  --md-icon-button-container-height: 40px;
352
400
  }
353
401
 
402
+ .row-seek md-icon {
403
+ font-variation-settings: 'FILL' 1;
404
+ }
405
+
354
406
  .timeline {
355
407
  flex: 1;
356
408
  display: flex;
@@ -360,14 +412,23 @@ PlaybackControls.styles = css `
360
412
  }
361
413
 
362
414
  .seek-bar {
415
+ position: absolute;
416
+ top: 0;
417
+ left: 0;
363
418
  width: 100%;
364
- height: 4px;
419
+ height: 12px;
365
420
  -webkit-appearance: none;
366
421
  appearance: none;
367
- background: rgba(255, 255, 255, 0.3);
368
- border-radius: 2px;
422
+ background: transparent;
369
423
  outline: none;
370
424
  cursor: pointer;
425
+ z-index: 2;
426
+ margin: 0;
427
+ }
428
+
429
+ .seek-bar::-webkit-slider-runnable-track {
430
+ height: 12px;
431
+ background: transparent;
371
432
  }
372
433
 
373
434
  .seek-bar::-webkit-slider-thumb {
@@ -379,6 +440,32 @@ PlaybackControls.styles = css `
379
440
  cursor: pointer;
380
441
  }
381
442
 
443
+ .seek-track {
444
+ position: relative;
445
+ width: 100%;
446
+ height: 12px;
447
+ }
448
+
449
+ .seek-rail {
450
+ position: absolute;
451
+ top: 4px;
452
+ left: 0;
453
+ right: 0;
454
+ height: 4px;
455
+ background: rgba(255, 255, 255, 0.15);
456
+ border-radius: 2px;
457
+ }
458
+
459
+ .buffer-bar {
460
+ position: absolute;
461
+ top: 4px;
462
+ height: 4px;
463
+ background: rgba(255, 255, 255, 0.4);
464
+ border-radius: 2px;
465
+ pointer-events: none;
466
+ z-index: 1;
467
+ }
468
+
382
469
  .seek-labels {
383
470
  display: flex;
384
471
  justify-content: space-between;
@@ -403,6 +490,9 @@ __decorate([
403
490
  __decorate([
404
491
  property({ type: String })
405
492
  ], PlaybackControls.prototype, "currentTime", void 0);
493
+ __decorate([
494
+ property({ type: Array })
495
+ ], PlaybackControls.prototype, "bufferedRanges", void 0);
406
496
  __decorate([
407
497
  property({ type: Object })
408
498
  ], PlaybackControls.prototype, "timeRange", void 0);
@@ -412,6 +502,12 @@ __decorate([
412
502
  __decorate([
413
503
  state()
414
504
  ], PlaybackControls.prototype, "_seekValue", void 0);
505
+ __decorate([
506
+ state()
507
+ ], PlaybackControls.prototype, "_seeking", void 0);
508
+ __decorate([
509
+ state()
510
+ ], PlaybackControls.prototype, "_seekPreviewTime", void 0);
415
511
  PlaybackControls = PlaybackControls_1 = __decorate([
416
512
  customElement('ox-playback-controls')
417
513
  ], PlaybackControls);