forked from platypush/platypush
For some reason, vlc event handlers are not re-entrant (https://github.com/oaubert/python-vlc/issues/44#issuecomment-378520074). This means that the vlc API can't be used from an event handler, and that an event handler that reacts to stop/end-of-stream by releasing the player and the vlc instance will likely get stuck and the app may eventually die with SIGSEGV. Because of this design limitation on the vlc side, the plugin has to run another thread in the main app that monitors the stop event set by the event handler and releases the resources appropriately.
443 lines
15 KiB
Python
443 lines
15 KiB
Python
import os
|
|
import threading
|
|
import urllib.parse
|
|
from typing import Optional
|
|
|
|
from platypush.context import get_bus
|
|
from platypush.plugins.media import PlayerState, MediaPlugin
|
|
from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \
|
|
MediaPauseEvent, MediaStopEvent, MediaSeekEvent, MediaVolumeChangedEvent, \
|
|
MediaMuteChangedEvent, NewPlayingMediaEvent
|
|
|
|
from platypush.plugins import action
|
|
|
|
|
|
class MediaVlcPlugin(MediaPlugin):
|
|
"""
|
|
Plugin to control vlc instances
|
|
|
|
Requires:
|
|
|
|
* **python-vlc** (``pip install python-vlc``)
|
|
* **vlc** executable on your system
|
|
"""
|
|
|
|
def __init__(self, args=None, fullscreen=False, volume=100, *argv, **kwargs):
|
|
"""
|
|
Create the vlc wrapper.
|
|
|
|
:param args: List of extra arguments to pass to the VLC executable (e.g.
|
|
``['--sub-language=en', '--snapshot-path=/mnt/snapshots']``)
|
|
:type args: list[str]
|
|
|
|
:param fullscreen: Set to True if you want media files to be opened in
|
|
fullscreen by default (can be overridden by `.play()`) (default: False)
|
|
:type fullscreen: bool
|
|
|
|
:param volume: Default media volume (default: 100)
|
|
:type volume: int
|
|
"""
|
|
|
|
super().__init__(*argv, **kwargs)
|
|
|
|
self._args = args or []
|
|
self._instance = None
|
|
self._player = None
|
|
self._latest_seek = None
|
|
self._default_fullscreen = fullscreen
|
|
self._default_volume = volume
|
|
self._on_stop_callbacks = []
|
|
self._title = None
|
|
self._filename = None
|
|
self._monitor_thread: Optional[threading.Thread] = None
|
|
self._on_stop_event = threading.Event()
|
|
|
|
@classmethod
|
|
def _watched_event_types(cls):
|
|
import vlc
|
|
return [getattr(vlc.EventType, evt) for evt in [
|
|
'MediaPlayerLengthChanged', 'MediaPlayerMediaChanged',
|
|
'MediaDurationChanged', 'MediaPlayerMuted',
|
|
'MediaPlayerUnmuted', 'MediaPlayerOpening', 'MediaPlayerPaused',
|
|
'MediaPlayerPlaying', 'MediaPlayerPositionChanged',
|
|
'MediaPlayerStopped', 'MediaPlayerTimeChanged', 'MediaStateChanged',
|
|
'MediaPlayerForward', 'MediaPlayerBackward',
|
|
'MediaPlayerEndReached', 'MediaPlayerTitleChanged',
|
|
'MediaPlayerAudioVolume',
|
|
] if hasattr(vlc.EventType, evt)]
|
|
|
|
def _init_vlc(self, resource):
|
|
import vlc
|
|
|
|
if self._instance:
|
|
self.logger.info('Another instance is running, waiting for it to terminate')
|
|
self._on_stop_event.wait()
|
|
|
|
self._reset_state()
|
|
|
|
for k, v in self._env.items():
|
|
os.environ[k] = v
|
|
|
|
self._monitor_thread = threading.Thread(target=self._player_monitor)
|
|
self._monitor_thread.start()
|
|
self._instance = vlc.Instance(*self._args)
|
|
self._player = self._instance.media_player_new(resource)
|
|
|
|
for evt in self._watched_event_types():
|
|
self._player.event_manager().event_attach(
|
|
eventtype=evt, callback=self._event_callback())
|
|
|
|
def _player_monitor(self):
|
|
self._on_stop_event.wait()
|
|
self.logger.info('VLC stream terminated')
|
|
self._reset_state()
|
|
|
|
def _reset_state(self):
|
|
self._latest_seek = None
|
|
self._title = None
|
|
self._filename = None
|
|
self._on_stop_event.clear()
|
|
|
|
if self._player:
|
|
self.logger.info('Releasing VLC player resource')
|
|
self._player.release()
|
|
self._player = None
|
|
|
|
if self._instance:
|
|
self.logger.info('Releasing VLC instance resource')
|
|
self._instance.release()
|
|
self._instance = None
|
|
|
|
@staticmethod
|
|
def _post_event(evt_type, **evt):
|
|
bus = get_bus()
|
|
bus.post(evt_type(player='local', plugin='media.vlc', **evt))
|
|
|
|
def _event_callback(self):
|
|
def callback(event):
|
|
from vlc import EventType
|
|
self.logger.debug('Received vlc event: {}'.format(event))
|
|
|
|
if event.type == EventType.MediaPlayerPlaying:
|
|
self._post_event(MediaPlayEvent, resource=self._get_current_resource())
|
|
elif event.type == EventType.MediaPlayerPaused:
|
|
self._post_event(MediaPauseEvent)
|
|
elif event.type == EventType.MediaPlayerStopped or \
|
|
event.type == EventType.MediaPlayerEndReached:
|
|
self._on_stop_event.set()
|
|
self._post_event(MediaStopEvent)
|
|
for cbk in self._on_stop_callbacks:
|
|
cbk()
|
|
elif event.type == EventType.MediaPlayerTitleChanged:
|
|
self._filename = event.u.filename
|
|
self._title = event.u.new_title
|
|
self._post_event(NewPlayingMediaEvent, resource=event.u.new_title)
|
|
elif event.type == EventType.MediaPlayerMediaChanged:
|
|
self._filename = event.u.filename
|
|
self._title = event.u.new_title
|
|
self._post_event(NewPlayingMediaEvent, resource=event.u.filename)
|
|
elif event.type == EventType.MediaPlayerLengthChanged:
|
|
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource())
|
|
elif event.type == EventType.MediaPlayerTimeChanged:
|
|
pos = float(event.u.new_time/1000)
|
|
if self._latest_seek is None or \
|
|
abs(pos-self._latest_seek) > 5:
|
|
self._post_event(MediaSeekEvent, position=pos)
|
|
self._latest_seek = pos
|
|
elif event.type == EventType.MediaPlayerAudioVolume:
|
|
self._post_event(MediaVolumeChangedEvent, volume=self._player.audio_get_volume())
|
|
elif event.type == EventType.MediaPlayerMuted:
|
|
self._post_event(MediaMuteChangedEvent, mute=True)
|
|
elif event.type == EventType.MediaPlayerUnmuted:
|
|
self._post_event(MediaMuteChangedEvent, mute=False)
|
|
|
|
return callback
|
|
|
|
@action
|
|
def play(self, resource=None, subtitles=None, fullscreen=None, volume=None):
|
|
"""
|
|
Play a resource.
|
|
|
|
:param resource: Resource to play - can be a local file or a remote URL (default: None == toggle play).
|
|
:type resource: str
|
|
|
|
:param subtitles: Path to optional subtitle file
|
|
:type subtitles: str
|
|
|
|
:param fullscreen: Set to explicitly enable/disable fullscreen (default:
|
|
`fullscreen` configured value or False)
|
|
:type fullscreen: bool
|
|
|
|
:param volume: Set to explicitly set the playback volume (default:
|
|
`volume` configured value or 100)
|
|
:type fullscreen: bool
|
|
"""
|
|
|
|
if not resource:
|
|
return self.pause()
|
|
|
|
self._post_event(MediaPlayRequestEvent, resource=resource)
|
|
resource = self._get_resource(resource)
|
|
|
|
if resource.startswith('file://'):
|
|
resource = resource[len('file://'):]
|
|
|
|
self._init_vlc(resource)
|
|
if subtitles:
|
|
if subtitles.startswith('file://'):
|
|
subtitles = subtitles[len('file://'):]
|
|
self._player.video_set_subtitle_file(subtitles)
|
|
|
|
self._player.play()
|
|
if self.volume:
|
|
self.set_volume(volume=self.volume)
|
|
|
|
if fullscreen or self._default_fullscreen:
|
|
self.set_fullscreen(True)
|
|
|
|
if volume is not None or self._default_volume is not None:
|
|
self.set_volume(volume if volume is not None
|
|
else self._default_volume)
|
|
|
|
return self.status()
|
|
|
|
@action
|
|
def pause(self):
|
|
""" Toggle the paused state """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
if not self._player.can_pause():
|
|
return None, 'The specified media type cannot be paused'
|
|
|
|
self._player.pause()
|
|
return self.status()
|
|
|
|
@action
|
|
def quit(self):
|
|
""" Quit the player (same as `stop`) """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
|
|
self._player.stop()
|
|
self._player = None
|
|
return self.status()
|
|
|
|
@action
|
|
def stop(self):
|
|
""" Stop the application (same as `quit`) """
|
|
return self.quit()
|
|
|
|
@action
|
|
def voldown(self, step=10.0):
|
|
""" Volume down by (default: 10)% """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
return self.set_volume(int(max(0, self._player.audio_get_volume()-step)))
|
|
|
|
@action
|
|
def volup(self, step=10.0):
|
|
""" Volume up by (default: 10)% """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
return self.set_volume(int(min(100, self._player.audio_get_volume()+step)))
|
|
|
|
@action
|
|
def set_volume(self, volume):
|
|
"""
|
|
Set the volume
|
|
|
|
:param volume: Volume value between 0 and 100
|
|
:type volume: float
|
|
"""
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
|
|
volume = max(0, min([100, volume]))
|
|
self._player.audio_set_volume(volume)
|
|
status = self.status().output
|
|
status['volume'] = volume
|
|
return status
|
|
|
|
@action
|
|
def seek(self, position):
|
|
"""
|
|
Seek backward/forward by the specified number of seconds
|
|
|
|
:param position: Number of seconds relative to the current cursor
|
|
:type position: int
|
|
"""
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
if not self._player.is_seekable():
|
|
return None, 'The resource is not seekable'
|
|
|
|
media = self._player.get_media()
|
|
if not media:
|
|
return None, 'No media loaded'
|
|
|
|
pos = min(media.get_duration()/1000, max(0, position))
|
|
self._player.set_time(int(pos*1000))
|
|
return self.status()
|
|
|
|
@action
|
|
def back(self, offset=30.0):
|
|
""" Back by (default: 30) seconds """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
|
|
media = self._player.get_media()
|
|
if not media:
|
|
return None, 'No media loaded'
|
|
|
|
pos = max(0, (self._player.get_time()/1000)-offset)
|
|
return self.seek(pos)
|
|
|
|
@action
|
|
def forward(self, offset=30.0):
|
|
""" Forward by (default: 30) seconds """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
|
|
media = self._player.get_media()
|
|
if not media:
|
|
return None, 'No media loaded'
|
|
|
|
pos = min(media.get_duration()/1000, (self._player.get_time()/1000)+offset)
|
|
return self.seek(pos)
|
|
|
|
@action
|
|
def toggle_subtitles(self, visibile=None):
|
|
""" Toggle the subtitles visibility """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
|
|
if self._player.video_get_spu_count() == 0:
|
|
return None, 'The media file has no subtitles set'
|
|
|
|
if self._player.video_get_spu() is None or \
|
|
self._player.video_get_spu() == -1:
|
|
self._player.video_set_spu(0)
|
|
else:
|
|
self._player.video_set_spu(-1)
|
|
|
|
@action
|
|
def toggle_fullscreen(self):
|
|
""" Toggle the fullscreen mode """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
self._player.toggle_fullscreen()
|
|
|
|
@action
|
|
def set_fullscreen(self, fullscreen=True):
|
|
""" Set fullscreen mode """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
self._player.set_fullscreen(fullscreen)
|
|
|
|
@action
|
|
def set_subtitles(self, filename, **args):
|
|
""" Sets media subtitles from filename """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
if filename.startswith('file://'):
|
|
filename = filename[len('file://'):]
|
|
|
|
self._player.video_set_subtitle_file(filename)
|
|
|
|
@action
|
|
def remove_subtitles(self):
|
|
""" Removes (hides) the subtitles """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
self._player.video_set_spu(-1)
|
|
|
|
@action
|
|
def is_playing(self):
|
|
"""
|
|
:returns: True if it's playing, False otherwise
|
|
"""
|
|
if not self._player:
|
|
return False
|
|
return self._player.is_playing()
|
|
|
|
@action
|
|
def load(self, resource, **args):
|
|
"""
|
|
Load/queue a resource/video to the player
|
|
"""
|
|
if not self._player:
|
|
return self.play(resource, **args)
|
|
self._player.set_media(resource)
|
|
return self.status()
|
|
|
|
@action
|
|
def mute(self):
|
|
""" Toggle mute state """
|
|
if not self._player:
|
|
return None, 'No vlc instance is running'
|
|
self._player.audio_toggle_mute()
|
|
|
|
@action
|
|
def set_position(self, position):
|
|
"""
|
|
Seek backward/forward to the specified absolute position (same as ``seek``)
|
|
"""
|
|
return self.seek(position)
|
|
|
|
@action
|
|
def status(self):
|
|
"""
|
|
Get the current player state.
|
|
|
|
:returns: A dictionary containing the current state.
|
|
|
|
Example::
|
|
|
|
output = {
|
|
"filename": "filename or stream URL",
|
|
"state": "play" # or "stop" or "pause"
|
|
}
|
|
"""
|
|
import vlc
|
|
if not self._player:
|
|
return {'state': PlayerState.STOP.value}
|
|
|
|
status = {}
|
|
vlc_state = self._player.get_state()
|
|
|
|
if vlc_state == vlc.State.Playing:
|
|
status['state'] = PlayerState.PLAY.value
|
|
elif vlc_state == vlc.State.Paused:
|
|
status['state'] = PlayerState.PAUSE.value
|
|
else:
|
|
status['state'] = PlayerState.STOP.value
|
|
|
|
status['url'] = urllib.parse.unquote(self._player.get_media().get_mrl()) if self._player.get_media() else None
|
|
status['position'] = float(self._player.get_time()/1000) if self._player.get_time() is not None else None
|
|
|
|
media = self._player.get_media()
|
|
status['duration'] = media.get_duration()/1000 if media and media.get_duration() is not None else None
|
|
|
|
status['seekable'] = status['duration'] is not None
|
|
status['fullscreen'] = self._player.get_fullscreen()
|
|
status['mute'] = self._player.audio_get_mute()
|
|
status['path'] = status['url']
|
|
status['pause'] = status['state'] == PlayerState.PAUSE.value
|
|
status['percent_pos'] = self._player.get_position()*100
|
|
status['filename'] = self._filename
|
|
status['title'] = self._title
|
|
status['volume'] = self._player.audio_get_volume()
|
|
status['volume_max'] = 100
|
|
|
|
return status
|
|
|
|
def on_stop(self, callback):
|
|
self._on_stop_callbacks.append(callback)
|
|
|
|
def _get_current_resource(self):
|
|
if not self._player or not self._player.get_media():
|
|
return
|
|
return self._player.get_media().get_mrl()
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|