Browse Source

feat: First mplayer control

Matthias Ladkau 4 years ago
parent
commit
a2f1e21b22
6 changed files with 427 additions and 21 deletions
  1. 1 1
      .gitignore
  2. 63 7
      musikautomat/display.py
  3. 70 9
      musikautomat/main.py
  4. 3 4
      musikautomat/music.yml
  5. 36 0
      musikautomat/player.py
  6. 254 0
      musikautomat/pymplb.py

+ 1 - 1
.gitignore

@@ -6,4 +6,4 @@
 /musikautomat/pyvenv.cfg
 /musikautomat/.mypy_cache
 /musikautomat/__pycache__
-
+*.pyc

+ 63 - 7
musikautomat/display.py

@@ -2,6 +2,7 @@
 
 import yaml
 import pygame # type: ignore
+from typing import Dict
 
 # Color constants
 
@@ -16,33 +17,88 @@ class Display:
         
         # Set config values
         
-        self._dx = config.get("dimx", 128)
+        self._dx = config.get("dimx", 128) # Drawing dimensions
         self._dy = config.get("dimy", 160)
-        self._playlistFile = config.get("playlist", "music.yml")
+        self._playlistFile = config.get("playlist", "music.yml") # Playlist data
         self._pydsp = pydsp # Pygame display object
-        self._fontsize = config.get("fontsize", 12)
+        self._fontsize = config.get("fontsize", 12) # Fontsize to use
+        self._drawlines = int(self._dy / 12) - 1 # Max number of drawn lines
 
         # Set initialisation values
 
         self._selection_pointer = 0 # Current selected value
+        self._line_offset = 0 # Drawing offset in playlist
+        self._current_item = None
+
+
+    def loadPlaylist(self) -> Dict:
+        '''
+        Load the playlist.
+        '''
+        return yaml.safe_load(open(self._playlistFile)).get("playlist", [])
 
 
     def update(self):
         '''
-        Load the data from the given config file.
+        Load the data from the given playlist file and draw it according to the current state.
         '''
-        playlist = yaml.safe_load(open(self._playlistFile))
+        self._pydsp.fill((0,0,0))
+        playlist = self.loadPlaylist()
+
+        # Correct offset and selection pointer
+
+        if self._selection_pointer < 0:
+            self._selection_pointer = 0 # Make sure the selection is at least at pos 0
+            if self._line_offset > 0: # Move the line offset down if possible
+                self._line_offset-=1
+        elif self._selection_pointer > self._drawlines: # Make sure sure selection does not exceed list
+            self._selection_pointer = self._drawlines # Move the line offset up if possible
+            if self._line_offset < len(playlist) - self._drawlines - 1:
+                self._line_offset+=1
+        
+        # Draw lines
 
         font = pygame.font.Font('freesansbold.ttf', self._fontsize)
         drawline = 0 # Line currently drawm
 
-        for i, item in enumerate(playlist.get("playlist", [])):
-            print(item["name"])
+        for i, item in enumerate(playlist):
+
+            # Skip lines until we reached the offset
+
+            if i < self._line_offset:
+                continue
+
+            # Draw the line
 
             if drawline == self._selection_pointer:
                 text = font.render(item["name"], True, BLACK, GREEN)
+                self._current_item = item
             else:
                 text = font.render(item["name"], True, GREEN, BLACK)
             
             self._pydsp.blit(text, (0, drawline * self._fontsize))
+            
+            # Increase number of drawn lines - stop when the maximum is reached
+
             drawline += 1
+            if drawline > self._drawlines:
+                break
+
+
+    def currentItem(self) -> Dict:
+        '''
+        Return the current selected item.
+        '''
+        return self._current_item
+
+
+    def action(self, action: str):
+        '''
+        Perform an action to this display.
+        '''
+        if action == "up":
+            self._selection_pointer-=1
+        elif action == "down":
+            self._selection_pointer+=1
+        
+        self.update()

+ 70 - 9
musikautomat/main.py

@@ -1,6 +1,7 @@
 #!./bin/python3
 
 import sys, os, time
+import traceback
 import yaml
 
 # Include pygame without support prompt
@@ -10,6 +11,7 @@ os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
 import pygame # type: ignore
 
 from display import Display
+from player import Player
 
 # Configuration
 
@@ -29,11 +31,14 @@ pygame.init()
 pydsp = pygame.display.set_mode((dx, dy), 0, 32)
 pygame.display.set_caption("Musikautomat")
 pygame.mouse.set_visible(False)
+pygame.key.set_repeat(400, 100)
 
 # Initialise state objects
 
 display = Display(config, pydsp)
 display.update()
