Changeset - 1082f0725c32
[Not reviewed]
default
0 1 0
drewp@bigasterisk.com - 8 months ago 2024-05-28 22:34:03
drewp@bigasterisk.com
fix PlayerState semantics
1 file changed with 6 insertions and 4 deletions:
0 comments (0 inline, 0 general)
src/light9/ascoltami/player.py
Show inline comments
 
"""
 
alternate to the mpd music player, for ascoltami.
 

	
 
This module doesn't know URIRef or graph. It does use 
 
GstFileUri (str) for filesystem paths.
 
"""
 

	
 
import asyncio
 
import logging
 
import time
 
import traceback
 
from dataclasses import dataclass
 
from typing import NewType
 

	
 
from light9.ascoltami.import_gst import Gst  # type: ignore
 
from light9.metrics import metrics
 

	
 
log = logging.getLogger()
 

	
 
GstFileUri = NewType('GstFileUri', str)
 

	
 

	
 
@dataclass
 
class PlayerState:
 
    song: GstFileUri | None = None
 
    duration: float = 0
 
    playing: bool = False  # time is advancing, and song time is now()-wallStartTime
 
    # wall time of song-time 0 (this is adjusted when you seek)
 
    wallStartTime: float = 0
 
    pausedSongTime: float | None = None  # if we're paused, this has the song time
 
    endOfSong: bool = False  # True if we're in the stopped state due to EOS
 

	
 

	
 
