Skip to content

Commit 975fe3c

Browse files
committed
feat(recorder): add ttyrec controls
1 parent 0fba113 commit 975fe3c

File tree

3 files changed

+179
-41
lines changed

3 files changed

+179
-41
lines changed
Lines changed: 165 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
<script lang="ts" setup>
22
import type { TerminalTab } from '@commas/types/terminal'
3+
import { useTimestamp } from '@vueuse/core'
34
import * as commas from 'commas:api/renderer'
4-
import { watchEffect } from 'vue'
5+
import { onMounted, watchEffect } from 'vue'
56
import { useTTYRecFrames } from './compositions'
67
78
const { tab } = defineProps<{
89
tab: TerminalTab,
910
}>()
1011
11-
const { TerminalBlock } = commas.ui.vueAssets
12+
const { TerminalBlock, VisualIcon } = commas.ui.vueAssets
1213
1314
const file = $computed(() => tab.shell)
1415
@@ -24,69 +25,193 @@ const element = $ref<HTMLElement>()
2425
2526
const xterm = commas.workspace.useReadonlyTerminal(() => tab, $$(element))
2627
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-
4728
const frames = $(useTTYRecFrames($$(file)))
4829
4930
const isFinished = $computed(() => {
5031
return frames.length ? frames[frames.length - 1].offset === -1 : false
5132
})
5233
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
6659
}
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+
})
69106
}
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()
72138
})
73139
</script>
74140

75141
<template>
76142
<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>
77151
<div ref="element" class="terminal-content"></div>
78152
</TerminalBlock>
79153
</template>
80154

81155
<style lang="scss" scoped>
156+
@use 'sass:math';
157+
@use '@commas/api/scss/_partials';
158+
82159
.recorder-pane {
83160
:deep(.terminal-container) {
84161
display: flex;
162+
flex-direction: column;
85163
}
86164
}
87165
.terminal-content {
88166
flex: 1;
89-
min-width: 0;
167+
min-height: 0;
90168
overflow: hidden;
91169
}
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+
}
92217
</style>

addons/camera/src/renderer/compositions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function openRecorderTab(file: string) {
1212
}
1313

1414
export function useTTYRecFrames(file: MaybeRefOrGetter<string>) {
15-
let frames = $shallowRef<TTYRecFrame[]>([])
15+
let frames = $ref<TTYRecFrame[]>([])
1616
watchEffect(onInvalidate => {
1717
const source = toValue(file)
1818
ipcRenderer.invoke('ttyrec-read', source)

env.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ interface Element {
55
scrollIntoViewIfNeeded(centerIfNeeded?: boolean): void,
66
}
77

8+
declare namespace Intl {
9+
interface DurationFormat {
10+
format(value: any): string,
11+
}
12+
interface DurationFormatConstructor {
13+
new (locales?: string | string[], options?: any): DurationFormat,
14+
(locales?: string | string[], options?: any): DurationFormat,
15+
supportedLocalesOf(locales: string | string[], options?: any): string[],
16+
readonly prototype: DurationFormat,
17+
}
18+
const DurationFormat: DurationFormatConstructor
19+
}
20+
821
declare module '@achrinza/node-ipc' {
922
export { default } from 'node-ipc'
1023
}

0 commit comments

Comments
 (0)