mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-11 12:15:53 +00:00
404 lines
14 KiB
Swift
404 lines
14 KiB
Swift
import Observation
|
|
import SwiftTerm
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
/// View for playing back terminal recordings from cast files.
|
|
///
|
|
/// Displays recorded terminal sessions with playback controls,
|
|
/// supporting the Asciinema cast v2 format.
|
|
struct CastPlayerView: View {
|
|
let castFileURL: URL
|
|
@Environment(\.dismiss)
|
|
var dismiss
|
|
@State private var viewModel = CastPlayerViewModel()
|
|
@State private var fontSize: CGFloat = 14
|
|
@State private var isPlaying = false
|
|
@State private var currentTime: TimeInterval = 0
|
|
@State private var playbackSpeed: Double = 1.0
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Theme.Colors.terminalBackground
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
if viewModel.isLoading {
|
|
loadingView
|
|
} else if let error = viewModel.errorMessage {
|
|
errorView(error)
|
|
} else if viewModel.player != nil {
|
|
playerContent
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Recording Playback")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
}
|
|
}
|
|
.navigationViewStyle(StackNavigationViewStyle())
|
|
.preferredColorScheme(.dark)
|
|
.onAppear {
|
|
viewModel.loadCastFile(from: castFileURL)
|
|
}
|
|
}
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
|
.scaleEffect(1.5)
|
|
|
|
Text("Loading recording...")
|
|
.font(Theme.Typography.terminalSystem(size: 14))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private func errorView(_ error: String) -> some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(Theme.Colors.errorAccent)
|
|
|
|
Text("Failed to load recording")
|
|
.font(.headline)
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Text(error)
|
|
.font(Theme.Typography.terminalSystem(size: 12))
|
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var playerContent: some View {
|
|
VStack(spacing: 0) {
|
|
// Terminal display
|
|
CastTerminalView(fontSize: $fontSize, viewModel: viewModel)
|
|
.background(Theme.Colors.terminalBackground)
|
|
|
|
// Playback controls
|
|
VStack(spacing: Theme.Spacing.medium) {
|
|
// Progress bar
|
|
VStack(spacing: Theme.Spacing.extraSmall) {
|
|
Slider(value: $currentTime, in: 0...viewModel.duration) { editing in
|
|
if !editing && isPlaying {
|
|
// Resume playback from new position
|
|
viewModel.seekTo(time: currentTime)
|
|
}
|
|
}
|
|
.accentColor(Theme.Colors.primaryAccent)
|
|
|
|
HStack {
|
|
Text(formatTime(currentTime))
|
|
.font(Theme.Typography.terminalSystem(size: 10))
|
|
Spacer()
|
|
Text(formatTime(viewModel.duration))
|
|
.font(Theme.Typography.terminalSystem(size: 10))
|
|
}
|
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
|
}
|
|
|
|
// Control buttons
|
|
HStack(spacing: Theme.Spacing.extraLarge) {
|
|
// Speed control
|
|
Menu {
|
|
Button("0.5x") { playbackSpeed = 0.5 }
|
|
Button("1x") { playbackSpeed = 1.0 }
|
|
Button("2x") { playbackSpeed = 2.0 }
|
|
Button("4x") { playbackSpeed = 4.0 }
|
|
} label: {
|
|
Text("\(playbackSpeed, specifier: "%.1f")x")
|
|
.font(Theme.Typography.terminalSystem(size: 14))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.padding(.horizontal, Theme.Spacing.small)
|
|
.padding(.vertical, Theme.Spacing.extraSmall)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
|
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// Play/Pause
|
|
Button(action: togglePlayback) {
|
|
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
|
|
// Restart
|
|
Button(action: restart) {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Theme.Colors.cardBackground)
|
|
}
|
|
.onChange(of: viewModel.currentTime) { _, newTime in
|
|
if !viewModel.isSeeking {
|
|
currentTime = newTime
|
|
}
|
|
}
|
|
}
|
|
|
|
private func togglePlayback() {
|
|
if isPlaying {
|
|
viewModel.pause()
|
|
} else {
|
|
viewModel.play(speed: playbackSpeed)
|
|
}
|
|
isPlaying.toggle()
|
|
}
|
|
|
|
private func restart() {
|
|
viewModel.restart()
|
|
currentTime = 0
|
|
if isPlaying {
|
|
viewModel.play(speed: playbackSpeed)
|
|
}
|
|
}
|
|
|
|
private func formatTime(_ seconds: TimeInterval) -> String {
|
|
let minutes = Int(seconds) / 60
|
|
let remainingSeconds = Int(seconds) % 60
|
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
|
}
|
|
}
|
|
|
|
/// Terminal view specialized for cast file playback.
|
|
///
|
|
/// Provides a read-only terminal emulator for displaying recorded
|
|
/// terminal sessions, handling font sizing and terminal dimensions
|
|
/// based on the cast file metadata.
|
|
struct CastTerminalView: UIViewRepresentable {
|
|
@Binding var fontSize: CGFloat
|
|
let viewModel: CastPlayerViewModel
|
|
|
|
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
|
let terminal = SwiftTerm.TerminalView()
|
|
|
|
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
|
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
|
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
|
|
|
terminal.allowMouseReporting = false
|
|
// SwiftTerm doesn't have built-in link detection API
|
|
// URL detection would need to be implemented manually
|
|
|
|
updateFont(terminal, size: fontSize)
|
|
|
|
// Set initial size from cast file if available
|
|
if let header = viewModel.header {
|
|
terminal.resize(cols: Int(header.width), rows: Int(header.height))
|
|
} else {
|
|
terminal.resize(cols: 80, rows: 24)
|
|
}
|
|
|
|
context.coordinator.terminal = terminal
|
|
return terminal
|
|
}
|
|
|
|
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
|
updateFont(terminal, size: fontSize)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(viewModel: viewModel)
|
|
}
|
|
|
|
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
|
let font: UIFont = if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
|
customFont
|
|
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
|
fallbackFont
|
|
} else {
|
|
UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
|
}
|
|
terminal.font = font
|
|
}
|
|
|
|
/// Coordinator for managing terminal state and handling events.
|
|
@MainActor
|
|
class Coordinator: NSObject {
|
|
weak var terminal: SwiftTerm.TerminalView?
|
|
let viewModel: CastPlayerViewModel
|
|
|
|
init(viewModel: CastPlayerViewModel) {
|
|
self.viewModel = viewModel
|
|
super.init()
|
|
|
|
// Set up terminal output handler
|
|
viewModel.onTerminalOutput = { [weak self] data in
|
|
Task { @MainActor in
|
|
self?.terminal?.feed(text: data)
|
|
}
|
|
}
|
|
|
|
viewModel.onTerminalClear = { [weak self] in
|
|
Task { @MainActor in
|
|
// SwiftTerm uses standard ANSI escape sequences for clearing
|
|
// This is the correct approach for clearing the terminal
|
|
self?.terminal?.feed(text: "\u{001B}[2J\u{001B}[H")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// View model for cast file playback control.
|
|
@MainActor
|
|
@Observable
|
|
class CastPlayerViewModel {
|
|
var isLoading = true
|
|
var errorMessage: String?
|
|
var currentTime: TimeInterval = 0
|
|
var isSeeking = false
|
|
|
|
var player: CastPlayer?
|
|
var header: CastFile? { player?.header }
|
|
var duration: TimeInterval { player?.duration ?? 0 }
|
|
|
|
var onTerminalOutput: ((String) -> Void)?
|
|
var onTerminalClear: (() -> Void)?
|
|
|
|
private var playbackTask: Task<Void, Never>?
|
|
|
|
func loadCastFile(from url: URL) {
|
|
Task {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
|
|
guard let player = CastPlayer(data: data) else {
|
|
errorMessage = "Invalid cast file format"
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
self.player = player
|
|
isLoading = false
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func play(speed: Double = 1.0) {
|
|
playbackTask?.cancel()
|
|
|
|
playbackTask = Task {
|
|
guard let player else { return }
|
|
|
|
player.play(from: currentTime, speed: speed) { [weak self] event in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
|
|
switch event.type {
|
|
case "o":
|
|
self.onTerminalOutput?(event.data)
|
|
case "r":
|
|
// Handle resize if needed
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
|
|
self.currentTime = event.time
|
|
}
|
|
} completion: {
|
|
// Playback completed
|
|
}
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
playbackTask?.cancel()
|
|
}
|
|
|
|
func seekTo(time: TimeInterval) {
|
|
isSeeking = true
|
|
currentTime = time
|
|
|
|
// Clear terminal and replay up to the seek point
|
|
onTerminalClear?()
|
|
|
|
guard let player else { return }
|
|
|
|
// Replay all events up to the seek time instantly
|
|
for event in player.events where event.time <= time {
|
|
if event.type == "o" {
|
|
onTerminalOutput?(event.data)
|
|
}
|
|
}
|
|
|
|
isSeeking = false
|
|
}
|
|
|
|
func restart() {
|
|
playbackTask?.cancel()
|
|
currentTime = 0
|
|
onTerminalClear?()
|
|
}
|
|
}
|
|
|
|
/// Extension to CastPlayer for playback from specific time
|
|
extension CastPlayer {
|
|
/// Plays the cast file from a specific time with adjustable speed.
|
|
///
|
|
/// - Parameters:
|
|
/// - startTime: Time offset to start playback from (default: 0).
|
|
/// - speed: Playback speed multiplier (default: 1.0).
|
|
/// - onEvent: Closure called for each event during playback.
|
|
/// - completion: Closure called when playback completes.
|
|
///
|
|
/// This method supports seeking and variable speed playback,
|
|
/// filtering events based on the start time and adjusting
|
|
/// delays according to the speed multiplier.
|
|
func play(
|
|
from startTime: TimeInterval = 0,
|
|
speed: Double = 1.0,
|
|
onEvent: @escaping @Sendable (CastEvent) -> Void,
|
|
completion: @escaping @Sendable () -> Void
|
|
) {
|
|
let eventsToPlay = events.filter { $0.time > startTime }
|
|
Task { @Sendable in
|
|
var lastEventTime = startTime
|
|
|
|
for event in eventsToPlay {
|
|
// Calculate wait time adjusted for playback speed
|
|
let waitTime = (event.time - lastEventTime) / speed
|
|
if waitTime > 0 {
|
|
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
|
}
|
|
|
|
// Check if task was cancelled
|
|
if Task.isCancelled { break }
|
|
|
|
await MainActor.run {
|
|
onEvent(event)
|
|
}
|
|
|
|
lastEventTime = event.time
|
|
}
|
|
|
|
await MainActor.run {
|
|
completion()
|
|
}
|
|
}
|
|
}
|
|
}
|