@fmsim/board 2.0.0-beta.11 → 2.0.0-beta.111

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.
Files changed (126) hide show
  1. package/custom-elements.json +6502 -2308
  2. package/demo/index-modeller.html +1 -1
  3. package/demo/index-player.html +2 -2
  4. package/demo/index-viewer.html +2 -2
  5. package/demo/index.html +2 -2
  6. package/dist/src/board-i18n.d.ts +37 -0
  7. package/dist/src/board-i18n.js +121 -0
  8. package/dist/src/board-i18n.js.map +1 -0
  9. package/dist/src/component/etc.js +36 -0
  10. package/dist/src/component/etc.js.map +1 -1
  11. package/dist/src/component/index.d.ts +1 -0
  12. package/dist/src/component/index.js +1 -0
  13. package/dist/src/component/index.js.map +1 -1
  14. package/dist/src/component/restful-property-editor-backdrop-faces.d.ts +50 -0
  15. package/dist/src/component/restful-property-editor-backdrop-faces.js +524 -0
  16. package/dist/src/component/restful-property-editor-backdrop-faces.js.map +1 -0
  17. package/dist/src/config/flow-replay.d.ts +14 -0
  18. package/dist/src/config/flow-replay.js +20 -0
  19. package/dist/src/config/flow-replay.js.map +1 -0
  20. package/dist/src/data-storage/board-model-cache.d.ts +13 -0
  21. package/dist/src/data-storage/board-model-cache.js +66 -0
  22. package/dist/src/data-storage/board-model-cache.js.map +1 -0
  23. package/dist/src/index.d.ts +5 -0
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/layers/mcs-mouse-event-handler.js +28 -3
  27. package/dist/src/layers/mcs-mouse-event-handler.js.map +1 -1
  28. package/dist/src/layers/movement-layer.d.ts +9 -6
  29. package/dist/src/layers/movement-layer.js +35 -10
  30. package/dist/src/layers/movement-layer.js.map +1 -1
  31. package/dist/src/modeller/bulk-create-dialog.d.ts +62 -0
  32. package/dist/src/modeller/bulk-create-dialog.js +595 -0
  33. package/dist/src/modeller/bulk-create-dialog.js.map +1 -0
  34. package/dist/src/modeller/bulk-create.d.ts +107 -0
  35. package/dist/src/modeller/bulk-create.js +201 -0
  36. package/dist/src/modeller/bulk-create.js.map +1 -0
  37. package/dist/src/modeller/component-toolbar/component-menu.d.ts +1 -1
  38. package/dist/src/modeller/component-toolbar/component-toolbar.d.ts +7 -1
  39. package/dist/src/modeller/component-toolbar/component-toolbar.js +18 -2
  40. package/dist/src/modeller/component-toolbar/component-toolbar.js.map +1 -1
  41. package/dist/src/modeller/edit-toolbar.d.ts +13 -0
  42. package/dist/src/modeller/edit-toolbar.js +177 -7
  43. package/dist/src/modeller/edit-toolbar.js.map +1 -1
  44. package/dist/src/modeller/property-sidebar/abstract-property.js +10 -2
  45. package/dist/src/modeller/property-sidebar/abstract-property.js.map +1 -1
  46. package/dist/src/modeller/property-sidebar/data-binding/data-binding.d.ts +1 -1
  47. package/dist/src/modeller/property-sidebar/effects/effects.d.ts +10 -1
  48. package/dist/src/modeller/property-sidebar/effects/effects.js +67 -1
  49. package/dist/src/modeller/property-sidebar/effects/effects.js.map +1 -1
  50. package/dist/src/modeller/property-sidebar/effects/event-handler-adapter.d.ts +44 -0
  51. package/dist/src/modeller/property-sidebar/effects/event-handler-adapter.js +70 -0
  52. package/dist/src/modeller/property-sidebar/effects/event-handler-adapter.js.map +1 -0
  53. package/dist/src/modeller/property-sidebar/effects/property-event.d.ts +13 -3
  54. package/dist/src/modeller/property-sidebar/effects/property-event.js +20 -11
  55. package/dist/src/modeller/property-sidebar/effects/property-event.js.map +1 -1
  56. package/dist/src/modeller/property-sidebar/inspector/inspector-controller.d.ts +66 -0
  57. package/dist/src/modeller/property-sidebar/inspector/inspector-controller.js +288 -0
  58. package/dist/src/modeller/property-sidebar/inspector/inspector-controller.js.map +1 -0
  59. package/dist/src/modeller/property-sidebar/inspector/inspector-group-view.d.ts +15 -0
  60. package/dist/src/modeller/property-sidebar/inspector/inspector-group-view.js +67 -0
  61. package/dist/src/modeller/property-sidebar/inspector/inspector-group-view.js.map +1 -0
  62. package/dist/src/modeller/property-sidebar/inspector/inspector-highlight.d.ts +7 -0
  63. package/dist/src/modeller/property-sidebar/inspector/inspector-highlight.js +25 -0
  64. package/dist/src/modeller/property-sidebar/inspector/inspector-highlight.js.map +1 -0
  65. package/dist/src/modeller/property-sidebar/inspector/inspector-model.d.ts +44 -0
  66. package/dist/src/modeller/property-sidebar/inspector/inspector-model.js +72 -0
  67. package/dist/src/modeller/property-sidebar/inspector/inspector-model.js.map +1 -0
  68. package/dist/src/modeller/property-sidebar/inspector/inspector-tree-view.d.ts +18 -0
  69. package/dist/src/modeller/property-sidebar/inspector/inspector-tree-view.js +61 -0
  70. package/dist/src/modeller/property-sidebar/inspector/inspector-tree-view.js.map +1 -0
  71. package/dist/src/modeller/property-sidebar/inspector/inspector.d.ts +17 -12
  72. package/dist/src/modeller/property-sidebar/inspector/inspector.js +287 -151
  73. package/dist/src/modeller/property-sidebar/inspector/inspector.js.map +1 -1
  74. package/dist/src/modeller/property-sidebar/property-sidebar.d.ts +1 -1
  75. package/dist/src/modeller/property-sidebar/shapes/ox-input-entity-id.d.ts +75 -0
  76. package/dist/src/modeller/property-sidebar/shapes/ox-input-entity-id.js +355 -0
  77. package/dist/src/modeller/property-sidebar/shapes/ox-input-entity-id.js.map +1 -0
  78. package/dist/src/modeller/property-sidebar/shapes/shapes.d.ts +22 -1
  79. package/dist/src/modeller/property-sidebar/shapes/shapes.js +110 -2
  80. package/dist/src/modeller/property-sidebar/shapes/shapes.js.map +1 -1
  81. package/dist/src/modeller/property-sidebar/specifics/parent-object.d.ts +29 -0
  82. package/dist/src/modeller/property-sidebar/specifics/parent-object.js +74 -0
  83. package/dist/src/modeller/property-sidebar/specifics/parent-object.js.map +1 -0
  84. package/dist/src/modeller/property-sidebar/specifics/specifics.d.ts +27 -1
  85. package/dist/src/modeller/property-sidebar/specifics/specifics.js +95 -3
  86. package/dist/src/modeller/property-sidebar/specifics/specifics.js.map +1 -1
  87. package/dist/src/modeller/property-sidebar/styles/styles.d.ts +1 -1
  88. package/dist/src/modeller/scene-viewer/ox-scene-layer.d.ts +4 -3
  89. package/dist/src/modeller/scene-viewer/ox-scene-layer.js +2 -1
  90. package/dist/src/modeller/scene-viewer/ox-scene-layer.js.map +1 -1
  91. package/dist/src/modeller/scene-viewer/ox-scene-viewer.d.ts +2 -1
  92. package/dist/src/modeller/scene-viewer/ox-scene-viewer.js +5 -1
  93. package/dist/src/modeller/scene-viewer/ox-scene-viewer.js.map +1 -1
  94. package/dist/src/ox-board-component-info.d.ts +1 -0
  95. package/dist/src/ox-board-component-info.js +42 -2
  96. package/dist/src/ox-board-component-info.js.map +1 -1
  97. package/dist/src/ox-board-modeller.d.ts +6 -3
  98. package/dist/src/ox-board-modeller.js +21 -1
  99. package/dist/src/ox-board-modeller.js.map +1 -1
  100. package/dist/src/ox-board-viewer.d.ts +117 -13
  101. package/dist/src/ox-board-viewer.js +688 -102
  102. package/dist/src/ox-board-viewer.js.map +1 -1
  103. package/dist/src/ox-editor-board-selector.d.ts +3 -0
  104. package/dist/src/ox-editor-board-selector.js +40 -9
  105. package/dist/src/ox-editor-board-selector.js.map +1 -1
  106. package/dist/src/ox-playback-controls.d.ts +115 -18
  107. package/dist/src/ox-playback-controls.js +744 -167
  108. package/dist/src/ox-playback-controls.js.map +1 -1
  109. package/dist/src/playback/carrier-tracking-store.d.ts +51 -0
  110. package/dist/src/playback/carrier-tracking-store.js +115 -0
  111. package/dist/src/playback/carrier-tracking-store.js.map +1 -0
  112. package/dist/src/playback/playback-engine.d.ts +106 -0
  113. package/dist/src/playback/playback-engine.js +395 -0
  114. package/dist/src/playback/playback-engine.js.map +1 -0
  115. package/dist/src/scene-monitor-renderer.d.ts +10 -0
  116. package/dist/src/scene-monitor-renderer.js +226 -0
  117. package/dist/src/scene-monitor-renderer.js.map +1 -0
  118. package/dist/src/selector/ox-board-selector.js +14 -2
  119. package/dist/src/selector/ox-board-selector.js.map +1 -1
  120. package/dist/tsconfig.tsbuildinfo +1 -1
  121. package/icons/components/text.png +0 -0
  122. package/icons/fab/mode-2d.svg +25 -0
  123. package/icons/fab/mode-3d.svg +25 -0
  124. package/icons/fab/playback-play.svg +49 -0
  125. package/icons/fab/playback-stop.svg +50 -0
  126. package/package.json +8 -4
