forked from Leak_Technologies/VideoTools
361 lines
7.8 KiB
Go
361 lines
7.8 KiB
Go
//go:build linux
|
|
|
|
package player
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const playerWindowTitle = "VideoToolsPlayer"
|
|
|
|
func newController() Controller {
|
|
return &ffplayController{}
|
|
}
|
|
|
|
type ffplayController struct {
|
|
mu sync.Mutex
|
|
cmd *exec.Cmd
|
|
stdin *bufio.Writer
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
path string
|
|
paused bool
|
|
seekT *time.Timer
|
|
seekAt float64
|
|
volume int // 0-100
|
|
winX int
|
|
winY int
|
|
winW int
|
|
winH int
|
|
}
|
|
|
|
// pickLastID runs a command and returns the last whitespace-delimited token from stdout.
|
|
func pickLastID(cmd *exec.Cmd) string {
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parts := strings.Fields(string(out))
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
var (
|
|
keyFullscreen = []byte{'f'}
|
|
keyPause = []byte{'p'}
|
|
keyQuit = []byte{'q'}
|
|
keyVolDown = []byte{'9'}
|
|
keyVolUp = []byte{'0'}
|
|
)
|
|
|
|
func (c *ffplayController) Load(path string, offset float64) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.path = path
|
|
if c.volume == 0 {
|
|
c.volume = 100
|
|
}
|
|
c.paused = true
|
|
return c.startLocked(offset)
|
|
}
|
|
|
|
func (c *ffplayController) SetWindow(x, y, w, h int) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.winX, c.winY, c.winW, c.winH = x, y, w, h
|
|
}
|
|
|
|
func (c *ffplayController) Play() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// Only toggle if we believe we are paused.
|
|
if c.paused {
|
|
if err := c.sendLocked(keyPause); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c.paused = false
|
|
return nil
|
|
}
|
|
|
|
func (c *ffplayController) Pause() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if !c.paused {
|
|
if err := c.sendLocked(keyPause); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c.paused = true
|
|
return nil
|
|
}
|
|
|
|
func (c *ffplayController) Seek(offset float64) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.path == "" {
|
|
return fmt.Errorf("no source loaded")
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
c.seekAt = offset
|
|
if c.seekT != nil {
|
|
c.seekT.Stop()
|
|
}
|
|
c.seekT = time.AfterFunc(90*time.Millisecond, func() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// Timer may fire after stop; guard.
|
|
if c.path == "" {
|
|
return
|
|
}
|
|
_ = c.startLocked(c.seekAt)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (c *ffplayController) FullScreen() error { return c.send(keyFullscreen) }
|
|
func (c *ffplayController) Stop() error { return c.send(keyQuit) }
|
|
func (c *ffplayController) SetVolume(level float64) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
target := int(level + 0.5)
|
|
if target < 0 {
|
|
target = 0
|
|
}
|
|
if target > 100 {
|
|
target = 100
|
|
}
|
|
if target == c.volume {
|
|
return nil
|
|
}
|
|
diff := target - c.volume
|
|
c.volume = target
|
|
|
|
if !c.runningLocked() {
|
|
return nil
|
|
}
|
|
|
|
key := keyVolUp
|
|
steps := diff
|
|
if diff < 0 {
|
|
key = keyVolDown
|
|
steps = -diff
|
|
}
|
|
// Limit burst size to avoid overwhelming stdin.
|
|
for i := 0; i < steps; i++ {
|
|
if err := c.sendLocked(key); err != nil {
|
|
return err
|
|
}
|
|
// Tiny delay to let ffplay process the keys.
|
|
if steps > 8 {
|
|
time.Sleep(8 * time.Millisecond)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *ffplayController) Close() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.stopLocked()
|
|
}
|
|
|
|
func (c *ffplayController) send(seq []byte) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.sendLocked(seq)
|
|
}
|
|
|
|
func (c *ffplayController) sendLocked(seq []byte) error {
|
|
if !c.runningLocked() {
|
|
return fmt.Errorf("ffplay not running")
|
|
}
|
|
if _, err := c.stdin.Write(seq); err != nil {
|
|
return err
|
|
}
|
|
return c.stdin.Flush()
|
|
}
|
|
|
|
func (c *ffplayController) stopLocked() {
|
|
if c.stdin != nil {
|
|
c.stdin.Write(keyQuit)
|
|
c.stdin.Flush()
|
|
}
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
c.cmd = nil
|
|
c.stdin = nil
|
|
c.cancel = nil
|
|
c.path = ""
|
|
c.paused = false
|
|
if c.seekT != nil {
|
|
c.seekT.Stop()
|
|
c.seekT = nil
|
|
}
|
|
}
|
|
|
|
func (c *ffplayController) waitForExit(cmd *exec.Cmd, cancel context.CancelFunc, stderr *bytes.Buffer) {
|
|
err := cmd.Wait()
|
|
exit := ""
|
|
if cmd.ProcessState != nil {
|
|
exit = cmd.ProcessState.String()
|
|
}
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
log.Printf("[ffplay] exit error: %v (%s) stderr=%s", err, exit, msg)
|
|
} else {
|
|
log.Printf("[ffplay] exit error: %v (%s)", err, exit)
|
|
}
|
|
} else {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
log.Printf("[ffplay] exit: %s stderr=%s", exit, msg)
|
|
} else {
|
|
log.Printf("[ffplay] exit: %s", exit)
|
|
}
|
|
}
|
|
cancel()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.cmd = nil
|
|
c.stdin = nil
|
|
c.ctx = nil
|
|
c.cancel = nil
|
|
c.path = ""
|
|
c.paused = false
|
|
if c.seekT != nil {
|
|
c.seekT.Stop()
|
|
c.seekT = nil
|
|
}
|
|
}
|
|
|
|
func (c *ffplayController) runningLocked() bool {
|
|
if c.cmd == nil || c.stdin == nil {
|
|
return false
|
|
}
|
|
if c.cmd.ProcessState != nil && c.cmd.ProcessState.Exited() {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *ffplayController) startLocked(offset float64) error {
|
|
if _, err := exec.LookPath("ffplay"); err != nil {
|
|
return fmt.Errorf("ffplay not found in PATH: %w", err)
|
|
}
|
|
|
|
if strings.TrimSpace(c.path) == "" {
|
|
return fmt.Errorf("no input path set")
|
|
}
|
|
input := c.path
|
|
|
|
c.stopLocked()
|
|
c.path = input
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
args := []string{
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
"-autoexit",
|
|
"-window_title", playerWindowTitle,
|
|
"-noborder",
|
|
}
|
|
if c.winW > 0 {
|
|
args = append(args, "-x", fmt.Sprintf("%d", c.winW))
|
|
}
|
|
if c.winH > 0 {
|
|
args = append(args, "-y", fmt.Sprintf("%d", c.winH))
|
|
}
|
|
if c.volume <= 0 {
|
|
args = append(args, "-volume", "0")
|
|
} else {
|
|
args = append(args, "-volume", fmt.Sprintf("%d", c.volume))
|
|
}
|
|
if offset > 0 {
|
|
args = append(args, "-ss", fmt.Sprintf("%.3f", offset))
|
|
}
|
|
args = append(args, input)
|
|
|
|
cmd := exec.CommandContext(ctx, "ffplay", args...)
|
|
env := os.Environ()
|
|
if c.winX != 0 || c.winY != 0 {
|
|
// SDL honors SDL_VIDEO_WINDOW_POS for initial window placement.
|
|
pos := fmt.Sprintf("%d,%d", c.winX, c.winY)
|
|
env = append(env, fmt.Sprintf("SDL_VIDEO_WINDOW_POS=%s", pos))
|
|
}
|
|
if os.Getenv("SDL_VIDEODRIVER") == "" {
|
|
env = append(env, "SDL_VIDEODRIVER=x11")
|
|
}
|
|
if os.Getenv("XDG_RUNTIME_DIR") == "" {
|
|
run := fmt.Sprintf("/run/user/%d", os.Getuid())
|
|
if fi, err := os.Stat(run); err == nil && fi.IsDir() {
|
|
env = append(env, fmt.Sprintf("XDG_RUNTIME_DIR=%s", run))
|
|
}
|
|
}
|
|
cmd.Env = env
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return fmt.Errorf("ffplay start failed: %w (%s)", err, msg)
|
|
}
|
|
cancel()
|
|
return err
|
|
}
|
|
log.Printf("[ffplay] start pid=%d args=%v pos=(%d,%d) size=%dx%d offset=%.3f vol=%d env(SDL_VIDEODRIVER=%s XDG_RUNTIME_DIR=%s DISPLAY=%s)", cmd.Process.Pid, args, c.winX, c.winY, c.winW, c.winH, offset, c.volume, os.Getenv("SDL_VIDEODRIVER"), os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("DISPLAY"))
|
|
|
|
c.cmd = cmd
|
|
c.stdin = bufio.NewWriter(stdin)
|
|
c.ctx = ctx
|
|
c.cancel = cancel
|
|
|
|
// Best-effort window placement via xdotool in case WM ignores SDL hints.
|
|
if c.winW > 0 && c.winH > 0 {
|
|
go func(title string, x, y, w, h int) {
|
|
time.Sleep(120 * time.Millisecond)
|
|
ffID := pickLastID(exec.Command("xdotool", "search", "--name", title))
|
|
mainID := pickLastID(exec.Command("xdotool", "search", "--name", "VideoTools"))
|
|
if ffID == "" {
|
|
return
|
|
}
|
|
// Reparent into main window if found, then move/size.
|
|
if mainID != "" {
|
|
_ = exec.Command("xdotool", "windowreparent", ffID, mainID).Run()
|
|
}
|
|
_ = exec.Command("xdotool", "windowmove", ffID, fmt.Sprintf("%d", x), fmt.Sprintf("%d", y)).Run()
|
|
_ = exec.Command("xdotool", "windowsize", ffID, fmt.Sprintf("%d", w), fmt.Sprintf("%d", h)).Run()
|
|
_ = exec.Command("xdotool", "windowraise", ffID).Run()
|
|
}(playerWindowTitle, c.winX, c.winY, c.winW, c.winH)
|
|
}
|
|
|
|
go c.waitForExit(cmd, cancel, &stderr)
|
|
|
|
// Reapply paused state if needed (ffplay starts unpaused).
|
|
if c.paused {
|
|
time.Sleep(20 * time.Millisecond)
|
|
_ = c.sendLocked(keyPause)
|
|
}
|
|
return nil
|
|
}
|