Repository: kivy/kivy

Issue:
Video lag between loop when eos=loop is used
If you instanciate a Video using eos to loop, you can see it lagging every restart, using ffpyplayer video core.

```python
video = Video(
    state="play",
    options={"eos": "loop"},
    source=filename)
```

This is due to the sleep(0.2) here: https://github.com/kivy/kivy/blob/master/kivy/core/video/video_ffpyplayer.py#L303


Discussion:

Resolving patch:
diff --git a/kivy/core/video/video_ffpyplayer.py b/kivy/core/video/video_ffpyplayer.py
--- a/kivy/core/video/video_ffpyplayer.py
+++ b/kivy/core/video/video_ffpyplayer.py
@@ -54,6 +54,7 @@
 
 
 from threading import Thread
+from queue import Queue, Empty, Full
 from kivy.clock import Clock, mainthread
 from kivy.logger import Logger
 from kivy.core.video import VideoBase
@@ -109,6 +110,7 @@ def __init__(self, **kwargs):
         self._next_frame = None
         self._seek_queue = []
         self._ffplayer_need_quit = False
+        self._wakeup_queue = Queue(maxsize=1)
         self._trigger = Clock.create_trigger(self._redraw)
 
         super(VideoFFPy, self).__init__(**kwargs)
@@ -116,6 +118,18 @@ def __init__(self, **kwargs):
     def __del__(self):
         self.unload()
 
+    def _wakeup_thread(self):
+        try:
+            self._wakeup_queue.put(None, False)
+        except Full:
+            pass
+
+    def _wait_for_wakeup(self, timeout):
+        try:
+            self._wakeup_queue.get(True, timeout)
+        except Empty:
+            pass
+
     def _player_callback(self, selector, value):
         if self._ffplayer is None:
             return
@@ -134,7 +148,7 @@ def _set_position(self, pos):
 
     def _set_volume(self, volume):
         self._volume = volume
-        if self._ffplayer:
+        if self._ffplayer is not None:
             self._ffplayer.set_volume(self._volume)
 
     def _get_duration(self):
@@ -149,13 +163,21 @@ def _do_eos(self):
         elif self.eos == 'stop':
             self.stop()
         elif self.eos == 'loop':
+            # this causes a seek to zero
             self.position = 0
 
         self.dispatch('on_eos')
 
     @mainthread
-    def _change_state(self, state):
-        self._state = state
+    def _finish_setup(self):
+        # once setup is done, we make sure player state matches what user
+        # could have requested while player was being setup and it was in limbo
+        # also, thread starts player in internal paused mode, so this unpauses
+        # it if user didn't request pause meanwhile
+        if self._ffplayer is not None:
+            self._ffplayer.set_volume(self._volume)
+            self._ffplayer.set_pause(self._state == 'paused')
+            self._wakeup_thread()
 
     def _redraw(self, *args):
         if not self._ffplayer:
@@ -211,46 +233,47 @@ def _redraw(self, *args):
 
             self.dispatch('on_frame')
 
-    def _next_frame_run(self):
-        ffplayer = self._ffplayer
+    def _next_frame_run(self, ffplayer):
         sleep = time.sleep
         trigger = self._trigger
         did_dispatch_eof = False
+        wait_for_wakeup = self._wait_for_wakeup
         seek_queue = self._seek_queue
+        # video starts in internal paused state
 
         # fast path, if the source video is yuv420p, we'll use a glsl shader
         # for buffer conversion to rgba
+        # wait until we get frame metadata
         while not self._ffplayer_need_quit:
             src_pix_fmt = ffplayer.get_metadata().get('src_pix_fmt')
             if not src_pix_fmt:
-                sleep(0.005)
+                wait_for_wakeup(0.005)
                 continue
 
             if src_pix_fmt == 'yuv420p':
                 self._out_fmt = 'yuv420p'
                 ffplayer.set_output_pix_fmt(self._out_fmt)
-            self._ffplayer.toggle_pause()
             break
 
         if self._ffplayer_need_quit:
+            ffplayer.close_player()
             return
 
         # wait until loaded or failed, shouldn't take long, but just to make
         # sure metadata is available.
-        s = time.perf_counter()
         while not self._ffplayer_need_quit:
             if ffplayer.get_metadata()['src_vid_size'] != (0, 0):
                 break
-            # XXX if will fail later then?
-            if time.perf_counter() - s > 10.:
-                break
-            sleep(0.005)
+            wait_for_wakeup(0.005)
 
         if self._ffplayer_need_quit:
+            ffplayer.close_player()
             return
 
-        # we got all the information, now, get the frames :)
-        self._change_state('playing')
+        self._ffplayer = ffplayer
+        self._finish_setup()
+        # now, we'll be in internal paused state and loop will wait until
+        # mainthread unpauses us when finishing setup
 
         while not self._ffplayer_need_quit:
             seek_happened = False