@@ -4,18 +4,47 @@ import '@material/web/icon/icon.js';
4
4
  import '@material/web/iconbutton/icon-button.js';
5
5
  import { css, html, LitElement } from 'lit';
6
6
  import { customElement, property, state } from 'lit/decorators.js';
7
- const ONE_HOUR = 3600000;
7
+ /**
8
+ * Window-length presets shown in the range dropdown. Values are durations
9
+ * in milliseconds; `'Range'` is the sentinel for "user picks both bounds
10
+ * directly". The fixed presets cap at 2H — same as the explicit Range
11
+ * mode max gap — so any preset switch produces a window already inside
12
+ * the allowed envelope.
13
+ */
14
+ const RANGE_DURATIONS = {
15
+ '10m': 10 * 60 * 1000,
16
+ '30m': 30 * 60 * 1000,
17
+ '1H': 60 * 60 * 1000,
18
+ '2H': 2 * 60 * 60 * 1000
19
+ };
20
+ const ONE_HOUR = RANGE_DURATIONS['1H'];
21
+ const MAX_GAP_MS = RANGE_DURATIONS['2H']; // 2H upper bound on (to − from)
22
+ const MIN_GAP_MS = RANGE_DURATIONS['10m']; // 10m lower bound (prevents reversal / zero-window)
8
23
  /**
9
24
  * 플레이백 컨트롤 오버레이 바 (2행 구조)
10
25
  *
11
- * 1행: 시작 시점 설정 (datetime + ±1h 버튼) + 배속 선택
12
- * 2행: 재생 제어 (play/pause/stop) + 1시간 범위 시크바 + 닫기
26
+ * 1행 (config): Range 선택 (10m / 30m / 1H / 2H / Range) + ←/→ shift
27
+ * + FROM/TO datetime 입력 (date 캘린더 + time 키인) + Apply 버튼
28
+ * + 배속 (×1 ×2 ×4)
29
+ *
30
+ * - Fixed presets: TO 가 anchor (←/→ 로 shift), FROM = TO − range.
31
+ * 입력 disabled. 초기 TO = 현재시간.
32
+ * - Range mode: 양쪽 직접 입력. 2H cap, 10m min, future clamp,
33
+ * 마지막 편집한 쪽이 anchor.
34
+ * - 상단 변경은 PENDING tier 만 갱신 — Apply 누르기 전에는 하단
35
+ * 슬라이더/엔진에 영향 없음.
13
36
  *
14
- * @fires playback-start - 플레이백 시작 요청 { fromTime, speed }
37
+ * 2행 (control): play / pause / 시크바 (적용된 range 만큼 span) /
38
+ * 현재 재생 시각 / 닫기.
39
+ *
40
+ * @fires playback-start - 플레이백 시작 요청 { fromTime, toTime, speed }
41
+ * toTime 은 window END — 엔진이 chunked fetch 의 상한으로 사용.
15
42
  * @fires playback-pause - 일시정지 요청
16
43
  * @fires playback-resume - 재개 요청
17
44
  * @fires playback-stop - 중지 요청
18
- * @fires playback-seek - 시크 요청 { toTime }
45
+ * @fires playback-seek - 드래그 릴리즈 시 시크 요청 { toTime }
46
+ * @fires playback-seek-start - 드래그 첫 input 시 즉시 발행 (엔진 타이머·애니메이션 정지 신호, detail 없음)
47
+ * @fires playback-seek-preview - 드래그 중 throttle (leading+trailing, 200ms) 된 스크러빙 요청 { toTime }
19
48
  * @fires playback-speed - 배속 변경 요청 { speed }
20
49
  */
