From fdc0b31ebcb3472758cf4b2281e016cd1fc3f494 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 9 Nov 2026 14:58:06 +0900 Subject: [PATCH 1/2] fix(player): only resync the picture when it fell behind the sound Coming back to the app reopened the picture and started the sound process again on every activation, even when the window stayed in sight and the picture had kept pace. Restarting ffplay left a gap and a small jump back in the sound, which showed as a hitch each time. Now activation compares the sound's clock with the picture's playhead and reopens only when the picture trails by 200ms or more, which happens when the window was out of sight. The reopened picture takes over the sound that kept playing instead of opening a new one, and a deactivation during the reopen is carried to the new picture. Co-Authored-By: Claude Opus 5.5 --- apps/kando/playback.go | 126 ++++++++++++++++---------------------- apps/kando/player_test.go | 55 ++++++++++++++--- 2 files changed, 100 insertions(+), 81 deletions(-) diff --git a/apps/kando/playback.go b/apps/kando/playback.go index 134230d1..8cb6a896 100644 --- a/apps/kando/playback.go +++ b/apps/kando/playback.go @@ -22,9 +22,7 @@ type filmPlay struct { path string duration time.Duration paused bool - // backgroundAt and backgroundHead remember a playing film's clock while - // the app is inactive. The sound keeps playing; on return the picture is - // reopened at the sound's expected position. + // backgroundAt and backgroundHead are the clock of a film that played as the app went inactive: the playhead then, and when. The sound plays on in the background, while the picture stops as long as the window is out of sight. backgroundAt time.Time backgroundHead time.Duration // finished is set once the picture reaches the file's end, so the clock stops there. @@ -43,21 +41,13 @@ func (a *app) ffmpegPath() string { // startPlayback opens path on the film page. A missing file or a pipeline without ffmpeg leaves the page as a subtitle list. func (a *app) startPlayback(path string) { - a.startPlaybackWith(path, nil) + a.startPlaybackWith(path, nil, nil) } -// startPlaybackWith opens a new decoder. keep leaves the current player on -// screen until the replacement is ready, which is used when returning from -// the background so the UI does not flash the loading state. -func (a *app) startPlaybackWith(path string, keep *filmPlay) { +// startPlaybackWith opens a new decoder. keep, a film whose picture fell behind its sound in the background, stays on screen with its sound playing while the new picture opens where that sound is by then (sound), and the new picture takes that sound over, so the sound never stops or starts again. +func (a *app) startPlaybackWith(path string, keep *filmPlay, sound func() time.Duration) { at := a.player.playFrom jump := a.player.playJump - resumeAt := time.Time{} - resumeHead := time.Duration(0) - if keep != nil { - resumeAt = keep.backgroundAt - resumeHead = keep.backgroundHead - } a.player.playFrom = 0 a.player.playJump = false if keep == nil { @@ -82,44 +72,36 @@ func (a *app) startPlaybackWith(path string, keep *filmPlay) { update := a.update go func() { openAt := at - if !resumeAt.IsZero() { - openAt = resumeHead + time.Since(resumeAt) + if keep != nil { + openAt = sound() } frames, err := media.OpenFramesAt(ffmpeg, path, openAt) if err != nil { update(func() { - if a.player.playGen != gen { - return - } - if keep != nil && a.player.play == keep { - keep.backgroundAt = time.Time{} - keep.backgroundHead = 0 + // A film coming back plays on as it was. + if a.player.playGen != gen || keep != nil { return } a.player.playErr = "这个视频播不了" }) return } - if !resumeAt.IsZero() { - currentAt := resumeHead + time.Since(resumeAt) - if currentAt > openAt { - if err := skipPlaybackFrames(frames, currentAt-openAt); err != nil { + var audio *pcmAudio + if keep != nil { + // The sound played on while ffmpeg opened the picture. + if now := sound(); now > openAt { + if err := skipPlaybackFrames(frames, now-openAt); err != nil { frames.Close() return } - openAt = currentAt + openAt = now } + } else { + audio, _ = openPCM(ffmpeg, path, openAt) } - audio, _ := openPCM(ffmpeg, path, openAt) update(func() { - if a.player.playGen != gen { - frames.Close() - if audio != nil { - audio.Close() - } - return - } - if keep != nil && a.player.play != keep { + // A film paused or ended while its picture opened again keeps the picture it has, as its sound no longer runs on. + if a.player.playGen != gen || keep != nil && (a.player.play != keep || keep.paused || keep.finished) { frames.Close() if audio != nil { audio.Close() @@ -133,16 +115,17 @@ func (a *app) startPlaybackWith(path string, keep *filmPlay) { if audio != nil { audio.Close() } - if keep != nil { - keep.backgroundAt = time.Time{} - keep.backgroundHead = 0 - return + if keep == nil { + a.player.playErr = "这个视频播不了" } - a.player.playErr = "这个视频播不了" return } + if keep != nil { + audio, keep.audio = keep.audio, nil + } paused := true if jump { + // Start does nothing to the sound a film coming back hands over, which plays already. if audio != nil { audio.Start() } @@ -151,14 +134,17 @@ func (a *app) startPlaybackWith(path string, keep *filmPlay) { } else { player.Pause() } - old := a.player.play - a.player.play = &filmPlay{ + next := &filmPlay{ player: player, frames: dec, audio: audio, ffmpeg: ffmpeg, path: path, duration: frames.Duration(), paused: paused, played: jump, } - if old != nil && old != a.player.play { - old.backgroundAt = time.Time{} - old.backgroundHead = 0 + if keep != nil && !keep.backgroundAt.IsZero() { + // The app went inactive again while the picture opened: the new picture is level with the sound now. + next.backgroundAt, next.backgroundHead = time.Now(), next.playhead() + } + old := a.player.play + a.player.play = next + if old != nil { if old.audio != nil { old.audio.Close() } @@ -237,9 +223,10 @@ func (a *app) stopPlayback() { play.player.Close() } -// playerDidResignActive remembers the playing film's clocks while the app is -// in the background. VideoPlayer deliberately stops advancing when its -// surface is hidden, while audio is a separate process that keeps playing. +// backgroundDrift is how far the picture may trail the sound when the app comes back before the picture opens again where the sound is. The picture of a window that stayed in sight kept pace with the sound, give or take the frames decoded ahead, and opening it again would only make it jump. +const backgroundDrift = 200 * time.Millisecond + +// playerDidResignActive notes the clock of the playing film as the app goes inactive. Its sound, a process of its own, plays on; VideoPlayer stops the picture only while the window is out of sight. func (a *app) playerDidResignActive() { p := a.player.play if p == nil || p.finished || p.paused || !p.player.Playing() { @@ -249,27 +236,32 @@ func (a *app) playerDidResignActive() { p.backgroundHead = p.playhead() } -// playerDidBecomeActive reopens the picture at the position the sound reached -// while the window was hidden, avoiding a burst of catch-up frames. +// playerDidBecomeActive opens the picture again where the sound is, when the picture stood still behind the window while the sound played on. A picture that kept pace is left alone. func (a *app) playerDidBecomeActive() { p := a.player.play if p == nil || p.backgroundAt.IsZero() { return } - at := p.backgroundResumeAt(time.Now()) - path := p.path - if at < 0 { - at = 0 - } - if p.duration > 0 && at >= p.duration { - at = max(0, p.duration-100*time.Millisecond) - } - if path == "" { + sound := p.backgroundSound() + p.backgroundAt, p.backgroundHead = time.Time{}, 0 + if p.paused || p.finished || p.path == "" || sound()-p.playhead() < backgroundDrift { return } - a.player.playFrom = at + a.player.playFrom = sound() a.player.playJump = true - a.startPlaybackWith(path, p) + a.startPlaybackWith(p.path, p, sound) +} + +// backgroundSound is where the sound of p is at any moment since the app went inactive, held short of the film's end. +func (p *filmPlay) backgroundSound() func() time.Duration { + head, since, length := p.backgroundHead, p.backgroundAt, p.duration + return func() time.Duration { + at := head + max(time.Since(since), 0) + if length > 0 && at >= length { + at = max(0, length-100*time.Millisecond) + } + return at + } } func skipPlaybackFrames(frames *media.FrameReader, d time.Duration) error { @@ -283,16 +275,6 @@ func skipPlaybackFrames(frames *media.FrameReader, d time.Duration) error { return nil } -func (p *filmPlay) backgroundResumeAt(now time.Time) time.Duration { - if p == nil { - return 0 - } - if p.backgroundAt.IsZero() || now.Before(p.backgroundAt) { - return p.backgroundHead - } - return p.backgroundHead + now.Sub(p.backgroundAt) -} - // playhead is how far the picture itself has gotten. It stays on the last frame once the file ends, instead of following the time since play was pressed. func (p *filmPlay) Widget() *ui.VideoPlayer { return p.player } diff --git a/apps/kando/player_test.go b/apps/kando/player_test.go index 7f0fa251..e25672ac 100644 --- a/apps/kando/player_test.go +++ b/apps/kando/player_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "time" @@ -250,27 +251,40 @@ func TestPlaybackStopsAtTheEnd(t *testing.T) { } } +// TestPlaybackResyncsWhenAppReturns checks that coming back to the app opens the picture again only when it stood still behind the window while the sound played on, and never for a film the user paused. func TestPlaybackResyncsWhenAppReturns(t *testing.T) { a, _, _ := newTestApp(&fakePipeline{}) - p := stillPlay(t, "", 0, true) + p := stillPlay(t, "/v/clip.mp4", 3*time.Second, true) a.player.play = p a.playerDidResignActive() - if p.backgroundAt.IsZero() || p.backgroundHead != 0 || !p.player.Playing() { + if p.backgroundAt.IsZero() || p.backgroundHead != 3*time.Second || !p.player.Playing() { t.Fatalf("inactive playback: at %v head %v playing %v", p.backgroundAt, p.backgroundHead, p.player.Playing()) } + // The window stayed in sight: the picture played on with the sound. p.backgroundAt = time.Now().Add(-2 * time.Second) - p.backgroundHead = 3 * time.Second - if got := p.backgroundResumeAt(time.Now()); got < 5*time.Second { - t.Fatalf("resume head %v, want at least 5s", got) + p.frames.shown = 5 * time.Second + a.player.opening = -1 + a.playerDidBecomeActive() + if a.player.opening != -1 || a.player.play != p || !p.backgroundAt.IsZero() { + t.Fatalf("a picture that kept pace opened again at %v, background at %v", a.player.opening, p.backgroundAt) } + + // The window was out of sight: the picture stood at 5s while the sound reached 7s. + a.playerDidResignActive() + p.backgroundAt = p.backgroundAt.Add(-2 * time.Second) a.playerDidBecomeActive() - if !p.player.Playing() || p.paused { - t.Fatalf("active playback: at %v head %v paused %v playing %v", p.backgroundAt, p.backgroundHead, p.paused, p.player.Playing()) + if a.player.opening < 7*time.Second || a.player.play != p || !p.player.Playing() || p.paused { + t.Fatalf("a picture behind its sound opened again at %v, playing %v paused %v", a.player.opening, p.player.Playing(), p.paused) } - p.backgroundAt = time.Time{} - p.backgroundHead = 0 + // The sound ended in the background: the picture opens just short of the end. + p.backgroundAt, p.backgroundHead = time.Now().Add(-time.Hour), time.Minute + if got := p.backgroundSound()(); got != p.duration-100*time.Millisecond { + t.Fatalf("sound past the end at %v, want %v", got, p.duration-100*time.Millisecond) + } + + p.backgroundAt, p.backgroundHead = time.Time{}, 0 p.player.Pause() p.paused = true a.playerDidResignActive() @@ -279,6 +293,29 @@ func TestPlaybackResyncsWhenAppReturns(t *testing.T) { } } +// TestPlaybackHandsTheSoundToTheReopenedPicture checks that the picture opened again on coming back plays on with the sound that played in the background, rather than starting the sound again. +func TestPlaybackHandsTheSoundToTheReopenedPicture(t *testing.T) { + a, _, updates := openClip(t, 12) + p := a.player.play + p.toggle() + sound := p.audio + if !p.Playing() || sound == nil && runtime.GOOS == "darwin" { + t.Fatalf("playing %v, sound %v", p.Playing(), sound) + } + a.playerDidResignActive() + // Two seconds out of sight: the picture stood still while the sound played on. + p.backgroundAt = p.backgroundAt.Add(-2 * time.Second) + a.playerDidBecomeActive() + waitUntil(t, updates, func() bool { return a.player.play != p }) + next := a.player.play + if next.audio != sound || p.audio != nil { + t.Fatalf("the sound started again: %p, was %p", next.audio, sound) + } + if !next.Playing() || next.paused || next.playhead() < 2*time.Second { + t.Fatalf("reopened picture playing %v paused %v at %v", next.Playing(), next.paused, next.playhead()) + } +} + func TestSeekJumpsIntoTheLaterPicture(t *testing.T) { ffmpeg, err := exec.LookPath("ffmpeg") if err != nil { From 9ee6648aa824040183843a977b82c06b527c417a Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 9 Nov 2026 15:16:34 +0900 Subject: [PATCH 2/2] fix(player): follow the sound behind a hidden window While the window is out of sight VideoPlayer stops the picture, but the sound plays on. Coming back reopened the picture there, so the frame the window was hidden on stayed up for the 200-400ms ffmpeg needed to seek, and then jumped. Now, while the app is inactive, a goroutine checks whether the picture has fallen behind the sound. Once it has, it opens a decoder where the sound is and keeps it on the frame the sound is in. Coming back hands that frame and decoder to a new VideoPlayer, which takes the running sound over, so the picture shows the right frame at once. Returning before the picture has fallen behind falls back to the drift check and reopen from the previous commit. The background code moves to playback_background.go, and replacing the playing film goes through playReplace, which also stops its background decoder. Co-Authored-By: Claude Opus 5.5 --- apps/kando/playback.go | 81 +++--------- apps/kando/playback_background.go | 200 ++++++++++++++++++++++++++++++ apps/kando/player_test.go | 38 ++++++ 3 files changed, 253 insertions(+), 66 deletions(-) create mode 100644 apps/kando/playback_background.go diff --git a/apps/kando/playback.go b/apps/kando/playback.go index 8cb6a896..ed6f1f94 100644 --- a/apps/kando/playback.go +++ b/apps/kando/playback.go @@ -25,6 +25,8 @@ type filmPlay struct { // backgroundAt and backgroundHead are the clock of a film that played as the app went inactive: the playhead then, and when. The sound plays on in the background, while the picture stops as long as the window is out of sight. backgroundAt time.Time backgroundHead time.Duration + // background decodes the picture level with the sound while the window is out of sight (playback_background.go); nil while the app is active. + background *backgroundPicture // finished is set once the picture reaches the file's end, so the clock stops there. finished bool // played is set once this playback has played: opened playing at a point picked for it (a seek, 继续, 从头看) or started with play. stopPlayback keeps where it stopped only then, so opening a film from its poster, which holds it on its first frame, and going back leaves where it continues from as it was. @@ -138,18 +140,7 @@ func (a *app) startPlaybackWith(path string, keep *filmPlay, sound func() time.D player: player, frames: dec, audio: audio, ffmpeg: ffmpeg, path: path, duration: frames.Duration(), paused: paused, played: jump, } - if keep != nil && !keep.backgroundAt.IsZero() { - // The app went inactive again while the picture opened: the new picture is level with the sound now. - next.backgroundAt, next.backgroundHead = time.Now(), next.playhead() - } - old := a.player.play - a.player.play = next - if old != nil { - if old.audio != nil { - old.audio.Close() - } - old.player.Close() - } + a.playReplace(next) if frames.Duration() > 0 { a.player.length = frames.Duration() } @@ -217,64 +208,13 @@ func (a *app) stopPlayback() { if play.played { a.rememberPosition(play) } + play.stopBackground() if play.audio != nil { play.audio.Close() } play.player.Close() } -// backgroundDrift is how far the picture may trail the sound when the app comes back before the picture opens again where the sound is. The picture of a window that stayed in sight kept pace with the sound, give or take the frames decoded ahead, and opening it again would only make it jump. -const backgroundDrift = 200 * time.Millisecond - -// playerDidResignActive notes the clock of the playing film as the app goes inactive. Its sound, a process of its own, plays on; VideoPlayer stops the picture only while the window is out of sight. -func (a *app) playerDidResignActive() { - p := a.player.play - if p == nil || p.finished || p.paused || !p.player.Playing() { - return - } - p.backgroundAt = time.Now() - p.backgroundHead = p.playhead() -} - -// playerDidBecomeActive opens the picture again where the sound is, when the picture stood still behind the window while the sound played on. A picture that kept pace is left alone. -func (a *app) playerDidBecomeActive() { - p := a.player.play - if p == nil || p.backgroundAt.IsZero() { - return - } - sound := p.backgroundSound() - p.backgroundAt, p.backgroundHead = time.Time{}, 0 - if p.paused || p.finished || p.path == "" || sound()-p.playhead() < backgroundDrift { - return - } - a.player.playFrom = sound() - a.player.playJump = true - a.startPlaybackWith(p.path, p, sound) -} - -// backgroundSound is where the sound of p is at any moment since the app went inactive, held short of the film's end. -func (p *filmPlay) backgroundSound() func() time.Duration { - head, since, length := p.backgroundHead, p.backgroundAt, p.duration - return func() time.Duration { - at := head + max(time.Since(since), 0) - if length > 0 && at >= length { - at = max(0, length-100*time.Millisecond) - } - return at - } -} - -func skipPlaybackFrames(frames *media.FrameReader, d time.Duration) error { - for d > 0 { - _, dur, err := frames.Next() - if err != nil { - return err - } - d -= dur - } - return nil -} - // playhead is how far the picture itself has gotten. It stays on the last frame once the file ends, instead of following the time since play was pressed. func (p *filmPlay) Widget() *ui.VideoPlayer { return p.player } @@ -372,11 +312,20 @@ func (p *filmPlay) restartAudioAt(at time.Duration) { // shown is the time of the frames decoded so far, including the one the player is about to show. type frameDecoder struct { frames *media.FrameReader - mu sync.Mutex - shown time.Duration + // held is a frame read ahead of the decoder, which Next gives first: the frame a picture decoded in the background stood on when it was handed over. + held *ui.VideoFrame + mu sync.Mutex + shown time.Duration } func (d *frameDecoder) Next() (ui.VideoFrame, error) { + if f := d.held; f != nil { + d.held = nil + d.mu.Lock() + d.shown += f.Duration + d.mu.Unlock() + return *f, nil + } img, dur, err := d.frames.Next() if err != nil { return ui.VideoFrame{}, err diff --git a/apps/kando/playback_background.go b/apps/kando/playback_background.go new file mode 100644 index 00000000..eae7b670 --- /dev/null +++ b/apps/kando/playback_background.go @@ -0,0 +1,200 @@ +package main + +import ( + "image" + "sync/atomic" + "time" + + "kando/packages/media" + + "github.com/egoist/mygo/ui" +) + +// The sound of a film is a process of its own and plays on while the app is in the background, but VideoPlayer stops the picture while the window is out of sight. This file keeps the two level: while the picture stands still behind the window, a second decoder follows the sound, and coming back hands its picture over at once instead of showing the frame the window was hidden on and then jumping. + +// backgroundDrift is how far the picture may trail the sound before it counts as standing still. The picture of a window that stayed in sight keeps pace with the sound, give or take the frames decoded ahead, and opening it again would only make it jump. +const backgroundDrift = 200 * time.Millisecond + +// backgroundPoll is how often a film in the background checks whether its picture stopped. +const backgroundPoll = 100 * time.Millisecond + +// backgroundPicture is the decoder that follows the sound of a film in the background (followSound). +type backgroundPicture struct { + // quit drops it; take, when the app came back, has it hand its picture over. + quit chan struct{} + take chan struct{} + // opened is set once its picture stopped and it began decoding where the sound is. Before that the app coming back drops it, and opens the picture again itself if the picture fell behind meanwhile. + opened atomic.Bool +} + +// playerDidResignActive notes the clock of the playing film as the app goes inactive and starts following its sound. +func (a *app) playerDidResignActive() { + p := a.player.play + if p == nil || p.finished || p.paused || !p.player.Playing() { + return + } + p.stopBackground() + p.backgroundAt = time.Now() + p.backgroundHead = p.playhead() + p.background = a.followSound(p) +} + +// playerDidBecomeActive puts the picture where the sound is, when it stood still behind the window while the sound played on: the picture decoded in the background when there is one, or else the picture opened again from there. A picture that kept pace is left alone. +func (a *app) playerDidBecomeActive() { + p := a.player.play + if p == nil || p.backgroundAt.IsZero() { + return + } + sound := p.backgroundSound() + bg := p.background + p.backgroundAt, p.backgroundHead, p.background = time.Time{}, 0, nil + if bg != nil && bg.opened.Load() { + close(bg.take) + return + } + if bg != nil { + close(bg.quit) + } + if p.paused || p.finished || p.path == "" || sound()-p.playhead() < backgroundDrift { + return + } + a.player.playFrom = sound() + a.player.playJump = true + a.startPlaybackWith(p.path, p, sound) +} + +// stopBackground drops the decoder following the sound of p, if any. +func (p *filmPlay) stopBackground() { + if p.background != nil { + close(p.background.quit) + p.background = nil + } +} + +// backgroundSound is where the sound of p is at any moment since the app went inactive, held short of the film's end. +func (p *filmPlay) backgroundSound() func() time.Duration { + head, since, length := p.backgroundHead, p.backgroundAt, p.duration + return func() time.Duration { + at := head + max(time.Since(since), 0) + if length > 0 && at >= length { + at = max(0, length-100*time.Millisecond) + } + return at + } +} + +// followSound watches, off the UI thread, whether the picture of p falls behind its sound, which happens only while the window is out of sight. Once it does, it opens a decoder where the sound is and keeps it on the frame the sound is in, until the app comes back (take) and the picture goes to the UI thread, or p is dropped (quit). A film without ffmpeg has nothing to follow. +func (a *app) followSound(p *filmPlay) *backgroundPicture { + if p.ffmpeg == "" || p.path == "" || p.frames == nil || a.update == nil { + return nil + } + bg := &backgroundPicture{quit: make(chan struct{}), take: make(chan struct{})} + sound := p.backgroundSound() + shown, ffmpeg, path, update, gen := p.frames, p.ffmpeg, p.path, a.update, a.player.playGen + go func() { + tick := time.NewTicker(backgroundPoll) + for sound()-shown.position() < backgroundDrift { + select { + case <-bg.quit: + tick.Stop() + return + case <-bg.take: + tick.Stop() + return + case <-tick.C: + } + } + tick.Stop() + bg.opened.Store(true) + at := sound() + frames, err := media.OpenFramesAt(ffmpeg, path, at) + if err != nil { + return + } + img, dur, err := frames.Next() + if err != nil { + frames.Close() + return + } + ended := false + wait := time.NewTimer(0) + defer wait.Stop() + for { + // Move on to the frame the sound is in. The reader draws every frame into the same image, so the frame held is read last. + for !ended && at+dur <= sound() { + next, d, err := frames.Next() + if err != nil { + ended = true + break + } + at, img, dur = at+dur, next, d + } + due := time.Hour + if !ended { + due = at + dur - sound() + } + wait.Reset(due) + select { + case <-bg.quit: + frames.Close() + return + case <-bg.take: + update(func() { a.takeBackgroundPicture(p, gen, frames, img, at, dur) }) + return + case <-wait.C: + } + } + }() + return bg +} + +// takeBackgroundPicture shows, in place of p's picture that stood still, the picture decoded in the background: frames, standing on img, which began at at and lasts dur. The new picture takes p's sound over. +func (a *app) takeBackgroundPicture(p *filmPlay, gen int, frames *media.FrameReader, img image.Image, at, dur time.Duration) { + if a.player.playGen != gen || a.player.play != p || p.paused || p.finished { + frames.Close() + return + } + dec := &frameDecoder{frames: frames, shown: at, held: &ui.VideoFrame{Image: img, Duration: dur}} + player, err := ui.NewVideoPlayer(dec) + if err != nil { + frames.Close() + return + } + player.Play() + audio := p.audio + p.audio = nil + a.playReplace(&filmPlay{ + player: player, frames: dec, audio: audio, ffmpeg: p.ffmpeg, path: p.path, + duration: p.duration, played: true, + }) +} + +// playReplace puts next in place of the film playing and closes that one, but for the sound next took over from it. When the app went inactive again while next opened, next follows its sound from here, level with it now. +func (a *app) playReplace(next *filmPlay) { + old := a.player.play + a.player.play = next + if old == nil { + return + } + if !old.backgroundAt.IsZero() && next.Playing() { + next.backgroundAt, next.backgroundHead = time.Now(), next.playhead() + next.background = a.followSound(next) + } + old.stopBackground() + if old.audio != nil { + old.audio.Close() + } + old.player.Close() +} + +// skipPlaybackFrames reads frames for d, so the reader stands on where the sound got to while it opened. +func skipPlaybackFrames(frames *media.FrameReader, d time.Duration) error { + for d > 0 { + _, dur, err := frames.Next() + if err != nil { + return err + } + d -= dur + } + return nil +} diff --git a/apps/kando/player_test.go b/apps/kando/player_test.go index e25672ac..bbe7373a 100644 --- a/apps/kando/player_test.go +++ b/apps/kando/player_test.go @@ -316,6 +316,44 @@ func TestPlaybackHandsTheSoundToTheReopenedPicture(t *testing.T) { } } +// TestPlaybackFollowsTheSoundBehindTheWindow checks that a picture that stands still in the background (the Tester builds no frames, as a window out of sight) is followed by a decoder of its own, whose picture coming back shows at once, where the sound is, with the same sound and without opening the film again. +func TestPlaybackFollowsTheSoundBehindTheWindow(t *testing.T) { + a, _, updates := openClip(t, 12) + p := a.player.play + p.toggle() + sound := p.audio + a.playerDidResignActive() + bg := p.background + if bg == nil { + t.Fatal("no decoder follows the sound") + } + deadline := time.Now().Add(5 * time.Second) + for !bg.opened.Load() { + if time.Now().After(deadline) { + t.Fatal("the picture standing still was not noticed") + } + time.Sleep(10 * time.Millisecond) + } + time.Sleep(time.Second) + gen := a.player.playGen + want := p.backgroundSound()() + a.playerDidBecomeActive() + waitUntil(t, updates, func() bool { return a.player.play != p }) + next := a.player.play + if a.player.playGen != gen { + t.Fatal("the film opened again instead of taking the picture decoded in the background") + } + if next.audio != sound || p.audio != nil { + t.Fatalf("the sound started again: %p, was %p", next.audio, sound) + } + if at := next.playhead(); !next.Playing() || at < want-200*time.Millisecond || at > want+300*time.Millisecond { + t.Fatalf("picture playing %v at %v, sound at %v", next.Playing(), at, want) + } + if p.background != nil || next.background != nil { + t.Fatal("a decoder still follows the sound after coming back") + } +} + func TestSeekJumpsIntoTheLaterPicture(t *testing.T) { ffmpeg, err := exec.LookPath("ffmpeg") if err != nil {