vibetunnel/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift
2025-06-23 14:58:11 +02:00

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()
}
}
}
}