1
1
<script lang="ts" setup>
2
2
import type { TerminalTab } from ' @commas/types/terminal'
3
+ import { useTimestamp } from ' @vueuse/core'
3
4
import * as commas from ' commas:api/renderer'
4
- import { watchEffect } from ' vue'
5
+ import { onMounted , watchEffect } from ' vue'
5
6
import { useTTYRecFrames } from ' ./compositions'
6
7
7
8
const { tab } = defineProps <{
8
9
tab: TerminalTab ,
9
10
}>()
10
11
11
- const { TerminalBlock } = commas .ui .vueAssets
12
+ const { TerminalBlock, VisualIcon } = commas .ui .vueAssets
12
13
13
14
const file = $computed (() => tab .shell )
14
15
@@ -24,69 +25,193 @@ const element = $ref<HTMLElement>()
24
25
25
26
const xterm = commas .workspace .useReadonlyTerminal (() => tab , $$ (element ))
26
27
27
- function loop<T >(fn : (time : number , token : T , next : (value : T ) => void ) => void , token : T ) {
28
- let id: ReturnType <typeof requestAnimationFrame >
29
- let timeOffset: number | undefined
30
- let next: (value : T ) => void
31
- const iteratee = (time : number ) => {
32
- if (typeof timeOffset !== ' number' ) {
33
- timeOffset = time
34
- }
35
- fn (time - timeOffset , token , next )
36
- }
37
- next = (value : T ) => {
38
- token = value
39
- requestAnimationFrame (iteratee )
40
- }
41
- id = requestAnimationFrame (iteratee )
42
- return () => {
43
- cancelAnimationFrame (id )
44
- }
45
- }
46
-
47
28
const frames = $ (useTTYRecFrames ($$ (file )))
48
29
49
30
const isFinished = $computed (() => {
50
31
return frames .length ? frames [frames .length - 1 ].offset === - 1 : false
51
32
})
52
33
53
- watchEffect (onInvalidate => {
54
- xterm .clear ()
55
- // Reference
56
- let list = frames
57
- const cancel = loop ((time , offset , next ) => {
58
- for (let index = offset ; index < list .length ; index += 1 ) {
59
- const item = list [index ]
60
- if (time >= item .offset ) {
61
- xterm .write (item .data )
62
- } else {
63
- next (index )
64
- return
65
- }
34
+ const actualFrames = $computed (() => {
35
+ return isFinished ? frames .slice (0 , - 1 ) : frames
36
+ })
37
+
38
+ let {
39
+ timestamp,
40
+ isActive,
41
+ pause,
42
+ resume,
43
+ } = $ (useTimestamp ({
44
+ controls: true ,
45
+ immediate: false ,
46
+ }))
47
+
48
+ let start = $ref <number >()
49
+
50
+ let currentTime = $computed ({
51
+ get : () => {
52
+ return typeof start === ' number' ? timestamp - start : 0
53
+ },
54
+ set : value => {
55
+ if (value < 0 ) {
56
+ start = undefined
57
+ } else {
58
+ start = timestamp - value
66
59
}
67
- if (! isFinished ) {
68
- next (list .length )
60
+ },
61
+ })
62
+
63
+ function play() {
64
+ const timeBefore = currentTime
65
+ timestamp = Date .now ()
66
+ currentTime = timeBefore
67
+ resume ()
68
+ }
69
+
70
+ const duration = $computed (() => {
71
+ if (! actualFrames .length ) return 0
72
+ return actualFrames [actualFrames .length - 1 ].offset
73
+ })
74
+
75
+ let lastFrameIndex = $ref (- 1 )
76
+
77
+ const isEnded = $computed (() => {
78
+ return lastFrameIndex === actualFrames .length - 1
79
+ })
80
+
81
+ watchEffect (() => {
82
+ const lastRenderingFrameIndex = actualFrames .findLastIndex (item => currentTime >= item .offset )
83
+ let firstRenderingFrameIndex: number
84
+ if (lastFrameIndex > lastRenderingFrameIndex ) {
85
+ xterm .clear ()
86
+ firstRenderingFrameIndex = 0
87
+ } else {
88
+ firstRenderingFrameIndex = lastFrameIndex + 1
89
+ }
90
+ for (let index = firstRenderingFrameIndex ; index < lastRenderingFrameIndex + 1 ; index += 1 ) {
91
+ const item = actualFrames [index ]
92
+ xterm .write (item .data )
93
+ }
94
+ lastFrameIndex = lastRenderingFrameIndex
95
+ })
96
+
97
+ watchEffect (onInvalidate => {
98
+ if (isEnded ) {
99
+ pause ()
100
+ if (isFinished ) {
101
+ currentTime = duration as number
102
+ } else {
103
+ onInvalidate (() => {
104
+ play ()
105
+ })
69
106
}
70
- }, 0 )
71
- onInvalidate (cancel )
107
+ }
108
+ })
109
+
110
+ function playOrPause() {
111
+ if (isActive ) {
112
+ pause ()
113
+ } else {
114
+ play ()
115
+ }
116
+ }
117
+
118
+ const durationFormat = new Intl .DurationFormat (undefined , {
119
+ style: ' digital' ,
120
+ hoursDisplay: ' auto' ,
121
+ })
122
+
123
+ function formatTime(time : number ) {
124
+ const seconds = Math .round (time / 1000 )
125
+ const minutes = Math .floor (seconds / 60 )
126
+ const hours = Math .floor (minutes / 60 )
127
+ return durationFormat .format ({
128
+ hours ,
129
+ minutes: minutes % 60 ,
130
+ seconds: seconds % 60 ,
131
+ })
132
+ }
133
+
134
+ onMounted (() => {
135
+ currentTime = 0
136
+ lastFrameIndex = - 1
137
+ play ()
72
138
})
73
139
</script >
74
140
75
141
<template >
76
142
<TerminalBlock :tab =" tab" class =" recorder-pane" @contextmenu =" openEditingMenu" >
143
+ <div class =" recorder-control" >
144
+ <button class =" action" @click =" playOrPause" >
145
+ <VisualIcon :name =" isActive ? 'lucide-pause' : 'lucide-play'" class =" play-icon" />
146
+ </button >
147
+ <span class =" time-indicator" >{{ formatTime(currentTime) }}</span >
148
+ <input v-model =" currentTime" type =" range" :max =" duration" class =" progress" >
149
+ <span class =" time-indicator" >{{ formatTime(duration) }}</span >
150
+ </div >
77
151
<div ref =" element" class =" terminal-content" ></div >
78
152
</TerminalBlock >
79
153
</template >
80
154
81
155
<style lang="scss" scoped>
156
+ @use ' sass:math' ;
157
+ @use ' @commas/api/scss/_partials' ;
158
+
82
159
.recorder-pane {
83
160
:deep (.terminal-container ) {
84
161
display : flex ;
162
+ flex-direction : column ;
85
163
}
86
164
}
87
165
.terminal-content {
88
166
flex : 1 ;
89
- min-width : 0 ;
167
+ min-height : 0 ;
90
168
overflow : hidden ;
91
169
}
170
+ .recorder-control {
171
+ display : flex ;
172
+ gap : 8px ;
173
+ align-items : center ;
174
+ padding : 8px 16px 0 8px ;
175
+ font-size : 12px ;
176
+ }
177
+ .action {
178
+ appearance : none ;
179
+ padding : 4px ;
180
+ border : none ;
181
+ font-size : 14px ;
182
+ background : transparent ;
183
+ border-radius : 4px ;
184
+ transition : opacity 0.2s , transform 0.2s ;
185
+ cursor : pointer ;
186
+ & :hover {
187
+ background : var (--design-highlight-background );
188
+ }
189
+ & :active {
190
+ transform : scale (partials .nano-scale (22 ));
191
+ }
192
+ }
193
+ .play-icon {
194
+ fill : currentColor !important ;
195
+ }
196
+ .progress {
197
+ appearance : none ;
198
+ flex : 1 ;
199
+ min-width : 0 ;
200
+ margin : 0 ;
201
+ background : transparent ;
202
+ & ::-webkit-slider-thumb {
203
+ appearance : none ;
204
+ width : 4px ;
205
+ height : 16px ;
206
+ margin-top : #{math .div (4px - 16px , 2 )} ;
207
+ color : rgb (var (--system-accent ));
208
+ background : currentColor ;
209
+ border-radius : 4px ;
210
+ }
211
+ & ::-webkit-slider-runnable-track {
212
+ height : 4px ;
213
+ background : var (--design-input-background );
214
+ border-radius : 4px ;
215
+ }
216
+ }
92
217
</style >
0 commit comments