asciinema_win 0.1.0
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.
- checksums.yaml +7 -0
- data/README.md +575 -0
- data/exe/asciinema_win +17 -0
- data/lib/asciinema_win/ansi_parser.rb +437 -0
- data/lib/asciinema_win/asciicast.rb +537 -0
- data/lib/asciinema_win/cli.rb +591 -0
- data/lib/asciinema_win/export.rb +780 -0
- data/lib/asciinema_win/output_organizer.rb +276 -0
- data/lib/asciinema_win/player.rb +348 -0
- data/lib/asciinema_win/recorder.rb +480 -0
- data/lib/asciinema_win/screen_buffer.rb +375 -0
- data/lib/asciinema_win/themes.rb +334 -0
- data/lib/asciinema_win/version.rb +6 -0
- data/lib/asciinema_win.rb +153 -0
- data/lib/rich/_palettes.rb +148 -0
- data/lib/rich/box.rb +342 -0
- data/lib/rich/cells.rb +512 -0
- data/lib/rich/color.rb +628 -0
- data/lib/rich/color_triplet.rb +220 -0
- data/lib/rich/console.rb +549 -0
- data/lib/rich/control.rb +332 -0
- data/lib/rich/json.rb +254 -0
- data/lib/rich/layout.rb +314 -0
- data/lib/rich/markdown.rb +509 -0
- data/lib/rich/markup.rb +175 -0
- data/lib/rich/panel.rb +311 -0
- data/lib/rich/progress.rb +430 -0
- data/lib/rich/segment.rb +387 -0
- data/lib/rich/style.rb +433 -0
- data/lib/rich/syntax.rb +1145 -0
- data/lib/rich/table.rb +525 -0
- data/lib/rich/terminal_theme.rb +126 -0
- data/lib/rich/text.rb +433 -0
- data/lib/rich/tree.rb +220 -0
- data/lib/rich/version.rb +5 -0
- data/lib/rich/win32_console.rb +859 -0
- data/lib/rich.rb +108 -0
- metadata +123 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module AsciinemaWin
|
|
6
|
+
# Output organization utilities
|
|
7
|
+
#
|
|
8
|
+
# Provides structured directory organization for recordings and exports
|
|
9
|
+
# with timestamp-based naming and format-specific subdirectories.
|
|
10
|
+
module OutputOrganizer
|
|
11
|
+
# Default base directory for outputs
|
|
12
|
+
DEFAULT_BASE_DIR = "asciinema_output"
|
|
13
|
+
|
|
14
|
+
# Format subdirectory mapping
|
|
15
|
+
FORMAT_DIRS = {
|
|
16
|
+
cast: "recordings",
|
|
17
|
+
html: "html",
|
|
18
|
+
svg: "svg",
|
|
19
|
+
txt: "text",
|
|
20
|
+
text: "text",
|
|
21
|
+
json: "json",
|
|
22
|
+
gif: "video",
|
|
23
|
+
mp4: "video",
|
|
24
|
+
webm: "video",
|
|
25
|
+
thumbnail: "thumbnails"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Get organized output path
|
|
30
|
+
#
|
|
31
|
+
# @param base_name [String] Base name for the file
|
|
32
|
+
# @param format [Symbol] Output format
|
|
33
|
+
# @param base_dir [String] Base output directory
|
|
34
|
+
# @param timestamp [Boolean] Include timestamp in filename
|
|
35
|
+
# @param session_id [String, nil] Session ID for grouping related files
|
|
36
|
+
# @return [String] Full output path
|
|
37
|
+
def output_path(base_name, format:, base_dir: DEFAULT_BASE_DIR, timestamp: true, session_id: nil)
|
|
38
|
+
# Ensure base directory exists
|
|
39
|
+
ensure_directory(base_dir)
|
|
40
|
+
|
|
41
|
+
# Get format-specific subdirectory
|
|
42
|
+
format_dir = FORMAT_DIRS[format.to_sym] || "other"
|
|
43
|
+
full_dir = File.join(base_dir, format_dir)
|
|
44
|
+
|
|
45
|
+
# Add session subdirectory if provided
|
|
46
|
+
if session_id
|
|
47
|
+
full_dir = File.join(full_dir, session_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ensure_directory(full_dir)
|
|
51
|
+
|
|
52
|
+
# Build filename
|
|
53
|
+
filename = if timestamp
|
|
54
|
+
"#{base_name}_#{timestamp_string}.#{format}"
|
|
55
|
+
else
|
|
56
|
+
"#{base_name}.#{format}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
File.join(full_dir, filename)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create a session directory for related outputs
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] Session name
|
|
65
|
+
# @param base_dir [String] Base output directory
|
|
66
|
+
# @return [Session] Session object for organizing related files
|
|
67
|
+
def create_session(name, base_dir: DEFAULT_BASE_DIR)
|
|
68
|
+
session_id = "#{sanitize_name(name)}_#{timestamp_string}"
|
|
69
|
+
Session.new(session_id, base_dir)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get organized path for a recording
|
|
73
|
+
#
|
|
74
|
+
# @param name [String] Recording name
|
|
75
|
+
# @param base_dir [String] Base output directory
|
|
76
|
+
# @param timestamp [Boolean] Include timestamp
|
|
77
|
+
# @return [String] Full path to recording file
|
|
78
|
+
def recording_path(name, base_dir: DEFAULT_BASE_DIR, timestamp: true)
|
|
79
|
+
output_path(sanitize_name(name), format: :cast, base_dir: base_dir, timestamp: timestamp)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# List all sessions in output directory
|
|
83
|
+
#
|
|
84
|
+
# @param base_dir [String] Base output directory
|
|
85
|
+
# @return [Array<String>] Session directory names
|
|
86
|
+
def list_sessions(base_dir: DEFAULT_BASE_DIR)
|
|
87
|
+
recordings_dir = File.join(base_dir, "recordings")
|
|
88
|
+
return [] unless Dir.exist?(recordings_dir)
|
|
89
|
+
|
|
90
|
+
Dir.children(recordings_dir)
|
|
91
|
+
.select { |f| File.directory?(File.join(recordings_dir, f)) }
|
|
92
|
+
.sort
|
|
93
|
+
.reverse
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get summary of output directory contents
|
|
97
|
+
#
|
|
98
|
+
# @param base_dir [String] Base output directory
|
|
99
|
+
# @return [Hash] Summary of files by category
|
|
100
|
+
def summary(base_dir: DEFAULT_BASE_DIR)
|
|
101
|
+
result = {}
|
|
102
|
+
|
|
103
|
+
FORMAT_DIRS.values.uniq.each do |subdir|
|
|
104
|
+
path = File.join(base_dir, subdir)
|
|
105
|
+
next unless Dir.exist?(path)
|
|
106
|
+
|
|
107
|
+
files = Dir.glob(File.join(path, "**", "*"))
|
|
108
|
+
.select { |f| File.file?(f) }
|
|
109
|
+
|
|
110
|
+
result[subdir] = {
|
|
111
|
+
count: files.length,
|
|
112
|
+
total_size: files.sum { |f| File.size(f) },
|
|
113
|
+
files: files.map { |f| File.basename(f) }
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Clean old files from output directory
|
|
121
|
+
#
|
|
122
|
+
# @param base_dir [String] Base output directory
|
|
123
|
+
# @param keep_days [Integer] Keep files newer than this many days
|
|
124
|
+
# @return [Integer] Number of files deleted
|
|
125
|
+
def cleanup(base_dir: DEFAULT_BASE_DIR, keep_days: 30)
|
|
126
|
+
cutoff = Time.now - (keep_days * 24 * 60 * 60)
|
|
127
|
+
deleted = 0
|
|
128
|
+
|
|
129
|
+
Dir.glob(File.join(base_dir, "**", "*")).each do |file|
|
|
130
|
+
next unless File.file?(file)
|
|
131
|
+
next if File.mtime(file) > cutoff
|
|
132
|
+
|
|
133
|
+
File.delete(file)
|
|
134
|
+
deleted += 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove empty directories
|
|
138
|
+
Dir.glob(File.join(base_dir, "**", "*"))
|
|
139
|
+
.select { |d| File.directory?(d) }
|
|
140
|
+
.sort_by { |d| -d.length } # Deepest first
|
|
141
|
+
.each do |dir|
|
|
142
|
+
Dir.rmdir(dir) if Dir.empty?(dir)
|
|
143
|
+
rescue Errno::ENOTEMPTY
|
|
144
|
+
# Skip non-empty directories
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
deleted
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Generate timestamp string for filenames
|
|
153
|
+
#
|
|
154
|
+
# @return [String] Timestamp in YYYYMMDD_HHMMSS format
|
|
155
|
+
def timestamp_string
|
|
156
|
+
Time.now.strftime("%Y%m%d_%H%M%S")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Sanitize a name for use in filenames
|
|
160
|
+
#
|
|
161
|
+
# @param name [String] Original name
|
|
162
|
+
# @return [String] Sanitized name
|
|
163
|
+
def sanitize_name(name)
|
|
164
|
+
name.to_s
|
|
165
|
+
.gsub(/[^a-zA-Z0-9_\-]/, "_")
|
|
166
|
+
.gsub(/_+/, "_")
|
|
167
|
+
.gsub(/^_|_$/, "")
|
|
168
|
+
.downcase
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Ensure directory exists
|
|
172
|
+
#
|
|
173
|
+
# @param path [String] Directory path
|
|
174
|
+
# @return [void]
|
|
175
|
+
def ensure_directory(path)
|
|
176
|
+
FileUtils.mkdir_p(path) unless Dir.exist?(path)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Session for grouping related output files
|
|
181
|
+
class Session
|
|
182
|
+
# @return [String] Session ID
|
|
183
|
+
attr_reader :id
|
|
184
|
+
|
|
185
|
+
# @return [String] Base output directory
|
|
186
|
+
attr_reader :base_dir
|
|
187
|
+
|
|
188
|
+
# @return [Time] Session creation time
|
|
189
|
+
attr_reader :created_at
|
|
190
|
+
|
|
191
|
+
# @return [Hash] Paths generated in this session
|
|
192
|
+
attr_reader :outputs
|
|
193
|
+
|
|
194
|
+
def initialize(id, base_dir)
|
|
195
|
+
@id = id
|
|
196
|
+
@base_dir = base_dir
|
|
197
|
+
@created_at = Time.now
|
|
198
|
+
@outputs = {}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Get path for a recording in this session
|
|
202
|
+
#
|
|
203
|
+
# @param name [String] Recording name
|
|
204
|
+
# @return [String] Full path
|
|
205
|
+
def recording_path(name = "recording")
|
|
206
|
+
path = OutputOrganizer.output_path(
|
|
207
|
+
OutputOrganizer.send(:sanitize_name, name),
|
|
208
|
+
format: :cast,
|
|
209
|
+
base_dir: @base_dir,
|
|
210
|
+
session_id: @id,
|
|
211
|
+
timestamp: false
|
|
212
|
+
)
|
|
213
|
+
@outputs[:recording] = path
|
|
214
|
+
path
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get path for an export in this session
|
|
218
|
+
#
|
|
219
|
+
# @param name [String] Export name
|
|
220
|
+
# @param format [Symbol] Output format
|
|
221
|
+
# @return [String] Full path
|
|
222
|
+
def export_path(name, format:)
|
|
223
|
+
path = OutputOrganizer.output_path(
|
|
224
|
+
OutputOrganizer.send(:sanitize_name, name),
|
|
225
|
+
format: format,
|
|
226
|
+
base_dir: @base_dir,
|
|
227
|
+
session_id: @id,
|
|
228
|
+
timestamp: false
|
|
229
|
+
)
|
|
230
|
+
@outputs[format] ||= []
|
|
231
|
+
@outputs[format] << path
|
|
232
|
+
path
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Get path for a thumbnail in this session
|
|
236
|
+
#
|
|
237
|
+
# @param name [String] Thumbnail name
|
|
238
|
+
# @param frame [Symbol] Frame type (:first, :middle, :last)
|
|
239
|
+
# @return [String] Full path
|
|
240
|
+
def thumbnail_path(name, frame: :last)
|
|
241
|
+
path = OutputOrganizer.output_path(
|
|
242
|
+
"#{OutputOrganizer.send(:sanitize_name, name)}_#{frame}",
|
|
243
|
+
format: :svg,
|
|
244
|
+
base_dir: File.join(@base_dir, "thumbnails"),
|
|
245
|
+
session_id: @id,
|
|
246
|
+
timestamp: false
|
|
247
|
+
)
|
|
248
|
+
@outputs[:thumbnails] ||= []
|
|
249
|
+
@outputs[:thumbnails] << path
|
|
250
|
+
path
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Get session directory path
|
|
254
|
+
#
|
|
255
|
+
# @return [String] Session directory
|
|
256
|
+
def directory
|
|
257
|
+
File.join(@base_dir, "recordings", @id)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Print summary of session outputs
|
|
261
|
+
#
|
|
262
|
+
# @return [String] Summary text
|
|
263
|
+
def summary
|
|
264
|
+
lines = ["Session: #{@id}", "Created: #{@created_at}", "Outputs:"]
|
|
265
|
+
@outputs.each do |type, paths|
|
|
266
|
+
paths = [paths] unless paths.is_a?(Array)
|
|
267
|
+
paths.each do |path|
|
|
268
|
+
size = File.exist?(path) ? File.size(path) : 0
|
|
269
|
+
lines << " #{type}: #{File.basename(path)} (#{size} bytes)"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
lines.join("\n")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciinemaWin
|
|
4
|
+
# Terminal session player for Windows
|
|
5
|
+
#
|
|
6
|
+
# Plays back asciicast v2 recordings with accurate timing,
|
|
7
|
+
# using Rich-Ruby for terminal output rendering. Supports
|
|
8
|
+
# speed control, idle time limiting, and interactive controls.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic playback
|
|
11
|
+
# player = AsciinemaWin::Player.new
|
|
12
|
+
# player.play("session.cast")
|
|
13
|
+
#
|
|
14
|
+
# @example Fast playback
|
|
15
|
+
# player = AsciinemaWin::Player.new(speed: 2.0)
|
|
16
|
+
# player.play("session.cast")
|
|
17
|
+
#
|
|
18
|
+
# @example With idle time limit
|
|
19
|
+
# player = AsciinemaWin::Player.new(idle_time_limit: 1.0)
|
|
20
|
+
# player.play("session.cast")
|
|
21
|
+
class Player
|
|
22
|
+
# @return [Float] Playback speed multiplier
|
|
23
|
+
attr_reader :speed
|
|
24
|
+
|
|
25
|
+
# @return [Float, nil] Maximum idle time between frames
|
|
26
|
+
attr_reader :idle_time_limit
|
|
27
|
+
|
|
28
|
+
# @return [Float, nil] Alternative max idle time setting
|
|
29
|
+
attr_reader :max_idle_time
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] Whether to pause at markers
|
|
32
|
+
attr_reader :pause_on_markers
|
|
33
|
+
|
|
34
|
+
# @return [Symbol] Current playback state (:idle, :playing, :paused, :stopped)
|
|
35
|
+
attr_reader :state
|
|
36
|
+
|
|
37
|
+
# @return [Float] Current playback position in seconds
|
|
38
|
+
attr_reader :position
|
|
39
|
+
|
|
40
|
+
# Create a new player
|
|
41
|
+
#
|
|
42
|
+
# @param speed [Float] Playback speed multiplier (1.0 = normal)
|
|
43
|
+
# @param idle_time_limit [Float, nil] Cap idle time from recording header
|
|
44
|
+
# @param max_idle_time [Float, nil] Override max idle time between frames
|
|
45
|
+
# @param pause_on_markers [Boolean] Pause playback at marker events
|
|
46
|
+
def initialize(
|
|
47
|
+
speed: 1.0,
|
|
48
|
+
idle_time_limit: nil,
|
|
49
|
+
max_idle_time: nil,
|
|
50
|
+
pause_on_markers: false
|
|
51
|
+
)
|
|
52
|
+
@speed = speed.to_f
|
|
53
|
+
@speed = 1.0 if @speed <= 0
|
|
54
|
+
|
|
55
|
+
@idle_time_limit = idle_time_limit&.to_f
|
|
56
|
+
@max_idle_time = max_idle_time&.to_f
|
|
57
|
+
@pause_on_markers = pause_on_markers
|
|
58
|
+
|
|
59
|
+
@state = :idle
|
|
60
|
+
@position = 0.0
|
|
61
|
+
@reader = nil
|
|
62
|
+
@header = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Play a recording from a file
|
|
66
|
+
#
|
|
67
|
+
# @param input_path [String] Path to the recording file
|
|
68
|
+
# @return [void]
|
|
69
|
+
# @raise [FormatError] If file format is invalid
|
|
70
|
+
# @raise [PlaybackError] If playback fails
|
|
71
|
+
def play(input_path)
|
|
72
|
+
raise PlaybackError, "Already playing" if @state == :playing
|
|
73
|
+
|
|
74
|
+
File.open(input_path, "r", encoding: "UTF-8") do |file|
|
|
75
|
+
@reader = Asciicast::Reader.new(file)
|
|
76
|
+
@header = @reader.header
|
|
77
|
+
@state = :playing
|
|
78
|
+
@position = 0.0
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
setup_terminal
|
|
82
|
+
play_events
|
|
83
|
+
ensure
|
|
84
|
+
restore_terminal
|
|
85
|
+
@state = :stopped
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Play from an IO stream
|
|
91
|
+
#
|
|
92
|
+
# @param io [IO] Input stream
|
|
93
|
+
# @return [void]
|
|
94
|
+
def play_stream(io)
|
|
95
|
+
raise PlaybackError, "Already playing" if @state == :playing
|
|
96
|
+
|
|
97
|
+
@reader = Asciicast::Reader.new(io)
|
|
98
|
+
@header = @reader.header
|
|
99
|
+
@state = :playing
|
|
100
|
+
@position = 0.0
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
setup_terminal
|
|
104
|
+
play_events
|
|
105
|
+
ensure
|
|
106
|
+
restore_terminal
|
|
107
|
+
@state = :stopped
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Pause playback
|
|
112
|
+
#
|
|
113
|
+
# @return [void]
|
|
114
|
+
def pause
|
|
115
|
+
return unless @state == :playing
|
|
116
|
+
|
|
117
|
+
@state = :paused
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Resume playback after pause
|
|
121
|
+
#
|
|
122
|
+
# @return [void]
|
|
123
|
+
def resume
|
|
124
|
+
return unless @state == :paused
|
|
125
|
+
|
|
126
|
+
@state = :playing
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Stop playback
|
|
130
|
+
#
|
|
131
|
+
# @return [void]
|
|
132
|
+
def stop
|
|
133
|
+
@state = :stopped
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Set playback speed
|
|
137
|
+
#
|
|
138
|
+
# @param multiplier [Float] New speed multiplier
|
|
139
|
+
# @return [void]
|
|
140
|
+
def set_speed(multiplier)
|
|
141
|
+
@speed = [multiplier.to_f, 0.1].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Get recording info without playing
|
|
145
|
+
#
|
|
146
|
+
# @param input_path [String] Path to recording file
|
|
147
|
+
# @return [Hash] Recording metadata
|
|
148
|
+
def info(input_path)
|
|
149
|
+
Asciicast::Reader.info(input_path)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Set up terminal for playback
|
|
155
|
+
#
|
|
156
|
+
# @return [void]
|
|
157
|
+
def setup_terminal
|
|
158
|
+
return unless @header
|
|
159
|
+
|
|
160
|
+
# Enable ANSI on Windows
|
|
161
|
+
if Gem.win_platform? && defined?(Rich::Win32Console)
|
|
162
|
+
Rich::Win32Console.enable_ansi!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Clear screen and move cursor to home
|
|
166
|
+
print "\e[2J" # Clear screen
|
|
167
|
+
print "\e[H" # Move to home
|
|
168
|
+
|
|
169
|
+
# Hide cursor during playback
|
|
170
|
+
print "\e[?25l"
|
|
171
|
+
|
|
172
|
+
# Print playback info if verbose
|
|
173
|
+
if ENV["DEBUG"]
|
|
174
|
+
$stderr.puts "Playing: #{@header.title || "Untitled"}"
|
|
175
|
+
$stderr.puts "Size: #{@header.width}x#{@header.height}"
|
|
176
|
+
$stderr.puts "Speed: #{@speed}x"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Restore terminal to normal state
|
|
181
|
+
#
|
|
182
|
+
# @return [void]
|
|
183
|
+
def restore_terminal
|
|
184
|
+
# Reset colors and attributes
|
|
185
|
+
print "\e[0m"
|
|
186
|
+
|
|
187
|
+
# Show cursor
|
|
188
|
+
print "\e[?25h"
|
|
189
|
+
|
|
190
|
+
# Move to new line
|
|
191
|
+
puts
|
|
192
|
+
|
|
193
|
+
# Flush output
|
|
194
|
+
$stdout.flush
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Play all events from the recording
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
def play_events
|
|
201
|
+
last_time = 0.0
|
|
202
|
+
effective_idle_limit = calculate_idle_limit
|
|
203
|
+
|
|
204
|
+
@reader.each_event do |event|
|
|
205
|
+
break if @state == :stopped
|
|
206
|
+
|
|
207
|
+
# Handle pause state
|
|
208
|
+
while @state == :paused
|
|
209
|
+
sleep(0.1)
|
|
210
|
+
break if @state == :stopped
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
break if @state == :stopped
|
|
214
|
+
|
|
215
|
+
# Calculate delay
|
|
216
|
+
delay = event.time - last_time
|
|
217
|
+
|
|
218
|
+
# Apply idle time limit
|
|
219
|
+
if effective_idle_limit && delay > effective_idle_limit
|
|
220
|
+
delay = effective_idle_limit
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Apply speed multiplier
|
|
224
|
+
delay /= @speed unless @speed == Float::INFINITY
|
|
225
|
+
|
|
226
|
+
# Wait for appropriate time
|
|
227
|
+
wait_for_event(delay) if delay > 0
|
|
228
|
+
|
|
229
|
+
# Update position
|
|
230
|
+
@position = event.time
|
|
231
|
+
last_time = event.time
|
|
232
|
+
|
|
233
|
+
# Process event
|
|
234
|
+
process_event(event)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Calculate effective idle time limit
|
|
239
|
+
#
|
|
240
|
+
# @return [Float, nil] Effective idle limit
|
|
241
|
+
def calculate_idle_limit
|
|
242
|
+
# Priority: explicit max_idle_time > idle_time_limit > header value
|
|
243
|
+
@max_idle_time ||
|
|
244
|
+
@idle_time_limit ||
|
|
245
|
+
@header&.idle_time_limit
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Wait for the specified duration (interruptible)
|
|
249
|
+
#
|
|
250
|
+
# @param seconds [Float] Time to wait
|
|
251
|
+
# @return [void]
|
|
252
|
+
def wait_for_event(seconds)
|
|
253
|
+
return if seconds <= 0 || @speed == Float::INFINITY
|
|
254
|
+
|
|
255
|
+
# Use small sleep intervals to allow interruption
|
|
256
|
+
remaining = seconds
|
|
257
|
+
|
|
258
|
+
while remaining > 0 && @state == :playing
|
|
259
|
+
sleep_time = [remaining, 0.05].min
|
|
260
|
+
sleep(sleep_time)
|
|
261
|
+
remaining -= sleep_time
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Process a single event
|
|
266
|
+
#
|
|
267
|
+
# @param event [Asciicast::Event] Event to process
|
|
268
|
+
# @return [void]
|
|
269
|
+
def process_event(event)
|
|
270
|
+
case event.type
|
|
271
|
+
when Asciicast::EventType::OUTPUT
|
|
272
|
+
render_output(event.data)
|
|
273
|
+
when Asciicast::EventType::RESIZE
|
|
274
|
+
handle_resize(event)
|
|
275
|
+
when Asciicast::EventType::MARKER
|
|
276
|
+
handle_marker(event)
|
|
277
|
+
when Asciicast::EventType::INPUT
|
|
278
|
+
# Input events are typically not played back
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Render output data to terminal
|
|
284
|
+
#
|
|
285
|
+
# @param data [String] Output data (may contain ANSI sequences)
|
|
286
|
+
# @return [void]
|
|
287
|
+
def render_output(data)
|
|
288
|
+
# Write directly to stdout, preserving ANSI sequences
|
|
289
|
+
print data
|
|
290
|
+
$stdout.flush
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Handle resize event
|
|
294
|
+
#
|
|
295
|
+
# @param event [Asciicast::Event] Resize event
|
|
296
|
+
# @return [void]
|
|
297
|
+
def handle_resize(event)
|
|
298
|
+
dimensions = event.resize_dimensions
|
|
299
|
+
return unless dimensions
|
|
300
|
+
|
|
301
|
+
width, height = dimensions
|
|
302
|
+
|
|
303
|
+
# Note: We can't actually resize the terminal, but we could
|
|
304
|
+
# adjust our rendering or warn the user if sizes don't match
|
|
305
|
+
if ENV["DEBUG"]
|
|
306
|
+
$stderr.puts "Resize: #{width}x#{height}"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Handle marker event
|
|
311
|
+
#
|
|
312
|
+
# @param event [Asciicast::Event] Marker event
|
|
313
|
+
# @return [void]
|
|
314
|
+
def handle_marker(event)
|
|
315
|
+
return unless @pause_on_markers
|
|
316
|
+
|
|
317
|
+
label = event.data.empty? ? "marker" : event.data
|
|
318
|
+
$stderr.puts "\n[Marker: #{label}] Press Enter to continue..."
|
|
319
|
+
|
|
320
|
+
# Pause and wait for input
|
|
321
|
+
@state = :paused
|
|
322
|
+
$stdin.gets
|
|
323
|
+
@state = :playing
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Simple output mode - outputs recording to stdout without timing
|
|
328
|
+
# Used by the `cat` command for piping to other tools
|
|
329
|
+
class RawPlayer < Player
|
|
330
|
+
def initialize
|
|
331
|
+
super(speed: Float::INFINITY)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
def setup_terminal
|
|
337
|
+
# No setup needed for raw output
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def restore_terminal
|
|
341
|
+
# No restore needed
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def wait_for_event(_seconds)
|
|
345
|
+
# No waiting in raw mode
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|