+eventSink = display
+player = Player(config, pydsp)
 clk = pygame.time.Clock()
 
 # Run the game loop
@@ -41,16 +46,72 @@ clk = pygame.time.Clock()
 while True:
     clk.tick(10) # We only need 10 FPS
 
-    for event in pygame.event.get():
-        if event.type == pygame.QUIT:
-            pygame.quit()
-            sys.exit()
-        if event.type == pygame.KEYDOWN:
-            if event.key == pygame.K_LEFT:
-                print("1")
-            if event.key == pygame.K_RIGHT:
-                print("2")
+    try:
 
+        for event in pygame.event.get():
+            
+            # Handle exit event
+            
+            if event.type == pygame.QUIT:
+                pygame.quit()
+                sys.exit()
+            
+            # Handle key events
 
+            if event.type == pygame.KEYDOWN:
+
+                if event.key == pygame.K_DOWN:
+                    eventSink.action("down")
+
+                elif event.key == pygame.K_UP:
+                    eventSink.action("up")
+
+                elif event.key == pygame.K_RIGHT:
+
+                    # Activate player
+
+                    item = display.currentItem()
+                    eventSink = player
+                    
+                    # TODO File selection depending on type
+                    path = item.get("path")
+                    files = os.listdir(path)
+
+                    player.setData([{ 
+                        "name" : f,
+                        "path" : os.path.join(path, f)
+                    } for f in files])
+
+                    player.update()
+
+                elif event.key == pygame.K_LEFT:
+
+                    # Get back to display
+
+                    eventSink = display
+                    display.update()
+
+                elif event.key == pygame.K_RETURN:
+
+                    # Play / pause current item
+
+                    player.togglePlay()
+
+
+    except Exception as e:
+        
+        # Display error
+        traceback.print_exc()
+
+        pydsp.fill((0,0,0))
+        font = pygame.font.Font('freesansbold.ttf', 12)
+        
+        s = str(e)
+        n = int(dx / 6)
+        for i, start in enumerate(range(0, len(s), n)):
+            text = font.render(s[start:start+n], True, (255, 0, 0) , (0, 0, 0))
+            pydsp.blit(text, (0, i * 12))
+        
+        eventSink = display
 
     pygame.display.update()

+ 3 - 4
musikautomat/music.yml

@@ -4,11 +4,10 @@ playlist:
     url: bla
   - name: Aerosmith
     type: dir
-    path: foo
-  - name: Pantera
+    path: /home/ml/Music/aerosmith
+  - name: ACDC
     type: dir
-    path: foo
-
+    path: /home/ml/Music/acdc
   - name: Pantera1
     type: dir
     path: foo

+ 36 - 0
musikautomat/player.py

@@ -0,0 +1,36 @@
+#!./bin/python3
+
+from display import Display
+from typing import Dict
+from pymplb import MPlayer
+
+class Player(Display):
+    '''
+    Player object
+    '''
+    def __init__(self, config, pydsp):
+        super().__init__({
+            "dimx"     : config.get("dimx"),
+            "dimx"     : config.get("dimy"),
+            "playlist" : config.get("dimy"),
+            "fontsize" : config.get("fontsize"),
+        }, pydsp)
+
+        self._player = MPlayer()
+
+    def setData(self, data: Dict):
+        self._data = data
+
+    def loadPlaylist(self) -> Dict:
+        return self._data
+    
+    def togglePlay(self):
+        if not self._current_item:
+            return
+        
+        if self._player.p_path == self._current_item["path"]:
+            print("stop")
+            self._player.stop()
+        else:
+            print("Playing:", self._current_item)
+            self._player.loadfile(self._current_item["path"])

+ 254 - 0
musikautomat/pymplb.py