21
50
  let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitElement {
@@ -24,110 +53,261 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
24
53
  this.playbackState = 'idle';
25
54
  this.speed = 1;
26
55
  this.currentTime = '';
56
+ this.bufferedRanges = [];
27
57
  /** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */
28
58
  this.timeRange = {
29
59
  from: new Date(Date.now() - ONE_HOUR),
30
60
  to: new Date()
31
61
  };
32
- this._startTime = PlaybackControls_1._floorToHour(new Date(Date.now() - ONE_HOUR));
33
- this._speeds = [1, 2, 4, 8];
34
- }
35
- get _endTime() {
36
- return new Date(this._startTime.getTime() + ONE_HOUR);
37
- }
38
- static _floorToHour(date) {
39
- const d = new Date(date);
40
- d.setMinutes(0, 0, 0);
41
- return d;
62
+ /**
63
+ * Two-tier state model:
64
+ *
65
+ * PENDING (`_pendingRange`, `_pendingFrom`, `_pendingTo`)
66
+ * The upper bar's draft. User edits these via the range dropdown,
67
+ * ←/→ shift, or direct date/time inputs (Range mode only). The
68
+ * lower seekbar is NOT driven by these — they only become live
69
+ * when the user clicks "적용".
70
+ *
71
+ * APPLIED (`_appliedFrom`, `_appliedTo`)
72
+ * The window the seekbar visualizes and the engine fetches data
73
+ * for. Updated only by `_onApply()`, which mirrors pending values
74
+ * here and dispatches a `playback-start` to refresh the engine
75
+ * with the new window (when playing/paused).
76
+ *
77
+ * Splitting these prevents mid-edit churn: typing into the time input
78
+ * doesn't refetch on every keystroke, and the slider doesn't jiggle
79
+ * while the user picks a date.
80
+ */
81
+ this._pendingRange = '1H';
82
+ this._pendingFrom = new Date(Date.now() - ONE_HOUR);
83
+ this._pendingTo = new Date();
84
+ /**
85
+ * Which side did the user touch most recently in Range mode? Anchors
86
+ * the auto-adjust when the resulting gap violates min/max — the
87
+ * unedited side is what gets pushed to a legal value, so the user's
88
+ * latest intent is preserved.
89
+ */
90
+ this._lastEdited = 'to';
91
+ this._appliedFrom = new Date(Date.now() - ONE_HOUR);
92
+ this._appliedTo = new Date();
93
+ this._seeking = false;
94
+ this._seekPreviewTime = null;
95
+ this._speeds = [1, 2, 4];
96
+ /** 드래그 중 가장 최근 seekbar 위치 시각 */
97
+ this._latestPreviewTime = null;
98
+ /** 가장 최근에 실제 emit 한 preview 시각 (dedupe 용) */
99
+ this._lastEmittedPreviewTime = null;
42
100
  }
43
101
  connectedCallback() {
44
102
  super.connectedCallback();
45
- this._startTime = PlaybackControls_1._floorToHour(this.timeRange.from);
103
+ // Initialize both pending and applied from the incoming timeRange.
104
+ // Default range = 1H (matches the previous fixed-window behavior),
105
+ // so the seekbar covers a 1H window from the start.
106
+ const initFrom = new Date(this.timeRange.from);
107
+ const initTo = new Date(this.timeRange.to);
108
+ this._appliedFrom = initFrom;
109
+ this._appliedTo = initTo;
110
+ this._pendingFrom = new Date(initFrom);
111
+ this._pendingTo = new Date(initTo);
112
+ this._pendingRange = this._inferRangeKey(initTo.getTime() - initFrom.getTime());
46
113
  this._seekValue = 0;
47
114
  this._currentPlayTime = null;
48
115
  }
49
116
  disconnectedCallback() {
50
117
  super.disconnectedCallback();
51
- this._stopTimer();
118
+ this._cancelPreviewThrottle();
52
119
  }
