Support fast forwarding and rewinding

This commit is contained in:
Trevor Slocum 2020-10-23 13:38:54 -07:00
parent d6824a35ba
commit f6c9c5e976
5 changed files with 190 additions and 78 deletions

View File

@ -19,8 +19,8 @@ go get gitlab.com/tslocum/asciinema-editor
Execute `asciinema-editor` twice to edit a screencast.
```bash
asciinema-editor --viewer # Start viewer
asciinema-editor /tmp/path/to/screen.cast # Start editor
asciinema-editor --viewer # Start the viewer
asciinema-editor /tmp/path/to/screen.cast # Start the editor
```
**Tip:** A terminal multiplexer such as [tmux](https://github.com/tmux/tmux) or [screen](https://www.gnu.org/software/screen/)

102
editor.go
View File

@ -18,6 +18,10 @@ var (
editorPaused bool
statusBuffer *cview.TextView
editorCursor time.Duration
editorCursorTime time.Time
editorStatus string
)
func handleEditor() {
@ -26,9 +30,9 @@ func handleEditor() {
for c := range commandsIn {
switch c.Type {
case commandStatus:
// TODO cache sent time and update realtime
statusBuffer.SetText(fmt.Sprintf("%d:%02d", int(c.D.Minutes())%60, int(c.D.Seconds())%60))
app.Draw()
editorCursor = c.D
editorCursorTime = time.Now()
editorStatus = c.S
}
}
}
@ -48,37 +52,52 @@ func connectToViewer(address string) error {
return nil
}
func runEditor(viewer, single, force bool, controlAddress string) {
func updateStatus() {
cursor := editorCursor
if !editorPaused && editorStatus == "playing" {
cursor += time.Since(editorCursorTime)
}
statusBuffer.SetText(fmt.Sprintf("%d:%02d", int(cursor.Minutes())%60, int(cursor.Seconds())%60))
}
func handleUpdateUI() {
t := time.NewTicker(time.Second)
for range t.C {
updateStatus()
app.Draw()
}
}
func runEditor(controlAddress string, force bool) {
filePath := flag.Arg(0)
if filePath == "" {
log.Fatal("supply the path to an asciinema .cast file")
}
err := loadCast(filePath)
if err != nil {
log.Fatalf("failed to load asciinema screencast: %s", err)
}
err = connectToViewer(controlAddress)
if err != nil {
if !single {
log.Fatalf("failed to connect to viwer: %s", err)
if !single {
err := connectToViewer(controlAddress)
if err != nil {
if !single {
log.Fatalf("failed to connect to viwer: %s", err)
}
err = nil
}
err = nil
}
app = cview.NewApplication()
err = app.Init()
err := app.Init()
if err != nil {
log.Fatal(err)
}
app.QueueUpdate(func() {
w, h := app.GetScreenSize()
if (uint(w) < loadedCast.Header.Width || uint(h) < loadedCast.Header.Height) && !force {
log.Fatalf("recording dimensions (%dx%d) are larger than the current terminal (%dx%d). add --force to force playback", loadedCast.Header.Width, loadedCast.Header.Height, w, h)
}
})
if single {
app.QueueUpdate(func() {
w, h := app.GetScreenSize()
if (uint(w) < loadedCast.Header.Width || uint(h) < loadedCast.Header.Height) && !force {
log.Fatalf("recording dimensions (%dx%d) are larger than the current terminal (%dx%d). add --force to force playback", loadedCast.Header.Width, loadedCast.Header.Height, w, h)
}
})
}
app.EnableMouse(true)
@ -86,19 +105,6 @@ func runEditor(viewer, single, force bool, controlAddress string) {
app.SetRoot(statusBuffer, true)
var cursor time.Duration
go func() {
if single {
app.Suspend(playFunc(cursor))
} else if !viewer {
sendCommand(&command{commandLoad, 0, filePath})
sendCommand(&command{commandPlay, 60 * time.Second, ""})
}
}()
// TODO loop, poll for current timestamp in commandsIn
quit := func(ev *tcell.EventKey) *tcell.EventKey {
app.Stop()
return nil
@ -110,6 +116,7 @@ func runEditor(viewer, single, force bool, controlAddress string) {
}
doPlay := func(ev *tcell.EventKey) *tcell.EventKey {
editorCursorTime = time.Now()
sendCommand(&command{commandPlay, 0, ""})
return nil
}
@ -119,20 +126,47 @@ func runEditor(viewer, single, force bool, controlAddress string) {
if editorPaused {
sendCommand(&command{commandPause, 0, ""})
} else {
editorCursorTime = time.Now()
sendCommand(&command{commandResume, 0, ""})
}
return nil
}
doFastForward := func(ev *tcell.EventKey) *tcell.EventKey {
c := editorCursor + 5*time.Second
editorCursor = c
sendCommand(&command{commandPlay, c, ""})
updateStatus()
return nil
}
doRewind := func(ev *tcell.EventKey) *tcell.EventKey {
c := editorCursor - 5*time.Second
if c < 0 {
c = 0
}
editorCursor = c
sendCommand(&command{commandPlay, c, ""})
updateStatus()
return nil
}
inputConfig := cbind.NewConfiguration()
inputConfig.Set("Escape", quit)
inputConfig.Set("Ctrl+c", quit)
inputConfig.Set("Ctrl+d", doInterrupt)
inputConfig.Set("Space", doPause)
inputConfig.Set("Enter", doPlay)
inputConfig.Set("Left", doRewind)
inputConfig.Set("Right", doFastForward)
app.SetInputCapture(inputConfig.Capture)
go handleUpdateUI()
castCommand <- &command{commandLoad, 0, filePath}
castCommand <- &command{commandPlay, 0, ""}
if err := app.Run(); err != nil {
log.Fatal(err)
}

28
main.go
View File

@ -3,14 +3,19 @@ package main
import (
"flag"
"os"
"os/signal"
"path"
"syscall"
)
var (
single bool
viewer bool
)
func main() {
var (
force bool
viewer bool
single bool
controlAddress string
)
flag.BoolVar(&force, "force", false, "force playback with insufficiently sized terminal")
@ -23,10 +28,27 @@ func main() {
controlAddress = path.Join(os.TempDir(), "asciinema-editor.sock")
}
if viewer || single {
sigc := make(chan os.Signal, 1)
signal.Notify(sigc,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT)
go func() {
<-sigc
interruptPlayback()
resetScreen()
os.Exit(0)
}()
}
go handleCast()
if viewer {
runViewer(controlAddress)
return
}
runEditor(viewer, single, force, controlAddress)
runEditor(controlAddress, force)
}

100
player.go
View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"os"
"time"
@ -11,10 +12,56 @@ import (
var (
playerCursor time.Duration
loadedCast *cast.Cast
playing bool
castCommand = make(chan *command)
interrupt = make(chan struct{})
goToTime = make(chan time.Duration)
)
func handleCast() {
for c := range castCommand {
if commandConn != nil && !single && !viewer {
sendCommand(c)
continue
}
switch c.Type {
case commandLoad:
err := loadCast(c.S)
if err != nil {
log.Fatalf("failed to load cast at %s: %s", c.S, err)
}
case commandStop:
interruptPlayback()
resetScreen()
case commandPlay:
if !playing {
interruptPlayback()
go play(c.D)
} else {
goToTime <- c.D
}
case commandPause:
interruptPlayback()
case commandResume:
if !playing {
interruptPlayback()
go play(c.D)
} else {
goToTime <- c.D
}
}
}
}
func loadCast(filePath string) error {
select {
case interrupt <- struct{}{}:
default:
}
playing = false
file, err := os.Open(filePath)
if err != nil {
return err
@ -31,45 +78,86 @@ func loadCast(filePath string) error {
// TODO maintain playing goroutine, unpause / fast forward when possible rather than reinstancing
func play(at time.Duration) {
playing = true
resetScreen()
disableEcho()
var lastPing time.Time
var fastForward time.Duration
start := time.Now().Add(at * -1)
for _, ev := range loadedCast.EventStream {
t := time.Duration(ev.Time * float64(time.Second))
if ev.Type == "i" {
continue // TODO
continue
} else if fastForward > 0 && t <= fastForward {
fmt.Print(ev.Data)
continue
}
select {
case <-interrupt:
playing = false
playerCursor = time.Since(start)
sendCommand(&command{commandStatus, playerCursor, ""})
sendCommand(&command{commandStatus, playerCursor, "stopped"})
return
case d := <-goToTime:
if d < playerCursor {
go play(d)
return
} else if d > playerCursor {
fastForward = d
start = time.Now().Add(fastForward * -1)
fmt.Print(ev.Data)
continue
}
default:
}
t := time.Duration(ev.Time * float64(time.Second))
if time.Since(start) < t {
t := time.NewTimer(t - time.Since(start))
select {
case <-interrupt:
playing = false
playerCursor = time.Since(start)
sendCommand(&command{commandStatus, playerCursor, ""})
status := "stopped"
if playing {
status = "playing"
}
sendCommand(&command{commandStatus, playerCursor, status})
return
case d := <-goToTime:
if d < playerCursor {
go play(d)
return
} else if d > playerCursor {
fastForward = d
start = time.Now().Add(fastForward * -1)
fmt.Print(ev.Data)
continue
}
case <-t.C:
}
playerCursor = time.Since(start)
if time.Since(lastPing) >= 1*time.Second {
sendCommand(&command{commandStatus, playerCursor, ""})
status := "stopped"
if playing {
status = "playing"
}
sendCommand(&command{commandStatus, playerCursor, status})
lastPing = time.Now()
}
}
fmt.Print(ev.Data)
}
playing = false
sendCommand(&command{commandStatus, playerCursor, "stopped"})
resetScreen()
fmt.Print("Playback complete.")
if !single {
fmt.Print("Playback complete.")
}
}
func playFunc(at time.Duration) func() {

View File

@ -5,9 +5,7 @@ import (
"net"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
@ -59,42 +57,12 @@ func hostViewer(address string) error {
func handleViewer() {
go handleRead()
go handleWrite()
var err error
for c := range commandsIn {
switch c.Type {
case commandLoad:
err = loadCast(c.S)
if err != nil {
log.Fatalf("failed to load cast at %s: %s", c.S, err)
}
case commandStop:
interruptPlayback()
resetScreen()
case commandPlay:
interruptPlayback()
go play(c.D)
case commandPause:
interruptPlayback()
case commandResume:
go play(playerCursor)
}
castCommand <- c
}
}
func runViewer(controlAddress string) {
sigc := make(chan os.Signal, 1)
signal.Notify(sigc,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT)
go func() {
<-sigc
interruptPlayback()
resetScreen()
os.Exit(0)
}()
err := hostViewer(controlAddress)
if err != nil {
log.Fatalf("failed to host viwer: %s", err)