@@ -0,0 +1,254 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2010, Stefan Parviainen <pafcu@iki.fi>
+#
+# Permission to use, copy, modify, and/or distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+"""
+pymplb (PYthonMPLayerBingings) is a library that can be used to play media using an external MPlayer process.
+The library runs the MPlayer binary in slave mode as a subprocess and then sends slave-mode commands to the process.
+Commands are mapped to class methods and properties to class properties (by default prefixed with 'p_').
+Commands are discovered at runtime and thus these bindings should automatically also support any new commands added to MPlayer in the future.
+An example:
+>>> import pymplb
+>>> player = pymplb.MPlayer()
+>>> player.loadfile('test.ogv')
+>>> player.p_filename
+'test.ogv'
+"""
+
+from functools import partial
+import subprocess
+import atexit
+
+class PlayerNotFoundException(Exception):
+	"""Exception which is raised when the external mplayer binary is not found."""
+	def __init__(self, player_path):
+		Exception.__init__(self, 'Player not found at %s'%player_path)
+
+def make_mplayer_class(mplayer_bin='mplayer', method_prefix='', property_prefix='p_'):
+	"""
+	Construct a MPlayer class which user mplayer_bin as the player binary and prepends the given prefixes to property and method names.
+	Prefixes are needed because some properties and methods have the same name.
+	You only need to construct a new class if the default values are not suitable (i.e. mplayer is not in your path, or some new commands have been introduced that conflict with the default prefixes.
+	"""
+
+	# Yes, I'm aware it's a bit messy to have a function in a function in a class in a function
+	# Decrease your indentation and bear with me here
+	class _MPlayer(object): #pylint: disable-msg=R0903
+		"""
+		This is the main class used to play audio and video files by launching mplayer as a subprocess in slave mode.
+		Slave mode methods can be called directly (e.g. x.loadfile("somefile)") while properties are prefixed to avoid
+		name conflicts between methods and properties (e.g. x.p_looping = False).
+		Available methods and properties are determined at runtime when the class is instantiated. All methods and properties are
+		type-safe and properties respect minimum and maximum values given by mplayer.
+		"""
+		_arg_types = {'Flag':type(False), 'String':type(''), 'Integer':type(0), 'Float':type(0.0), 'Position':type(0.0), 'Time':type(0.0)} # Mapping from mplayer -> Python types
+		_player_methods = {} # Need to keep track of methods because they must be modified after they have been added
+
+		def __init__(self, env=None, mplayer_args_d=None, **mplayer_args):
+			if mplayer_args_d: # Make pylint happy by not passing {} as an argument
+				mplayer_args.update(mplayer_args_d)
+			cmd_args = [mplayer_bin, '-slave', '-quiet', '-idle', '-msglevel', 'all=-1:global=4']
+			for (name, value) in mplayer_args.items():
+				cmd_args.append('-'+name)
+				if value != None and value != True:
+					cmd_args.append(str(value))
+
+			self.__player = _MPlayer._run_player(cmd_args, env=env)
+
+			# Partially apply methods to use the newly created player
+			for (name, func) in self._player_methods.items():
+				setattr(self, name, partial(func, self.__player))
+
+			atexit.register(self.close) # Make sure subprocess is killed
+
+		def close(self):
+			"""Method that kills the MPlayer subprocess"""
+			try:
+				self.__player.terminate()
+			except:
+				pass
+
+		@staticmethod
+		def _run_player(args, env=None):
+			"""Helper function that runs MPlayer with the given arguments"""
+			try:
+				player = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
+			except OSError as err:
+				if err.errno == 2:
+					raise PlayerNotFoundException(args[0])
+				else:
+					raise
+			return player
+
+		@classmethod
+		def _add_methods(cls, mplayer_bin):
+			"""Discover which commands MPlayer understands and add them as class methods"""
+			def cmd(name, argtypes, obligatory, player, *args, **kwargs):
+				"""Function which sends the given command to the MPlayer process"""
+				if len(args) < obligatory:
+					raise TypeError('TypeError: %s() takes at least %d arguments (%d given)'%(name, obligatory, len(args)))
+				if len(args) > len(argtypes):
+					raise TypeError('TypeError: %s() takes at most %d arguments (%d given)'%(name, len(argtypes), len(args)))
+				for i in range(len(args)):
+					if type(args[i]) != argtypes[i]:
+						raise TypeError('Argument %d of %s() has type %s, should be %s'%(i, name, type(args[i]).__name__, argtypes[i].__name__))
+
+				pausing = kwargs.get('pausing', '')
+				if pausing != '':
+					pausing = pausing + ' '
+
+				mplayer_command = '%s%s %s\n' % (pausing, name, ' '.join((str(x) for x in args)))
+				player.stdin.write(mplayer_command.encode('utf-8'))
+				player.stdin.flush()
+
+				# Read return value of commands that give one
+				# Hopefully this is smart enough ...
+				if name.startswith('get_'):
+					while True:
+						line = player.stdout.readline().decode('utf-8')
+						if line == '': # no more lines
+							return None
+
+						if not line[:3] == 'ANS':
+							continue
+
+						retval = line.split('=', 2)[1].rstrip()
+						if retval == 'PROPERTY_UNAVAILABLE':
+							return None
+
+						return retval
+
+			player = cls._run_player([mplayer_bin, '-input', 'cmdlist'])
+
+			# Add each command found
+			for line in player.stdout:
+				line = str(line.decode('utf-8'))
+				parts = line.strip().split()
+				name = parts[0]
+				args = parts[1:]
+				if len(parts) > 1:
+					obligatory = len([x for x in args if x[0] != '[']) # Number of obligatory args
+					try:
+						argtypes = [cls._arg_types[y] for y in [x.strip('[]') for x in args]]
+					except KeyError:  # Unknown argument type
+						continue # Some garbage on the output (version?)
+
+				method = partial(cmd, name, argtypes, obligatory)
+				if not args:
+					method.__doc__ = 'Method taking no arguments'
+				elif len(args) == 1:
+					method.__doc__ = 'Method taking argument of type %s' % args[0]
+				else:
+					method.__doc__ = 'Method taking arguments of types %s' % ' '.join(args)
+
+				cls._player_methods[cls._method_prefix+name] = method
+				setattr(cls, cls._method_prefix+name, method)
+
+		@classmethod
+		def _add_properties(cls, mplayer_bin):
+			"""Discover which properties MPlayer understands and add them as class properties"""
+			def get_prop(name, prop_type, islist, self):
+				"""Function which calls the get_property method to get the property value and does some type checking"""
+				# self argument is needed to be property at the end because of partial
+				retval = getattr(self, cls._method_prefix+'get_property')(name)
+				if islist and retval == '(null)':
+					return []
+				if retval != None:
+					if prop_type != type(False):
+						if islist:
+							retval = [prop_type(x) for x in retval.split(',')]
+						else:
+							retval = prop_type(retval)
+					else:
+						if islist:
+							retval = [x == 'yes' for x in retval.split(',')]
+						else:
+							retval = (retval == 'yes')
+				return retval
+
+			# Function for getting and setting properties
+			def set_prop(name, prop_type, islist, prop_min, prop_max, self, value):
+				"""Function which calls the set_property method to set the property value and does some type checking"""
+				if islist:
+					for elem in value:
+						if type(elem) != prop_type:
+							raise TypeError('TypeError: Element %s has wrong type %s, not %s'%(elem, type(elem).__name__, prop_type))
+						if prop_min != None and elem < prop_min:
+							raise ValueError('ValueError: Element %s must be at least %s'%(elem, prop_min))
+						if prop_max != None and elem > prop_max:
+							raise ValueError('ValueError: Element %s must be at most %s'%(elem, prop_max))
+					value = ','.join([str(elem) for elem in value])
+				else:
+					if type(value) != prop_type:
+						raise TypeError('TypeError: %s has type %s, not %s'%(name, prop_type.__name__, type(value).__name__))
+					if prop_min != None and value < prop_min:
+						raise ValueError('ValueError: %s must be at least %s (>%s)'%(name, prop_min, value))
+					if prop_max != None and value > prop_max:
+						raise ValueError('ValueError: %s must be at most %s (<%s)'%(name, prop_max, value))
+
+				getattr(self, cls._method_prefix+'set_property')(name, str(value))
+
+			player = cls._run_player([mplayer_bin, '-list-properties'])
+			# Add each property found
+			for line in player.stdout:
+				parts = line.strip().decode('utf-8').split()
+				if not (len(parts) == 4 or (len(parts) == 5 and parts[2] == 'list')):
+					continue
+				name = parts[0]
+				try:
+					prop_type = cls._arg_types[parts[1]]
+				except KeyError:
+					continue
+
+				if parts[2] == 'list': # Actually a list
+					prop_min = parts[3]
+					prop_max = parts[4]
+					islist = True
+				else:
+					prop_min = parts[2]
+					prop_max = parts[3]
+					islist = False
+
+				if prop_min == 'No':
+					prop_min = None
+				else:
+					prop_min = prop_type(prop_min)
+
+				if prop_max == 'No':
+					prop_max = None
+				else:
+					prop_max = prop_type(prop_max)
+
+
+				getter = partial(get_prop, name, prop_type, islist)
+				setter = partial(set_prop, name, prop_type, islist, prop_min, prop_max)
+				setattr(cls, cls._property_prefix+name, property(getter, setter, doc='Property of type %s in range [%s, %s].'%(prop_type.__name__, prop_min, prop_max)))
+	# end of _MPlayer
+
+	_MPlayer._method_prefix = method_prefix
+	_MPlayer._property_prefix = property_prefix
+
+	_MPlayer._add_methods(mplayer_bin)
+	_MPlayer._add_properties(mplayer_bin)
+	return _MPlayer
+
+try:
+	MPlayer = make_mplayer_class() # pylint: disable-msg=C0103
+except PlayerNotFoundException as e:
+	print("Fatal:", e)
+
+if __name__ == "__main__":
+	import doctest
+	doctest.testmod(optionflags=doctest.ELLIPSIS)