53
120
  updated(changes) {
54
121
  if (changes.has('timeRange') && this.timeRange) {
55
- this._startTime = PlaybackControls_1._floorToHour(this.timeRange.from);
122
+ // Resync both tiers from the externally-supplied window.
123
+ this._appliedFrom = new Date(this.timeRange.from);
124
+ this._appliedTo = new Date(this.timeRange.to);
125
+ this._pendingFrom = new Date(this._appliedFrom);
126
+ this._pendingTo = new Date(this._appliedTo);
127
+ this._pendingRange = this._inferRangeKey(this._appliedTo.getTime() - this._appliedFrom.getTime());
56
128
  }
57
- if (changes.has('currentTime') && this.currentTime) {
58
- this._updateSeekPosition();
129
+ // 엔진 프레임(currentTime 프로프 변경)이 유일한 시간 소스.
130
+ // drag 중에는 _seeking 가 true 이므로 엔진 업데이트 무시 (스크러빙 중 UI 튐 방지).
131
+ if (changes.has('currentTime') && this.currentTime && !this._seeking) {
132
+ this._syncFromEngine();
59
133
  }
60
134
  }
135
+ /**
136
+ * Map a millisecond gap to the matching preset key, or `'Range'` if
137
+ * the gap doesn't match any preset (custom window already in effect).
138
+ */
139
+ _inferRangeKey(gapMs) {
140
+ for (const k of ['10m', '30m', '1H', '2H']) {
141
+ if (RANGE_DURATIONS[k] === gapMs)
142
+ return k;
143
+ }
144
+ return 'Range';
145
+ }
61
146
  render() {
62
147
  const isPlaying = this.playbackState === 'playing';
63
148
  const isPaused = this.playbackState === 'paused';
64
149
  const isActive = isPlaying || isPaused;
150
+ const isRangeMode = this._pendingRange === 'Range';
151
+ const isDirty = this._pendingFrom.getTime() !== this._appliedFrom.getTime() ||
152
+ this._pendingTo.getTime() !== this._appliedTo.getTime();
65
153
  return html `
66
154
  <div class="control-bar">
67
- <!-- 1행: 시점 설정 + 배속 -->
155
+ <!-- 1행 (config): Range 선택 + ←/→ shift + datetime 입력 + Apply -->
68
156
  <div class="row-time">
69
- <md-icon-button @click=${this._shiftTime(-1)} title="-1h">
70
- <md-icon>chevron_left</md-icon>
71
- </md-icon-button>
72
-
73
- <input
74
- type="date"
75
- class="date-input"
76
- .value=${this._toDateString(this._startTime)}
77
- @change=${this._onDateChange}
78
- />
79
-
80
- <select class="hour-select" .value=${String(this._startTime.getHours())} @change=${this._onHourChange}>
81
- ${Array.from({ length: 24 }, (_, i) => html `
82
- <option value=${i} ?selected=${this._startTime.getHours() === i}>
83
- ${String(i).padStart(2, '0')}:00
84
- </option>
85
- `)}
157
+ <select class="range-select" .value=${this._pendingRange} @change=${this._onRangeChange}>
158
+ ${['10m', '30m', '1H', '2H', 'Range'].map(k => html `<option value=${k} ?selected=${this._pendingRange === k}>${k}</option>`)}
86
159
  </select>
87
160
 
88
- <md-icon-button @click=${this._shiftTime(1)} title="+1h">
161
+ <button
162
+ class="shift-btn"
163
+ @click=${this._shiftPending(-1)}
164
+ ?disabled=${isRangeMode}
165
+ title="이전 ${this._pendingRange}"
166
+ >
167
+ <md-icon>chevron_left</md-icon>
168
+ </button>
169
+ <button
170
+ class="shift-btn"
171
+ @click=${this._shiftPending(1)}
172
+ ?disabled=${isRangeMode}
173
+ title="다음 ${this._pendingRange}"
174
+ >
89
175
  <md-icon>chevron_right</md-icon>
90
- </md-icon-button>
176
+ </button>
91
177
 
92
- <span class="time-label">~ ${String(this._endTime.getHours()).padStart(2, '0')}:00</span>
178
+ <!-- FROM composite datetime -->
179
+ <div class="composite-datetime ${!isRangeMode ? 'disabled' : ''}">
180
+ <input
181
+ type="date"
182
+ class="inner-date"
183
+ .value=${this._toDateString(this._pendingFrom)}
184
+ ?disabled=${!isRangeMode}
185
+ @change=${(e) => this._onCompositeChange('from', 'date', e)}
186
+ />
187
+ &nbsp;
188
+ <input
189
+ type="text"
190
+ class="inner-time"
191
+ maxlength="2"
192
+ .value=${this._pad(this._pendingFrom.getHours())}
193
+ ?disabled=${!isRangeMode}
194
+ @keydown=${(e) => this._onTimeKeydown('from', 'h', e)}
195
+ @input=${(e) => this._onTimeInput('h', e)}
196
+ @change=${(e) => this._onCompositeChange('from', 'h', e)}
197
+ />:
198
+ <input
199
+ type="text"
200
+ class="inner-time"
201
+ maxlength="2"
202
+ .value=${this._pad(this._pendingFrom.getMinutes())}
203
+ ?disabled=${!isRangeMode}
204
+ @keydown=${(e) => this._onTimeKeydown('from', 'm', e)}
205
+ @input=${(e) => this._onTimeInput('m', e)}
206
+ @change=${(e) => this._onCompositeChange('from', 'm', e)}
207
+ />:
208
+ <input
209
+ type="text"
210
+ class="inner-time"
211
+ maxlength="2"
212
+ .value=${this._pad(this._pendingFrom.getSeconds())}
213
+ ?disabled=${!isRangeMode}
214
+ @keydown=${(e) => this._onTimeKeydown('from', 's', e)}
215
+ @input=${(e) => this._onTimeInput('s', e)}
216
+ @change=${(e) => this._onCompositeChange('from', 's', e)}
217
+ />
218
+ </div>
93
219
 
94
- <span class="spacer"></span>
220
+ <span class="time-label">~</span>
95
221
 
96
- <div class="speed-group">
97
- ${this._speeds.map(s => html `
98
- <button class="speed-btn ${this.speed === s ? 'active' : ''}" @click=${() => this._onSpeedChange(s)}>
99
- ×${s}
100
- </button>
101
- `)}
222
+ <!-- TO composite datetime -->
223
+ <div class="composite-datetime ${!isRangeMode ? 'disabled' : ''}">
224
+ <input
225
+ type="date"
226
+ class="inner-date"
227
+ .value=${this._toDateString(this._pendingTo)}
228
+ ?disabled=${!isRangeMode}
229
+ @change=${(e) => this._onCompositeChange('to', 'date', e)}
230
+ />
231
+ &nbsp;
232
+ <input
233
+ type="text"
234
+ class="inner-time"
235
+ maxlength="2"
236
+ .value=${this._pad(this._pendingTo.getHours())}
237
+ ?disabled=${!isRangeMode}
238
+ @keydown=${(e) => this._onTimeKeydown('to', 'h', e)}
239
+ @input=${(e) => this._onTimeInput('h', e)}
240
+ @change=${(e) => this._onCompositeChange('to', 'h', e)}
241
+ />:
242
+ <input
243
+ type="text"
244
+ class="inner-time"
245
+ maxlength="2"
246
+ .value=${this._pad(this._pendingTo.getMinutes())}
247
+ ?disabled=${!isRangeMode}
248
+ @keydown=${(e) => this._onTimeKeydown('to', 'm', e)}
249
+ @input=${(e) => this._onTimeInput('m', e)}
250
+ @change=${(e) => this._onCompositeChange('to', 'm', e)}
251
+ />:
252
+ <input
253
+ type="text"
254
+ class="inner-time"
255
+ maxlength="2"
256
+ .value=${this._pad(this._pendingTo.getSeconds())}
257
+ ?disabled=${!isRangeMode}
258
+ @keydown=${(e) => this._onTimeKeydown('to', 's', e)}
259
+ @input=${(e) => this._onTimeInput('s', e)}
260
+ @change=${(e) => this._onCompositeChange('to', 's', e)}
261
+ />
102
262
  </div>
263
+
264
+ <button class="apply-btn ${isDirty ? 'dirty' : ''}" ?disabled=${!isDirty} @click=${this._onApply}>
265
+ 적용
266
+ </button>
267
+
268
+ <span class="spacer"></span>
103
269
  </div>
104
270
 
105
- <!-- 2행: 재생 제어 + 시크바 + 닫기 -->
271
+ <!-- 2행: 재생 제어 + 시크바 + 배속 + 닫기 -->
106
272
  <div class="row-seek">
107
273
  <md-icon-button class="primary" @click=${isActive ? this._onTogglePlayPause : this._onStart}>
108
274
  <md-icon>${isPlaying ? 'pause' : 'play_arrow'}</md-icon>
109
275
  </md-icon-button>
110
276
 
111
277
  <div class="timeline">
112
- <input
113
- type="range"
114
- class="seek-bar"
115
- min="0"
116
- max="1000"
117
- .value=${String(this._seekValue)}
118
- @input=${this._onSeekInput}
119
- @change=${this._onSeekChange}
120
- />
278
+ <div class="seek-track">
279
+ <div class="seek-rail"></div>
280
+ ${this._renderBufferBars()}
281
+ <input
282
+ type="range"
283
+ class="seek-bar"
284
+ min="0"
285
+ max="1000"
286
+ .value=${String(this._seekValue)}
287
+ @input=${this._onSeekInput}
288
+ @change=${this._onSeekChange}
289
+ />
290
+ </div>
121
291
  <div class="seek-labels">
122
- <span>${this._formatTime(this._startTime)}</span>
123
- <span>${this._formatTime(this._endTime)}</span>
292
+ <span>${this._formatTime(this._appliedFrom)}</span>
293
+ <span>${this._formatTime(this._appliedTo)}</span>
124
294
  </div>
125
295
  </div>
126
296
 
127
297
  <span class="current-time">
128
- ${this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}
298
+ ${this._seeking && this._seekPreviewTime
299
+ ? this._formatTime(this._seekPreviewTime)
300
+ : this._currentPlayTime ? this._formatTime(this._currentPlayTime) : '--:--:--'}
129
301
  </span>
130
302
 
303
+ <div class="speed-group">
304
+ ${this._speeds.map(s => html `
305
+ <button class="speed-btn ${this.speed === s ? 'active' : ''}" @click=${() => this._onSpeedChange(s)}>
306
+ ×${s}
307
+ </button>
308
+ `)}
309
+ </div>
310
+
131
311
  <md-icon-button @click=${this._onStop}>
132
312
  <md-icon>close</md-icon>
133
313
  </md-icon-button>
@@ -142,64 +322,230 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
142
322
  const pad = (n) => String(n).padStart(2, '0');
143
323
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
144
324
  }
145
- _updateSeekPosition() {
146
- if (!this.currentTime)
325
+ /** "HH:MM:SS" — what `<input type="time" step="1">` reads/writes. */
326
+ _toTimeString(date) {
327
+ const pad = (n) => String(n).padStart(2, '0');
328
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
329
+ }
330
+ /**
331
+ * "YYYY-MM-DDTHH:MM:SS" — what `<input type="datetime-local" step="1">`
332
+ * reads/writes. Local time (no Z suffix), matches the input element's
333
+ * own convention.
334
+ */
335
+ _toDatetimeLocalString(date) {
336
+ return `${this._toDateString(date)}T${this._toTimeString(date)}`;
337
+ }
338
+ /**
339
+ * 엔진 프레임(currentTime 프로프) 을 유일한 시간 소스로 사용하여
340
+ * _currentPlayTime 및 _seekValue 동기화.
341
+ */
342
+ _syncFromEngine() {
343
+ if (!this.currentTime || this._seeking)
344
+ return;
345
+ const currentMs = new Date(this.currentTime).getTime();
346
+ if (!Number.isFinite(currentMs))
147
347
  return;
148
- const current = new Date(this.currentTime).getTime();
149
- const from = this._startTime.getTime();
150
- const to = this._endTime.getTime();
348
+ this._currentPlayTime = new Date(currentMs);
349
+ const from = this._appliedFrom.getTime();
350
+ const to = this._appliedTo.getTime();
151
351
  const range = to - from;
152
352
  if (range <= 0)
153
353
  return;
154
- this._seekValue = Math.max(0, Math.min(1000, Math.round(((current - from) / range) * 1000)));
354
+ this._seekValue = Math.max(0, Math.min(1000, Math.round(((currentMs - from) / range) * 1000)));
155
355
  }
156
- _onDateChange(e) {
157
- const value = e.target.value;
158
- if (!value)
356
+ // ── Pending-tier handlers (upper config bar) ──────────────────────
357
+ _onRangeChange(e) {
358
+ const newKey = e.target.value;
359
+ this._pendingRange = newKey;
360
+ if (newKey === 'Range') {
361
+ // Switching to Range: keep current pending bounds, just unlock the
362
+ // inputs. Auto-adjust will kick in when the user edits one side.
159
363
  return;
160
- const [year, month, day] = value.split('-').map(Number);
161
- const newStart = new Date(this._startTime);
162
- newStart.setFullYear(year, month - 1, day);
163
- this._shiftStartTime(newStart);
164
- }
165
- _onHourChange(e) {
166
- const hour = Number(e.target.value);
167
- const newStart = new Date(this._startTime);
168
- newStart.setHours(hour, 0, 0, 0);
169
- this._shiftStartTime(newStart);
170
- }
171
- _shiftTime(hours) {
364
+ }
365
+ // Fixed preset: end stays where it is (clamped to now), start = end − range.
366
+ const dur = RANGE_DURATIONS[newKey];
367
+ const now = Date.now();
368
+ const newTo = Math.min(this._pendingTo.getTime(), now);
369
+ this._pendingTo = new Date(newTo);
370
+ this._pendingFrom = new Date(newTo - dur);
371
+ this._lastEdited = 'to';
372
+ }
373
+ _shiftPending(direction) {
172
374
  return () => {
173
- const newStart = new Date(this._startTime.getTime() + hours * ONE_HOUR);
174
- this._shiftStartTime(newStart);
375
+ // Only meaningful in Fixed mode — Range mode disables the buttons.
376
+ if (this._pendingRange === 'Range')
377
+ return;
378
+ const dur = RANGE_DURATIONS[this._pendingRange];
379
+ const now = Date.now();
380
+ let newToMs = this._pendingTo.getTime() + direction * dur;
381
+ newToMs = Math.min(newToMs, now); // future clamp on forward shift
382
+ this._pendingTo = new Date(newToMs);
383
+ this._pendingFrom = new Date(newToMs - dur);
384
+ this._lastEdited = 'to';
175
385
  };
176
386
  }
387
+ _pad(n) {
388
+ return String(n).padStart(2, '0');
389
+ }
390
+ _onTimeKeydown(side, part, e) {
391
+ const el = e.target;
392
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
393
+ e.preventDefault();
394
+ const current = side === 'from' ? this._pendingFrom : this._pendingTo;
395
+ const next = new Date(current);
396
+ let num = parseInt(el.value, 10);
397
+ if (isNaN(num))
398
+ num = 0;
399
+ const step = e.key === 'ArrowUp' ? 1 : -1;
400
+ num += step;
401
+ const max = part === 'h' ? 23 : 59;
402
+ if (num < 0)
403
+ num = max;
404
+ if (num > max)
405
+ num = 0;
406
+ if (part === 'h')
407
+ next.setHours(num);
408
+ if (part === 'm')
409
+ next.setMinutes(num);
410
+ if (part === 's')
411
+ next.setSeconds(num);
412
+ this._writePendingSide(side, next);
413
+ return;
414
+ }
415
+ if (!/^[0-9]$/.test(e.key) &&
416
+ !['Backspace', 'Tab', 'ArrowLeft', 'ArrowRight', 'Delete', 'Enter'].includes(e.key) &&
417
+ !e.ctrlKey && !e.metaKey) {
418
+ e.preventDefault();
419
+ }
420
+ }
421
+ _onTimeInput(part, e) {
422
+ const el = e.target;
423
+ let val = el.value.replace(/[^0-9]/g, '');
424
+ if (val.length > 0) {
425
+ let num = parseInt(val, 10);
426
+ const max = part === 'h' ? 23 : 59;
427
+ if (num > max) {
428
+ val = String(max);
429
+ }
430
+ }
431
+ el.value = val;
432
+ }
433
+ _onCompositeChange(side, part, e) {
434
+ const el = e.target;
435
+ const current = side === 'from' ? this._pendingFrom : this._pendingTo;
436
+ const next = new Date(current);
437
+ if (part === 'date') {
438
+ const value = el.value;
439
+ if (!value)
440
+ return;
441
+ const [y, m, d] = value.split('-').map(Number);
442
+ next.setFullYear(y, m - 1, d);
443
+ }
444
+ else {
445
+ let num = parseInt(el.value, 10);
446
+ if (isNaN(num))
447
+ num = 0;
448
+ if (part === 'h')
449
+ next.setHours(Math.max(0, Math.min(23, num)));
450
+ if (part === 'm')
451
+ next.setMinutes(Math.max(0, Math.min(59, num)));
452
+ if (part === 's')
453
+ next.setSeconds(Math.max(0, Math.min(59, num)));
454
+ // 패딩 강제 적용하여 UI 보정
455
+ el.value = this._pad(num);
456
+ }
457
+ if (Number.isNaN(next.getTime()))
458
+ return;
459
+ this._writePendingSide(side, next);
460
+ }
177
461
  /**
178
- * 시작 시간 변경 시 현재 재생 시간도 같은 offset만큼 이동.
179
- * 재생 중이면 타이머 유지.
462
+ * Commit a side's new value, then enforce the gap envelope.
463
+ *
464
+ * Rules (per spec):
465
+ * - `to` clamped to now (no future).
466
+ * - `to − from ≥ 10m` (no zero/negative window).
467
+ * - `to − from ≤ 2h`.
468
+ * - The side just edited (`side`) is the anchor; the OTHER side
469
+ * gets pushed to satisfy the envelope.
470
+ * - If anchoring `from` would push `to` past now, fall back to
471
+ * anchoring `to = now` and shrink `from`.
180
472
  */
181
- _shiftStartTime(newStart) {
182
- const delta = newStart.getTime() - this._startTime.getTime();
183
- this._startTime = newStart;
184
- if (this._currentPlayTime) {
185
- const shifted = new Date(this._currentPlayTime.getTime() + delta);
186
- const end = this._endTime;
187
- // 새 범위 안에 있으면 이동, 밖이면 시작점으로
188
- if (shifted.getTime() >= newStart.getTime() && shifted.getTime() <= end.getTime()) {
189
- this._currentPlayTime = shifted;
473
+ _writePendingSide(side, next) {
474
+ this._lastEdited = side;
475
+ const now = Date.now();
476
+ if (side === 'to') {
477
+ const toMs = Math.min(next.getTime(), now);
478
+ this._pendingTo = new Date(toMs);
479
+ const fromMs = this._pendingFrom.getTime();
480
+ const gap = toMs - fromMs;
481
+ if (gap < MIN_GAP_MS)
482
+ this._pendingFrom = new Date(toMs - MIN_GAP_MS);
483
+ else if (gap > MAX_GAP_MS)
484
+ this._pendingFrom = new Date(toMs - MAX_GAP_MS);
485
+ }
486
+ else {
487
+ const fromMs = next.getTime();
488
+ this._pendingFrom = new Date(fromMs);
489
+ const toMs = this._pendingTo.getTime();
490
+ const gap = toMs - fromMs;
491
+ if (gap < MIN_GAP_MS) {
492
+ let adj = fromMs + MIN_GAP_MS;
493
+ if (adj > now) {
494
+ // Pushing `to` would land in the future — re-anchor on `to=now`
495
+ // and shrink `from` instead.
496
+ adj = now;
497
+ this._pendingFrom = new Date(adj - MIN_GAP_MS);
498
+ }
499
+ this._pendingTo = new Date(adj);
190
500
  }
191
- else {
192
- this._currentPlayTime = new Date(newStart);
501
+ else if (gap > MAX_GAP_MS) {
502
+ let adj = fromMs + MAX_GAP_MS;
503
+ if (adj > now) {
504
+ adj = now;
505
+ this._pendingFrom = new Date(adj - MAX_GAP_MS);
506
+ }
507
+ this._pendingTo = new Date(adj);
193
508
  }
194
- this._updateSeekFromPlayTime();
509
+ }
510
+ }
511
+ /**
512
+ * "적용" — copy pending → applied, reset seek to window start, and
513
+ * (re)start the engine so it refetches data for the new window.
514
+ *
515
+ * Behavior by prior state:
516
+ * - playing: dispatch `playback-start` → engine restarts in new window.
517
+ * - paused : same — engine refetches; we leave the user in 'playing'
518
+ * state because the alternative (carry "paused" through a window
519
+ * change) ends up showing stale buffered data while the new fetch
520
+ * completes, which reads as a stuck UI.
521
+ * - idle : window updated silently; no engine activity until user
522
+ * presses play.
523
+ */
524
+ _onApply() {
525
+ this._appliedFrom = new Date(this._pendingFrom);
526
+ this._appliedTo = new Date(this._pendingTo);
527
+ this._seekValue = 0;
528
+ this._currentPlayTime = new Date(this._appliedFrom);
529
+ this._seekPreviewTime = null;
530
+ if (this.playbackState === 'playing' || this.playbackState === 'paused') {
531
+ this._onStart();
195
532
  }
196
533
  }
197
534
  _onStart() {
198
- this._currentPlayTime = new Date(this._startTime);
535
+ // 현재 seekbar 위치에서 재생 시작 (_seekValue 가 유일한 슬라이더 위치 소스)
536
+ const ratio = this._seekValue / 1000;
537
+ const from = this._appliedFrom.getTime();
538
+ const to = this._appliedTo.getTime();
539
+ const fromTime = new Date(from + (to - from) * ratio);
540
+ const toTime = new Date(to);
541
+ this._currentPlayTime = fromTime;
199
542
  this.playbackState = 'playing';
200
- this._startTimer();
543
+ // toTime is the window END (= _appliedTo), NOT a hardcoded
544
+ // fromTime + 1h. The engine uses it as the chunked-fetch upper
545
+ // bound — without it, variable ranges (10m / 30m / Range mode)
546
+ // would silently overshoot the user-selected window.
201
547
  this.dispatchEvent(new CustomEvent('playback-start', {
202
- detail: { fromTime: this._startTime, speed: this.speed },
548
+ detail: { fromTime, toTime, speed: this.speed },
203
549
  bubbles: true,
204
550
  composed: true
205
551
  }));
@@ -207,46 +553,113 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
207
553
  _onTogglePlayPause() {
208
554
  if (this.playbackState === 'playing') {
209
555
  this.playbackState = 'paused';
210
- this._stopTimer();
211
556
  this.dispatchEvent(new CustomEvent('playback-pause', { bubbles: true, composed: true }));
212
557
  }
213
558
  else {
214
559
  this.playbackState = 'playing';
215
- this._startTimer();
216
560
  this.dispatchEvent(new CustomEvent('playback-resume', { bubbles: true, composed: true }));
217
561
  }
218
562
  }
219
563
  _onStop() {
220
- this._stopTimer();
564
+ this._cancelPreviewThrottle();
221
565
  this._currentPlayTime = null;
222
566
  this._seekValue = 0;
223
567
  this.playbackState = 'idle';
224
568
  this.dispatchEvent(new CustomEvent('playback-stop', { bubbles: true, composed: true }));
225
569
  }
226
570
  _onSeekInput(e) {
571
+ // 드래그 시작(첫 input) 탐지
572
+ const justStartedDrag = !this._seeking;
573
+ this._seeking = true;
227
574
  this._seekValue = Number(e.target.value);
228
- // 드래그 중 현재 시간 업데이트 (비주얼 피드백)
575
+ // 드래그 중 preview 시간 계산 + UI 표시
229
576
  const ratio = this._seekValue / 1000;
230
- const from = this._startTime.getTime();
231
- const to = this._endTime.getTime();
232
- this._currentPlayTime = new Date(from + (to - from) * ratio);
577
+ const from = this._appliedFrom.getTime();
578
+ const to = this._appliedTo.getTime();
579
+ const previewTime = new Date(from + (to - from) * ratio);
580
+ this._seekPreviewTime = previewTime;
581
+ this._latestPreviewTime = previewTime;
582
+ if (justStartedDrag) {
583
+ // 즉시 이벤트 — 재생 타이머 정지
584
+ this.dispatchEvent(new CustomEvent('playback-seek-start', {
585
+ bubbles: true,
586
+ composed: true
587
+ }));
588
+ }
589
+ // throttle: leading-edge 에서 즉시 emit, 이후 200ms 내 input 은 latest 만 갱신.
590
+ // throttle 만료 시점에 latest 가 마지막 emit 과 다르면 trailing emit.
591
+ if (this._previewThrottle === undefined) {
592
+ // leading emit
593
+ this._emitPreview();
594
+ // trailing 예약
595
+ this._previewThrottle = window.setTimeout(() => {
596
+ var _a;
597
+ this._previewThrottle = undefined;
598
+ // 드래그가 끝났거나 (_seeking=false) 새 throttle 이 이미 있으면 스킵
599
+ if (!this._seeking)
600
+ return;
601
+ // latest 가 마지막 emit 과 같으면 중복 skip
602
+ const latest = this._latestPreviewTime;
603
+ if (latest && latest.getTime() !== ((_a = this._lastEmittedPreviewTime) === null || _a === void 0 ? void 0 : _a.getTime())) {
604
+ this._emitPreview();
605
+ }
606
+ }, PlaybackControls_1.PREVIEW_THROTTLE_MS);
607
+ }
608
+ // else: 이미 throttle 진행 중 → _latestPreviewTime 만 업데이트됨 (위에서 처리됨)
609
+ }
610
+ /** latest 위치 시각으로 playback-seek-preview 를 발행 (dedupe). */
611
+ _emitPreview() {
612
+ var _a;
613
+ const time = this._latestPreviewTime;
614
+ if (!time)
615
+ return;
616
+ if (((_a = this._lastEmittedPreviewTime) === null || _a === void 0 ? void 0 : _a.getTime()) === time.getTime())
617
+ return;
618
+ this._lastEmittedPreviewTime = time;
619
+ this.dispatchEvent(new CustomEvent('playback-seek-preview', {
620
+ detail: { toTime: time },
621
+ bubbles: true,
622
+ composed: true
623
+ }));
233
624
  }
234
625
  _onSeekChange(e) {
626
+ this._seeking = false;
627
+ this._cancelPreviewThrottle();
235
628
  const ratio = Number(e.target.value) / 1000;
236
- const from = this._startTime.getTime();
237
- const to = this._endTime.getTime();
629
+ const from = this._appliedFrom.getTime();
630
+ const to = this._appliedTo.getTime();
238
631
  const targetTime = new Date(from + (to - from) * ratio);
632
+ this._seekPreviewTime = null;
239
633
  this._currentPlayTime = targetTime;
240
634
  this.dispatchEvent(new CustomEvent('playback-seek', {
241
635
  detail: { toTime: targetTime },
242
636
  bubbles: true,
243
637
  composed: true
244
638
  }));
245
- // 재생 중이었으면 새 시점에서 타이머 계속
246
- if (this.playbackState === 'playing') {
247
- this._stopTimer();
248
- this._startTimer();
639
+ }
640
+ _cancelPreviewThrottle() {
641
+ if (this._previewThrottle != null) {
642
+ clearTimeout(this._previewThrottle);
643
+ this._previewThrottle = undefined;
249
644
  }
645
+ this._latestPreviewTime = null;
646
+ this._lastEmittedPreviewTime = null;
647
+ }
648
+ _renderBufferBars() {
649
+ var _a;
650
+ if (!((_a = this.bufferedRanges) === null || _a === void 0 ? void 0 : _a.length))
651
+ return '';
652
+ const from = this._appliedFrom.getTime();
653
+ const to = this._appliedTo.getTime();
654
+ const range = to - from;
655
+ if (range <= 0)
656
+ return '';
657
+ return this.bufferedRanges.map(r => {
658
+ const left = Math.max(0, (r.from - from) / range) * 100;
659
+ const right = Math.min(1, (r.to - from) / range) * 100;
660
+ const width = right - left;
661
+ return html `<div class="buffer-bar" style="left:${left}%;width:${width}%"></div>`;
662
+ });
250
663
  }
251
664
  _onSpeedChange(speed) {
252
665
  this.speed = speed;
@@ -255,49 +668,6 @@ let PlaybackControls = PlaybackControls_1 = class PlaybackControls extends LitEl
255
668
  bubbles: true,
256
669
  composed: true
257
670
  }));
258
- // 재생 중이면 타이머 재시작 (새 속도 반영)
259
- if (this.playbackState === 'playing') {
260
- this._stopTimer();
261
- this._startTimer();
262
- }
263
- }
264
- // --- 타이머: 초 단위 진행 ---
265
- _startTimer() {
266
- this._stopTimer();
267
- const intervalMs = 1000 / this.speed;
268
- this._timer = window.setInterval(() => {
269
- if (!this._currentPlayTime)
270
- return;
271
- const next = new Date(this._currentPlayTime.getTime() + 1000);
272
- // 종료 시점 초과 시 정지
273
- if (next.getTime() > this._endTime.getTime()) {
274
- this._onStop();
275
- return;
276
- }
277
- this._currentPlayTime = next;
278
- this._updateSeekFromPlayTime();
279
- this.dispatchEvent(new CustomEvent('playback-tick', {
280
- detail: { currentTime: this._currentPlayTime },
281
- bubbles: true,
282
- composed: true
283
- }));
284
- }, intervalMs);
285
- }
286
- _stopTimer() {
287
- if (this._timer) {
288
- clearInterval(this._timer);
289
- this._timer = undefined;
290
- }
291
- }
292
- _updateSeekFromPlayTime() {
293
- if (!this._currentPlayTime)
294
- return;
295
- const from = this._startTime.getTime();
296
- const to = this._endTime.getTime();
297
- const range = to - from;
298
- if (range <= 0)
299
- return;
300
- this._seekValue = Math.max(0, Math.min(1000, Math.round(((this._currentPlayTime.getTime() - from) / range) * 1000)));
301
671
  }
302
672
  };
303
673
  PlaybackControls.styles = css `
@@ -349,10 +719,49 @@ PlaybackControls.styles = css `
349
719
  border-radius: 50%;
350
720
  }
351
721
 
352
- .row-time md-icon-button {
353
- --md-icon-button-icon-size: 18px;
354
- --md-icon-button-container-width: 32px;
355
- --md-icon-button-container-height: 32px;
722
+ /*
723
+ * shift 버튼 (←/→) — Material Web 의 md-icon-button 은 shadow DOM
724
+ * 내 ripple/focus-ring/touch-target 영역이 visible 32px 컨테이너
725
+ * 너머로 hit area 를 확장해서 인접 버튼끼리 호버 영역이 겹친다.
726
+ * (사용자 보고: > 의 hover 가 < 의 오른쪽 영역까지 침범) plain
727
+ * <button> 으로 교체해 hit area 를 정확히 32×32px 로 한정.
728
+ */
729
+ .shift-btn {
730
+ width: 22px;
731
+ height: 28px;
732
+ padding: 0;
733
+ border: none;
734
+ background: transparent;
735
+ cursor: pointer;
736
+ display: inline-flex;
737
+ align-items: center;
738
+ justify-content: center;
739
+ color: #fff;
740
+ flex-shrink: 0;
741
+ }
742
+
743
+ /*
744
+ * shift 버튼 묶음 — 두 chevron 이 시각적으로 한 쌍으로 보이도록
745
+ * 사이 여백을 완전히 제거 (row-time 의 6px gap 전체를 negative
746
+ * margin 으로 상쇄). 양쪽 인접 요소와의 간격은 row gap 으로 유지.
747
+ */
748
+ .shift-btn + .shift-btn {
749
+ margin-left: -6px;
750
+ }
751
+
752
+ .shift-btn md-icon {
753
+ font-size: 16px;
754
+ width: 16px;
755
+ height: 16px;
756
+ }
757
+
758
+ .shift-btn:hover:not(:disabled) {
759
+ color: #ffb766;
760
+ }
761
+
762
+ .shift-btn:disabled {
763
+ opacity: 0.4;
764
+ cursor: not-allowed;
356
765
  }
357
766
 
358
767
  .date-input {
@@ -371,7 +780,8 @@ PlaybackControls.styles = css `
371
780
  border-color: rgba(255, 255, 255, 0.6);
372
781
  }
373
782
 
374
- .hour-select {
783
+ .hour-select,
784
+ .range-select {
375
785
  background: rgba(255, 255, 255, 0.15);
376
786
  border: 1px solid rgba(255, 255, 255, 0.3);
377
787
  border-radius: 6px;
@@ -383,10 +793,113 @@ PlaybackControls.styles = css `
383
793
  cursor: pointer;
384
794
  }
385
795
 
386
- .hour-select:focus {
796
+ /*
797
+ * Windows + Chrome: select 의 color/background 가 native popup option 에
798
+ * 일관되게 적용 안 됨 (option 텍스트는 white 상속, popup 배경은 OS 기본 흰색
799
+ * → invisible). option 요소에 명시적으로 dark 컬러 박아 모든 OS 에서 가독.
800
+ */
801
+ .hour-select option,
802
+ .range-select option {
803
+ background-color: #1a1d22;
804
+ color: #fff;
805
+ }
806
+
807
+ .hour-select:focus,
808
+ .range-select:focus {
809
+ border-color: rgba(255, 255, 255, 0.6);
810
+ }
811
+
812
+ .range-select {
813
+ min-width: 64px;
814
+ }
815
+
816
+ .composite-datetime {
817
+ display: inline-flex;
818
+ align-items: center;
819
+ background: rgba(255, 255, 255, 0.15);
820
+ border: 1px solid rgba(255, 255, 255, 0.3);
821
+ border-radius: 6px;
822
+ color: #fff;
823
+ font-size: 12px;
824
+ padding: 4px 6px;
825
+ box-sizing: border-box;
826
+ transition: border-color 0.2s, opacity 0.2s;
827
+ }
828
+
829
+ .composite-datetime:focus-within {
387
830
  border-color: rgba(255, 255, 255, 0.6);
388
831
  }
389
832
 
833
+ .composite-datetime.disabled {
834
+ opacity: 0.5;
835
+ pointer-events: none;
836
+ }
837
+
838
+ .inner-date {
839
+ background: transparent;
840
+ border: none;
841
+ color: inherit;
842
+ font-family: inherit;
843
+ font-size: inherit;
844
+ outline: none;
845
+ color-scheme: dark;
846
+ padding: 0;
847
+ cursor: text;
848
+ }
849
+
850
+ .inner-date::-webkit-calendar-picker-indicator,
851
+ .inner-date::-webkit-inner-spin-button,
852
+ .inner-date::-webkit-clear-button {
853
+ display: none;
854
+ -webkit-appearance: none;
855
+ }
856
+
857
+ .inner-time {
858
+ background: transparent;
859
+ border: none;
860
+ color: inherit;
861
+ font-family: inherit;
862
+ font-size: inherit;
863
+ outline: none;
864
+ width: 16px;
865
+ text-align: center;
866
+ padding: 0;
867
+ }
868
+
869
+ .inner-time::placeholder {
870
+ color: rgba(255, 255, 255, 0.3);
871
+ }
872
+
873
+ .date-input:disabled {
874
+ opacity: 0.5;
875
+ cursor: not-allowed;
876
+ }
877
+
878
+ .apply-btn {
879
+ padding: 4px 12px;
880
+ border: 1px solid rgba(255, 255, 255, 0.3);
881
+ border-radius: 4px;
882
+ background: transparent;
883
+ color: #fff;
884
+ font-size: 12px;
885
+ cursor: pointer;
886
+ white-space: nowrap;
887
+ }
888
+
889
+ .apply-btn.dirty {
890
+ background: rgba(255, 165, 0, 0.4);
891
+ border-color: rgba(255, 165, 0, 0.8);
892
+ }
893
+
894
+ .apply-btn:hover:not(:disabled) {
895
+ background: rgba(255, 255, 255, 0.2);
896
+ }
897
+
898
+ .apply-btn:disabled {
899
+ opacity: 0.4;
900
+ cursor: not-allowed;
901
+ }
902
+
390
903
  .time-label {
391
904
  font-size: 12px;
392
905
  opacity: 0.7;
@@ -441,6 +954,10 @@ PlaybackControls.styles = css `
441
954
  --md-icon-button-container-height: 40px;
442
955
  }
443
956
 
957
+ .row-seek md-icon {
958
+ font-variation-settings: 'FILL' 1;
959
+ }
960
+
444
961
  .timeline {
445
962
  flex: 1;
446
963
  display: flex;
@@ -449,15 +966,50 @@ PlaybackControls.styles = css `
449
966
  min-width: 0;
450
967
  }
451
968
 
452
- .seek-bar {
969
+ .seek-track {
970
+ position: relative;
453
971
  width: 100%;
972
+ height: 12px;
973
+ }
974
+
975
+ .seek-rail {
976
+ position: absolute;
977
+ top: 4px;
978
+ left: 0;
979
+ right: 0;
980
+ height: 4px;
981
+ background: rgba(255, 255, 255, 0.15);
982
+ border-radius: 2px;
983
+ }
984
+
985
+ .buffer-bar {
986
+ position: absolute;
987
+ top: 4px;
454
988
  height: 4px;
989
+ background: rgba(255, 255, 255, 0.4);
990
+ border-radius: 2px;
991
+ pointer-events: none;
992
+ z-index: 1;
993
+ }
994
+
995
+ .seek-bar {
996
+ position: absolute;
997
+ top: 0;
998
+ left: 0;
999
+ width: 100%;
1000
+ height: 12px;
455
1001
  -webkit-appearance: none;
456
1002
  appearance: none;
457
- background: rgba(255, 255, 255, 0.3);
458
- border-radius: 2px;
1003
+ background: transparent;
459
1004
  outline: none;
460
1005
  cursor: pointer;
1006
+ z-index: 2;
1007
+ margin: 0;
1008
+ }
1009
+
1010
+ .seek-bar::-webkit-slider-runnable-track {
1011
+ height: 12px;
1012
+ background: transparent;
461
1013
  }
462
1014
 
463
1015
  .seek-bar::-webkit-slider-thumb {
@@ -484,6 +1036,7 @@ PlaybackControls.styles = css `
484
1036
  text-align: center;
485
1037
  }
486
1038
  `;
1039
+ PlaybackControls.PREVIEW_THROTTLE_MS = 200;
487
1040
  __decorate([
488
1041
  property({ type: String })
489
1042
  ], PlaybackControls.prototype, "playbackState", void 0);
@@ -493,18 +1046,42 @@ __decorate([
493
1046
  __decorate([
494
1047
  property({ type: String })
495
1048
  ], PlaybackControls.prototype, "currentTime", void 0);
1049
+ __decorate([
1050
+ property({ type: Array })
1051
+ ], PlaybackControls.prototype, "bufferedRanges", void 0);
496
1052
  __decorate([
497
1053
  property({ type: Object })
498
1054
  ], PlaybackControls.prototype, "timeRange", void 0);
499
1055
  __decorate([
500
1056
  state()
501
- ], PlaybackControls.prototype, "_startTime", void 0);
1057
+ ], PlaybackControls.prototype, "_pendingRange", void 0);
1058
+ __decorate([
1059
+ state()
1060
+ ], PlaybackControls.prototype, "_pendingFrom", void 0);
1061
+ __decorate([
1062
+ state()
1063
+ ], PlaybackControls.prototype, "_pendingTo", void 0);
1064
+ __decorate([
1065
+ state()
1066
+ ], PlaybackControls.prototype, "_lastEdited", void 0);
1067
+ __decorate([
1068
+ state()
1069
+ ], PlaybackControls.prototype, "_appliedFrom", void 0);
1070
+ __decorate([
1071
+ state()
1072
+ ], PlaybackControls.prototype, "_appliedTo", void 0);
502
1073
  __decorate([
503
1074
  state()
504
1075
  ], PlaybackControls.prototype, "_seekValue", void 0);
505
1076
  __decorate([
506
1077
  state()
507
1078
  ], PlaybackControls.prototype, "_currentPlayTime", void 0);
1079
+ __decorate([
1080
+ state()
1081
+ ], PlaybackControls.prototype, "_seeking", void 0);
1082
+ __decorate([
1083
+ state()
1084
+ ], PlaybackControls.prototype, "_seekPreviewTime", void 0);
508
1085
  PlaybackControls = PlaybackControls_1 = __decorate([
509
1086
  customElement('ox-playback-controls')
510
1087
  ], PlaybackControls);