class Player:
 

	
 
    def __init__(self, autoStopOffset=4, onStateChange=lambda ps: None):
 
        """autoStopOffset is the number of seconds before the end of
 
        song before automatically stopping (which is really pausing).
 
        """
 
        self.autoStopOffset = autoStopOffset
 
        self.onStateChange = onStateChange
 
        self._playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
 

	
 
        self.playerState: PlayerState = PlayerState()
 

	
 
        self._lastWatchTime = 0
 
        self._autoStopTime = 0
 
        self._lastSetSongUri: GstFileUri | None = None
 

	
 
        # task.LoopingCall(self._watchTime).start(.050)
 
        asyncio.create_task(self._watchTime())
 

	
 
        #bus = self.pipeline.get_bus()
 
        # not working- see notes in pollForMessages
 
        #self._watchForMessages(bus)
 

	
 
    async def _watchTime(self):
 
        while True:
 
            now = time.time()
 
            try:
 
                self._pollForMessages()
 
                t = self.currentTime()
 
                # log.debug("watch %s < %s < %s", self._lastWatchTime, self._autoStopTime, t)
 
                if self._lastWatchTime < self._autoStopTime < t:
 
                    log.info("autostop")
 
                    self.pause()
 

	
 
                eos = t >= self.duration() - .1  #todo
 
                playing = self.isPlaying() and not eos
 
                ps = PlayerState(
 
                    song=self._getSongFileUri(),
 
                    duration=round(self.duration(), 2),
 
                    wallStartTime=round(now - t, 2) if self.isPlaying() else None,
 
                    playing=self.isPlaying(),
 
                    pausedSongTime=None if self.isPlaying() else t,
 
                    endOfSong=t >= self.duration() - .1,  #todo
 
                    wallStartTime=round(now - t, 2) if playing else None,
 
                    playing=playing,
 
                    pausedSongTime=None if playing else t,
 
                    endOfSong=eos,
 
                )
 

	
 
                if self.playerState != ps:
 
                    self.onStateChange(ps)
 
                    self.playerState = ps
 

	
 
                self._lastWatchTime = t
 
            except Exception:
 
                traceback.print_exc()
 
            await asyncio.sleep(0.5)
 

	
 
    def _watchForMessages(self, bus):
 
        """this would be nicer than pollForMessages but it's not working for
 
        me. It's like add_signal_watch isn't running."""
 
        bus.add_signal_watch()
 

	
 
        def onEos(*args):
 
            print("onEos", args)
 
            if self._onEOS is not None:
 
                self._onEOS(self._getSongFileUri())
 

	
 
        bus.connect('message::eos', onEos)
 

	
 
        def onStreamStatus(bus, message):
 
            print("streamstatus", bus, message)
 
            (statusType, _elem) = message.parse_stream_status()
 
            if statusType == Gst.StreamStatusType.ENTER:
 
                self.setupAutostop()
 

	
 
        bus.connect('message::stream-status', onStreamStatus)
 

	
 
    def _pollForMessages(self):
 
        """bus.add_signal_watch seems to be having no effect, but this works"""
 
        bus = self.pipeline.get_bus()
 
        mt = Gst.MessageType
 
        msg = bus.poll(
 
            mt.EOS | mt.STREAM_STATUS | mt.ERROR,  # | mt.ANY,
 
            0)
 
        if msg is not None:
 
            log.debug("bus message: %r %r", msg.src, msg.type)
 
            # i'm trying to catch here a case where the pulseaudio
 
            # output has an error, since that's otherwise kind of
 
            # mysterious to diagnose. I don't think this is exactly
 
            # working.
 
            if msg.type == mt.ERROR:
 
                log.error(repr(msg.parse_error()))
 
            if msg.type == mt.EOS:
 
                log.info("EOS msg: todo")
 
            if msg.type == mt.STREAM_STATUS:
 
                (statusType, _elem) = msg.parse_stream_status()
 
                if statusType == Gst.StreamStatusType.ENTER:
 
                    self.setupAutostop()
 

	
 
    def seek(self, t):
 
        isSeekable = self._playbin.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE | Gst.SeekFlags.SKIP, t * Gst.SECOND)
 
        if not isSeekable:
 
            raise ValueError('seek_simple failed')
 
        self._playStartTime = time.time()
 

	
 
    def setSong(self, songLoc: GstFileUri, play=True):
 
        """
 
        uri like file:///my/proj/light9/show/dance2010/music/07.wav
 
        """
 
        log.info("set song to %r" % songLoc)
 
        self.pipeline.set_state(Gst.State.READY)
 
        self._preload(songLoc)
 
        self.pipeline.set_property("uri", songLoc)
 
        self._lastSetSongUri = songLoc
 
        # todo: don't have any error report yet if the uri can't be read
 
        if play:
 
            self.pipeline.set_state(Gst.State.PLAYING)
 
            self._playStartTime = time.time()
 

	
 
    def _getSongFileUri(self) -> GstFileUri | None:
 
        """Returns the file URI of the current song."""
 
        # even the 'uri' that I just set isn't readable yet
 
        return GstFileUri(self._playbin.get_property("uri")) or self._lastSetSongUri
 

	
 
    def getSong(self) -> GstFileUri | None:
 
        return self.playerState.song
 

	
 
    def _preload(self, songPath):
 
        """
 
        to avoid disk seek stutters, which happened sometimes (in 2007) with the
 
        non-gst version of this program, we read the whole file to get
 
        more OS caching.
 

	
 
        i don't care that it's blocking.
 
        """
 
        log.info("preloading %s", songPath)
 
        assert songPath.startswith("file://"), songPath
 
        try:
 
            open(songPath[len("file://"):], 'rb').read()
 
        except IOError as e:
 
            log.error("couldn't preload %s, %r", songPath, e)
 
            raise
 

	
 
    @metrics('current_time').time()
 
    def currentTime(self):
 
        success, cur = self._playbin.query_position(Gst.Format.TIME)
 
        if not success:
 
            return 0
 
        return cur / Gst.SECOND
 

	
 
    def duration(self):
 
        success, dur = self._playbin.query_duration(Gst.Format.TIME)
 
        if not success:
 
            return 0
 
        return dur / Gst.SECOND
 

	
 
    def states(self):
 
        """json-friendly object describing the interesting states of
 
        the player nodes"""
 
        success, state, pending = self._playbin.get_state(timeout=0)
 
        return {"current": {"name": state.value_nick}, "pending": {"name": state.value_nick}}
 

	
 
    def pause(self):
 
        self.pipeline.set_state(Gst.State.PAUSED)
 

	
 
    def isAutostopped(self):
 
        """
 
        are we stopped at the autostop time?
 
        """
 
        if self.autoStopOffset < .01:
 
            return False
 
        pos = self.currentTime()
 
        autoStop = self.duration() - self.autoStopOffset
 
        return not self.isPlaying() and abs(pos - autoStop) < 1  # i've seen .4 difference here
 

	
 
    def resume(self):
 
        self.pipeline.set_state(Gst.State.PLAYING)
 

	
 
    def setupAutostop(self):
 
        dur = self.duration()
 
        if dur == 0:
 
            raise ValueError("duration=0, can't set autostop")
 
        self._autoStopTime = (dur - self.autoStopOffset)
 
        log.info("autostop will be at %s", self._autoStopTime)
 
        # pipeline.seek can take a stop time, but using that wasn't
 
        # working out well. I'd get pauses at other times that were
 
        # hard to remove.
 

	
 
    def isPlaying(self):
 
        _, state, _ = self.pipeline.get_state(timeout=0)
 
        return state == Gst.State.PLAYING
0 comments (0 inline, 0 general)