diff --git a/go.mod b/go.mod index acfa34c..33d270b 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,12 @@ go 1.25.1 require ( fyne.io/fyne/v2 v2.7.1 - github.com/u2takey/ffmpeg-go v0.5.0 + github.com/hajimehoshi/oto v0.7.1 ) require ( fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect github.com/BurntSushi/toml v1.5.0 // indirect - github.com/aws/aws-sdk-go v1.38.20 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fredbi/uri v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -26,7 +25,6 @@ require ( github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kr/text v0.2.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect @@ -36,9 +34,10 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/u2takey/go-utils v0.3.1 // indirect github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect golang.org/x/image v0.24.0 // indirect + golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum index b09d53a..67cedb2 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQb github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= +github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= @@ -65,12 +67,21 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -78,57 +89,3 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA= -github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= -github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPizyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU= -github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc= -github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys= -github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs= -gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/main.go b/main.go index 46aaa28..d290c7e 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,10 @@ import ( "encoding/json" "flag" "fmt" + "image" "image/color" "image/png" + "io" "log" "math" "os" @@ -16,6 +18,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "fyne.io/fyne/v2" @@ -28,6 +31,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "git.leaktechnologies.dev/stu/VideoTools/internal/player" + "github.com/hajimehoshi/oto" ) // Module describes a high level tool surface that gets a tile on the menu. @@ -129,6 +133,7 @@ type appState struct { playerMuted bool lastVolume float64 playerPaused bool + playSess *playSession } func (s *appState) stopPreview() { @@ -212,6 +217,10 @@ func (s *appState) shutdown() { } func (s *appState) stopPlayer() { + if s.playSess != nil { + s.playSess.Stop() + s.playSess = nil + } if s.player != nil { s.player.Stop() } @@ -870,18 +879,43 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu dlg.Show() }) - usePlayer := state.playerReady && state.player != nil + usePlayer := true currentTime := widget.NewLabel("0:00") totalTime := widget.NewLabel(src.DurationString()) totalTime.Alignment = fyne.TextAlignTrailing + var updatingProgress bool + slider := widget.NewSlider(0, math.Max(1, src.Duration)) + slider.Step = 0.5 + updateProgress := func(val float64) { + updatingProgress = true + currentTime.SetText(formatClock(val)) + slider.SetValue(val) + updatingProgress = false + } + if state.playSess != nil { + state.playSess.prog = updateProgress + } + if state.playSess == nil { + state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, int(targetWidth-28), int(targetHeight-40), updateProgress, img) + } var controls fyne.CanvasObject if usePlayer { - var updatingVolume bool - var slider *widget.Slider var volIcon *widget.Button - + var updatingVolume bool + seek := func(val float64) { + if state.playSess != nil { + state.playSess.Seek(val) + } + } + slider.OnChanged = func(val float64) { + if updatingProgress { + return + } + updateProgress(val) + seek(val) + } updateVolIcon := func() { if volIcon == nil { return @@ -892,19 +926,8 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu volIcon.SetText("🔊") } } - - slider = widget.NewSlider(0, math.Max(1, src.Duration)) - slider.Step = 0.5 - slider.OnChanged = func(val float64) { - currentTime.SetText(formatClock(val)) - if state.player != nil && state.playerReady { - if err := state.player.Seek(val); err != nil { - debugLog(logCatFFMPEG, "player seek failed: %v", err) - } - } - } volIcon = makeIconButton("🔊", "Mute/Unmute", func() { - if state.player == nil { + if state.playSess == nil { return } if state.playerMuted { @@ -914,18 +937,16 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu } state.playerVolume = target state.playerMuted = false - updatingVolume = true - slider.SetValue(target) - updatingVolume = false - _ = state.player.SetVolume(target) + if state.playSess != nil { + state.playSess.SetVolume(target) + } } else { state.lastVolume = state.playerVolume state.playerVolume = 0 state.playerMuted = true - updatingVolume = true - slider.SetValue(0) - updatingVolume = false - _ = state.player.SetVolume(0) + if state.playSess != nil { + state.playSess.SetVolume(0) + } } updateVolIcon() }) @@ -943,39 +964,28 @@ func buildVideoPane(state *appState, min fyne.Size, src *videoSource, onCover fu } else { state.playerMuted = true } - if state.player != nil && state.playerReady { - if err := state.player.SetVolume(val); err != nil { - debugLog(logCatFFMPEG, "player volume failed: %v", err) - } + if state.playSess != nil { + state.playSess.SetVolume(val) } updateVolIcon() } updateVolIcon() volSlider.Refresh() playBtn := makeIconButton("▶/⏸", "Play/Pause", func() { - if state.player == nil { - return + if state.playSess == nil { + state.playSess = newPlaySession(src.Path, src.Width, src.Height, src.FrameRate, int(targetWidth-28), int(targetHeight-40), updateProgress, img) + state.playSess.SetVolume(state.playerVolume) } if state.playerPaused { - if err := state.player.Play(); err != nil { - debugLog(logCatFFMPEG, "player play failed: %v", err) - return - } + state.playSess.Play() state.playerPaused = false } else { - if err := state.player.Pause(); err != nil { - debugLog(logCatFFMPEG, "player pause failed: %v", err) - return - } + state.playSess.Pause() state.playerPaused = true } }) fullBtn := makeIconButton("⛶", "Toggle fullscreen", func() { - if state.player != nil { - if err := state.player.FullScreen(); err != nil { - debugLog(logCatFFMPEG, "player fullscreen failed: %v", err) - } - } + // Placeholder: embed fullscreen toggle into playback surface later. }) volBox := container.NewHBox(volIcon, container.NewMax(volSlider)) progress := container.NewBorder(nil, nil, currentTime, totalTime, container.NewMax(slider)) @@ -1049,6 +1059,260 @@ func moduleColor(id string) color.Color { return queueColor } +type playSession struct { + path string + fps float64 + width int + height int + targetW int + targetH int + volume float64 + muted bool + paused bool + current float64 + stop chan struct{} + done chan struct{} + prog func(float64) + img *canvas.Image + mu sync.Mutex + videoCmd *exec.Cmd + audioCmd *exec.Cmd + audioCtx *oto.Context + audioPlay io.Closer +} + +func newPlaySession(path string, w, h int, fps float64, targetW, targetH int, prog func(float64), img *canvas.Image) *playSession { + if fps <= 0 { + fps = 24 + } + if targetW <= 0 { + targetW = 640 + } + if targetH <= 0 { + targetH = int(float64(targetW) * (float64(h) / float64(maxInt(w, 1)))) + } + return &playSession{ + path: path, + fps: fps, + width: w, + height: h, + targetW: targetW, + targetH: targetH, + volume: 100, + stop: make(chan struct{}), + done: make(chan struct{}), + prog: prog, + img: img, + } +} + +func (p *playSession) Play() { + p.mu.Lock() + defer p.mu.Unlock() + if p.videoCmd == nil && p.audioCmd == nil { + p.startLocked(p.current) + return + } + p.paused = false +} + +func (p *playSession) Pause() { + p.mu.Lock() + defer p.mu.Unlock() + p.paused = true +} + +func (p *playSession) Seek(offset float64) { + p.mu.Lock() + defer p.mu.Unlock() + p.current = offset + p.stopLocked() + p.startLocked(offset) +} + +func (p *playSession) SetVolume(v float64) { + p.mu.Lock() + defer p.mu.Unlock() + if v < 0 { + v = 0 + } + if v > 100 { + v = 100 + } + p.volume = v + if v > 0 { + p.muted = false + } else { + p.muted = true + } +} + +func (p *playSession) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + p.stopLocked() +} + +func (p *playSession) stopLocked() { + select { + case <-p.stop: + default: + close(p.stop) + } + if p.videoCmd != nil && p.videoCmd.Process != nil { + _ = p.videoCmd.Process.Kill() + } + if p.audioCmd != nil && p.audioCmd.Process != nil { + _ = p.audioCmd.Process.Kill() + } + if p.audioPlay != nil { + p.audioPlay.Close() + } + if p.audioCtx != nil { + p.audioCtx.Close() + } + p.videoCmd = nil + p.audioCmd = nil + p.audioPlay = nil + p.audioCtx = nil + p.stop = make(chan struct{}) + p.done = make(chan struct{}) +} + +func (p *playSession) startLocked(offset float64) { + p.paused = false + p.current = offset + p.runVideo(offset) + p.runAudio(offset) +} + +func (p *playSession) runVideo(offset float64) { + cmd := exec.Command("ffmpeg", + "-hide_banner", "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", offset), + "-i", p.path, + "-vf", fmt.Sprintf("scale=%d:%d", p.targetW, p.targetH), + "-f", "rawvideo", + "-pix_fmt", "rgb24", + "-r", fmt.Sprintf("%.3f", p.fps), + "-", + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + if err := cmd.Start(); err != nil { + return + } + p.videoCmd = cmd + frameSize := p.targetW * p.targetH * 3 + buf := make([]byte, frameSize) + go func() { + defer cmd.Process.Kill() + for { + select { + case <-p.stop: + return + default: + } + if p.paused { + time.Sleep(30 * time.Millisecond) + continue + } + _, err := io.ReadFull(stdout, buf) + if err != nil { + return + } + frame := image.NewNRGBA(image.Rect(0, 0, p.targetW, p.targetH)) + copy(frame.Pix, buf) + fyne.CurrentApp().Driver().DoFromGoroutine(func() { + if p.img != nil { + p.img.Image = frame + p.img.Refresh() + } + }, false) + p.current += 1.0 / p.fps + if p.prog != nil { + p.prog(p.current) + } + } + }() +} + +func (p *playSession) runAudio(offset float64) { + const sampleRate = 48000 + const channels = 2 + const bytesPerSample = 2 + cmd := exec.Command("ffmpeg", + "-hide_banner", "-loglevel", "error", + "-ss", fmt.Sprintf("%.3f", offset), + "-i", p.path, + "-vn", + "-ac", fmt.Sprintf("%d", channels), + "-ar", fmt.Sprintf("%d", sampleRate), + "-f", "s16le", + "-", + ) + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + if err := cmd.Start(); err != nil { + return + } + p.audioCmd = cmd + ctx, err := oto.NewContext(sampleRate, channels, bytesPerSample, 2048) + if err != nil { + return + } + player := ctx.NewPlayer() + p.audioCtx = ctx + p.audioPlay = player + go func() { + defer cmd.Process.Kill() + defer player.Close() + defer ctx.Close() + chunk := make([]byte, 4096) + for { + select { + case <-p.stop: + return + default: + } + if p.paused { + time.Sleep(30 * time.Millisecond) + continue + } + n, err := stdout.Read(chunk) + if n > 0 { + gain := p.volume / 100.0 + if p.muted || gain <= 0 { + for i := 0; i < n; i++ { + chunk[i] = 0 + } + } else if gain < 0.999 || gain > 1.001 { + for i := 0; i+1 < n; i += 2 { + sample := int16(chunk[i]) | int16(chunk[i+1])<<8 + amp := int(float64(sample) * gain) + if amp > math.MaxInt16 { + amp = math.MaxInt16 + } + if amp < math.MinInt16 { + amp = math.MinInt16 + } + chunk[i] = byte(amp) + chunk[i+1] = byte(amp >> 8) + } + } + player.Write(chunk[:n]) + } + if err != nil { + return + } + } + }() +} + type previewAnimator struct { frames []string img *canvas.Image @@ -1184,6 +1448,10 @@ func (s *appState) handleDrop(items []fyne.URI) { func (s *appState) loadVideo(path string) { win := s.window + if s.playSess != nil { + s.playSess.Stop() + s.playSess = nil + } src, err := probeVideo(path) if err != nil { debugLog(logCatFFMPEG, "ffprobe failed for %s: %v", path, err) @@ -1515,6 +1783,13 @@ func channelLabel(ch int) string { } } +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + func makeIconButton(symbol, tooltip string, tapped func()) *widget.Button { btn := widget.NewButton(symbol, tapped) btn.Importance = widget.LowImportance