@@ -264,6 +287,7 @@ def _next_frame_run(self):
                     accurate=precise
                 )
                 seek_happened = True
+                did_dispatch_eof = False
                 self._next_frame = None
 
             # Get next frame if paused:
@@ -294,19 +318,21 @@ def _next_frame_run(self):
                     frame, val = ffplayer.get_frame(force_refresh=True)
                 finally:
                     ffplayer.set_pause(bool(self._state == 'paused'))
+                    # todo: this is not safe because user could have updated
+                    # volume between us reading it and setting it
                     ffplayer.set_volume(self._volume)
             # Get next frame regular:
             else:
                 frame, val = ffplayer.get_frame()
 
             if val == 'eof':
-                sleep(0.2)
                 if not did_dispatch_eof:
                     self._do_eos()
                     did_dispatch_eof = True
+                wait_for_wakeup(None)
             elif val == 'paused':
                 did_dispatch_eof = False
-                sleep(0.2)
+                wait_for_wakeup(None)
             else:
                 did_dispatch_eof = False
                 if frame:
@@ -314,60 +340,107 @@ def _next_frame_run(self):
                     trigger()
                 else:
                     val = val if val else (1 / 30.)
-                sleep(val)
+                wait_for_wakeup(val)
+
+        ffplayer.close_player()
 
     def seek(self, percent, precise=True):
-        if self._ffplayer is None:
-            return
+        # still save seek while thread is setting up
         self._seek_queue.append((percent, precise,))
+        self._wakeup_thread()
 
     def stop(self):
         self.unload()
 
     def pause(self):
-        if self._ffplayer and self._state != 'paused':
-            self._ffplayer.toggle_pause()
+        # if state hasn't been set (empty), there's no player. If it's
+        # paused, nothing to do so just handle playing
+        if self._state == 'playing':
+            # we could be in limbo while player is setting up so check. Player
+            # will pause when finishing setting up
+            if self._ffplayer is not None:
+                self._ffplayer.set_pause(True)
+            # even in limbo, indicate to start in paused state
             self._state = 'paused'
+            self._wakeup_thread()
 
     def play(self):
-        if self._ffplayer and self._state == 'paused':
-            self._ffplayer.toggle_pause()
+        # _state starts empty and is empty again after unloading
+        if self._ffplayer:
+            # player is already setup, just handle unpausing
+            assert self._state in ('paused', 'playing')
+            if self._state == 'paused':
+                self._ffplayer.set_pause(False)
+                self._state = 'playing'
+                self._wakeup_thread()
+            return
+
+        # we're now either in limbo state waiting for thread to setup,
+        # or no thread has been started
+        if self._state == 'playing':
+            # in limbo, just wait for thread to setup player
+            return
+        elif self._state == 'paused':
+            # in limbo, still unpause for when player becomes ready
             self._state = 'playing'
+            self._wakeup_thread()
             return
 
+        # load first unloads
         self.load()
         self._out_fmt = 'rgba'
+        # it starts internally paused, but unpauses itself
         ff_opts = {
             'paused': True,
             'out_fmt': self._out_fmt,
             'sn': True,
             'volume': self._volume,
         }
-        self._ffplayer = MediaPlayer(
-                self._filename, callback=self._player_callback,
-                thread_lib='SDL',
-                loglevel='info', ff_opts=ff_opts)
+        ffplayer = MediaPlayer(
+            self._filename, callback=self._player_callback,
+            thread_lib='SDL',
+            loglevel='info', ff_opts=ff_opts
+        )
 
         # Disabled as an attempt to fix kivy issue #6210
         # self._ffplayer.set_volume(self._volume)
 
-        self._thread = Thread(target=self._next_frame_run, name='Next frame')
+        self._thread = Thread(
+            target=self._next_frame_run,
+            name='Next frame',
+            args=(ffplayer, )
+        )
+        # todo: remove
         self._thread.daemon = True
+
+        # start in playing mode, but _ffplayer isn't set until ready. We're
+        # now in a limbo state
+        self._state = 'playing'
         self._thread.start()
 
     def load(self):
         self.unload()
 
     def unload(self):
-        if self._trigger is not None:
-            self._trigger.cancel()
+        # no need to call self._trigger.cancel() because _ffplayer is set
+        # to None below, and it's not safe to call clock stuff from __del__
+
+        # if thread is still alive, set it to exit and wake it
+        self._wakeup_thread()
         self._ffplayer_need_quit = True
+        # wait until it exits
         if self._thread:
+            # TODO: use callback, don't block here
             self._thread.join()
             self._thread = None
+
         if self._ffplayer:
             self._ffplayer = None
         self._next_frame = None
         self._size = (0, 0)
         self._state = ''
+        self._seek_queue = []
+
+        # reset for next load since thread is dead for sure
         self._ffplayer_need_quit = False
+        self._wakeup_queue = Queue(maxsize=1)


Test patch: