New media webplugin WIP

This commit is contained in:
Fabio Manganiello 2019-06-22 00:15:32 +02:00
parent 3bd9bec660
commit 482f6f0765
18 changed files with 274 additions and 127 deletions

View file

@ -34,3 +34,13 @@
}
}
.active-glow {
@include animation(active-glow 5s infinite);
}
@keyframes active-glow {
0% { background: $active-glow-bg-1; }
50% { background: $active-glow-bg-2; }
100% { background: $active-glow-bg-1; }
}

View file

@ -38,6 +38,8 @@ $fade-in-transition-duration: $fade-transition-duration !default;
$fade-out-transition-duration: $fade-transition-duration !default;
$roll-in-transition-duration: $roll-transition-duration !default;
$roll-out-transition-duration: $roll-transition-duration !default;
$active-glow-bg-1: #d4ffe3 !default;
$active-glow-bg-2: #9cdfb0 !default;
//// Notifications
$notification-bg: rgba(185, 255, 193, 0.9) !default;

View file

@ -10,6 +10,11 @@
@extend .vertical-center;
padding-left: 1rem;
line-height: 2.6rem;
.item-info {
font-size: 1.15em;
letter-spacing: .02em;
}
}
button {
@ -26,6 +31,10 @@
justify-content: center;
}
.position {
margin-top: .75em;
}
button {
padding: 0 1.5rem;

View file

@ -1,8 +1,19 @@
@import 'common/animations';
.media-plugin {
.results {
@include calc(height, '100% - 16rem');
position: relative; // For the dropdown menu
overflow: auto;
@include calc(height, '100%');
&.resize {
@include calc(height, '100% - 16rem');
}
.active {
@extend .active-glow;
height: 4rem;
}
.empty {
height: 100%;

View file

@ -1,6 +1,8 @@
@import 'common/vars';
@import 'common/mixins';
@import 'common/layout';
@import 'common/animations';
@import 'webpanel/plugins/music.mpd/vars';
.music-mpd-container {
@ -155,7 +157,7 @@
&.active {
height: 4rem;
@include animation(active-track 5s infinite);
@extend .active-glow;
}
&.move:hover {
@ -449,21 +451,3 @@
}
}
@keyframes active-track {
0% { background: $active-track-bg-1; }
50% { background: $active-track-bg-2; }
100% { background: $active-track-bg-1; }
}
@-moz-keyframes active-track {
0% { background: $active-track-bg-1; }
50% { background: $active-track-bg-2; }
100% { background: $active-track-bg-1; }
}
@-webkit-keyframes active-track {
0% { background: $active-track-bg-1; }
50% { background: $active-track-bg-2; }
100% { background: $active-track-bg-1; }
}

View file

@ -16,9 +16,6 @@ $empty-playlist-shadow: 2px 1px rgb(235,235,235);
$playlist-controls-bg: rgba(247,247,247,0.95);
$playlist-controls-border: $default-border-2;
$active-track-bg-1: #d4ffe3;
$active-track-bg-2: #9cdfb0;
$move-mode-track-border: 3px dotted rgb(216,156,136);
$move-mode-track-bg: rgba(216,156,136,0.3);

View file

@ -1,5 +1,6 @@
Vue.component('media-controls', {
template: '#tmpl-media-controls',
mixins: [mediaUtils],
props: {
bus: { type: Object },
status: {
@ -7,8 +8,5 @@ Vue.component('media-controls', {
default: () => {},
},
},
methods: {
},
});

View file

@ -16,6 +16,12 @@ MediaHandlers.file = Vue.extend({
action: this.play,
},
{
text: 'Play with subtitles',
iconClass: 'fas fa-closed-captioning',
action: this.searchSubtiles,
},
{
text: 'Download',
icon: 'download',
@ -41,6 +47,9 @@ MediaHandlers.file = Vue.extend({
info: function(item) {
},
searchSubtitles: function(item) {
},
},
});

View file

@ -1,9 +1,37 @@
// Will be filled by dynamically loading media type handler scripts
const MediaHandlers = {};
const mediaUtils = {
methods: {
convertTime: function(time) {
time = parseFloat(time); // Normalize strings
var t = {};
t.h = '' + parseInt(time/3600);
t.m = '' + parseInt(time/60 - t.h*60);
t.s = '' + parseInt(time - (t.h*3600 + t.m*60));
for (var attr of ['m','s']) {
if (parseInt(t[attr]) < 10) {
t[attr] = '0' + t[attr];
}
}
var ret = [];
if (parseInt(t.h)) {
ret.push(t.h);
}
ret.push(t.m, t.s);
return ret.join(':');
},
},
};
Vue.component('media', {
template: '#tmpl-media',
props: ['config','player'],
mixins: [mediaUtils],
data: function() {
return {
bus: new Vue({}),
@ -54,6 +82,38 @@ Vue.component('media', {
});
},
pause: async function() {
let status = await this.selectedDevice.pause();
this.onStatusUpdate({
device: this.selectedDevice,
status: status,
});
},
stop: async function() {
let status = await this.selectedDevice.stop();
this.onStatusUpdate({
device: this.selectedDevice,
status: status,
});
},
seek: async function(position) {
let status = await this.selectedDevice.seek(position);
this.onStatusUpdate({
device: this.selectedDevice,
status: status,
});
},
setVolume: async function(volume) {
let status = await this.selectedDevice.setVolume(volume);
this.onStatusUpdate({
device: this.selectedDevice,
status: status,
});
},
info: function(item) {
// TODO
console.log(item);
@ -78,29 +138,21 @@ Vue.component('media', {
Vue.set(this.status[dev.type], dev.name, status);
},
onNewPlayingMedia: function(event) {
console.log('NEW MEDIA');
console.log(event);
},
onMediaEvent: async function(event) {
let status = await request(event.plugin + '.status');
onMediaPlay: function(event) {
console.log('PLAY');
console.log(event);
},
if (event.resource) {
event.url = event.resource;
delete event.resource;
}
onMediaPause: function(event) {
console.log('PAUSE');
console.log(event);
},
if (event.plugin.startsWith('media.'))
event.plugin = event.plugin.substr(6);
onMediaStop: function(event) {
console.log('STOP');
console.log(event);
},
onMediaSeek: function(event) {
console.log('SEEK');
console.log(event);
if (this.status[event.player] && this.status[event.player][event.plugin])
this.status[event.player][event.plugin] = status;
else if (this.status[event.plugin] && this.status[event.plugin][event.player])
this.status[event.plugin][event.player] = status;
},
},
@ -112,13 +164,18 @@ Vue.component('media', {
MediaHandlers[type].bus = this.bus;
}
registerEventHandler(this.onNewPlayingMedia, 'platypush.message.event.media.NewPlayingMediaEvent');
registerEventHandler(this.onMediaPlay, 'platypush.message.event.media.MediaPlayEvent');
registerEventHandler(this.onMediaPause, 'platypush.message.event.media.MediaPauseEvent');
registerEventHandler(this.onMediaStop, 'platypush.message.event.media.MediaStopEvent');
registerEventHandler(this.onMediaSeek, 'platypush.message.event.media.MediaSeekEvent');
registerEventHandler(this.onMediaEvent,
'platypush.message.event.media.NewPlayingMediaEvent',
'platypush.message.event.media.MediaPlayEvent',
'platypush.message.event.media.MediaPauseEvent',
'platypush.message.event.media.MediaStopEvent',
'platypush.message.event.media.MediaSeekEvent');
this.bus.$on('play', this.play);
this.bus.$on('pause', this.pause);
this.bus.$on('stop', this.stop);
this.bus.$on('seek', this.seek);
this.bus.$on('volume', this.setVolume);
this.bus.$on('info', this.info);
this.bus.$on('selected-device', this.selectDevice);
this.bus.$on('results-loading', this.onResultsLoading);

View file

@ -0,0 +1,80 @@
MediaPlayers.kodi = Vue.extend({
props: {
type: {
type: String,
default: 'kodi',
},
device: {
type: Object,
default: () => {
return {
url: undefined,
};
},
},
accepts: {
type: Object,
default: () => {
return {
youtube: true,
};
},
},
iconClass: {
type: String,
default: 'fa fa-film',
},
},
computed: {
host: function() {
if (!this.device.url) {
return;
}
return this.device.url.match(/^https?:\/\/([^:]+):(\d+).*$/)[1];
},
name: function() {
return this.host;
},
port: function() {
if (!this.device.url) {
return;
}
return parseInt(this.device.url.match(/^https?:\/\/([^:]+):(\d+).*$/)[2]);
},
text: function() {
return 'Kodi '.concat('[', this.host, ']');
},
},
methods: {
scan: async function() {
if (!('media.kodi' in __plugins__)) {
return [];
}
return [
{ url: __plugins__['media.kodi'].url }
];
},
status: async function() {
return {};
},
play: async function(item) {
},
stop: async function() {
},
},
});

View file

@ -67,7 +67,7 @@ MediaPlayers.local = Vue.extend({
);
},
volume: async function(volume) {
setVolume: async function(volume) {
return await request(
this.pluginPrefix.concat('.set_volume'),
{volume: volume}

View file

@ -18,6 +18,10 @@ Vue.component('media-results', {
type: Object,
default: () => {},
},
resize: {
type: Boolean,
default: false,
},
},
data: function() {
@ -38,6 +42,7 @@ Vue.component('media-results', {
return {
text: item.text,
icon: item.icon,
iconClass: item.iconClass,
click: function() {
item.action(self.selectedItem);
},

View file

@ -5,7 +5,7 @@ var utils = {
var t = {};
t.h = '' + parseInt(time/3600);
t.m = '' + parseInt(time/60 - t.h*60);
t.s = '' + parseInt(time - t.m*60);
t.s = '' + parseInt(time - (t.h*3600 + t.m*60));
for (var attr of ['m','s']) {
if (parseInt(t[attr]) < 10) {

View file

@ -9,22 +9,28 @@
</div>
<div class="col-6 playback-controls">
<div class="row">
<button>
<div class="row buttons">
<button v-if="status.state === 'pause'" @click="bus.$emit('pause')">
<i class="fa fa-play"></i>
</button>
<button>
<button v-if="status.state === 'play'" @click="bus.$emit('pause')">
<i class="fa fa-pause"></i>
</button>
<button v-if="status.state === 'play' || status.state === 'pause'" @click="bus.$emit('stop')">
<i class="fa fa-stop"></i>
</button>
</div>
<div class="row">
<span class="elapsed-time" v-text="'-:--'"></span>
<div class="row position">
<span class="elapsed-time" v-text="status.position ? convertTime(status.position) : '-:--'"></span>
<input type="range"
v-model="status.position"
@input="bus.$emit('seek', $event.target.value)"
class="slider seek-slider"
:disabled="!status.seekable || !status.duration"
min="0"
max="100">
<span class="total-time" v-text="'-:--'"></span>
:max="status.duration || 0">
<span class="total-time" v-text="status.duration ? convertTime(status.duration) : '-:--'"></span>
</div>
</div>
@ -34,9 +40,11 @@
<i class="fa fa-volume-up"></i>
</button>
<input type="range"
v-model="status.volume"
@input="bus.$emit('volume', $event.target.value)"
class="slider volume-slider"
min="0"
max="100">
:max="status.volume_max || 100">
</div>
</div>
</div>

View file

@ -27,11 +27,13 @@
:status="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] ? status[selectedDevice.type][selectedDevice.name] : {}"
:searching="loading.results"
:loading="loading.media"
:resize="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] && (status[selectedDevice.type][selectedDevice.name].state === 'play' || status[selectedDevice.type][selectedDevice.name].state === 'pause')"
:results="results">
</media-results>
<media-controls :bus="bus"
:status="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] ? status[selectedDevice.type][selectedDevice.name] : {}">
:status="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] ? status[selectedDevice.type][selectedDevice.name] : {}"
v-if="selectedDevice && status[selectedDevice.type] && status[selectedDevice.type][selectedDevice.name] && (status[selectedDevice.type][selectedDevice.name].state === 'play' || status[selectedDevice.type][selectedDevice.name].state === 'pause')">
</media-controls>
</div>
</script>

View file

@ -1,7 +1,7 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/media/results.js') }}"></script>
<script type="text/x-template" id="tmpl-media-results">
<div class="results">
<div class="results" :class="{resize: resize}">
<div class="empty" v-if="searching || loading || !results.length">
<div class="searching" v-if="searching">Searching</div>
<div class="loading" v-else-if="loading">Loading</div>
@ -12,7 +12,7 @@
:key="item.url"
:bus="bus"
:selected="item.url && item.url === selectedItem.url"
:active="item.url && item.url === status.url"
:active="(status.state === 'play' || status.state === 'pause') && item.url && item.url === status.url"
:item="item"
v-else>
</media-item>

View file

@ -4,8 +4,8 @@ from platypush.message.event import Event
class MediaEvent(Event):
""" Base class for media events """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, player=None, plugin=None, *args, **kwargs):
super().__init__(player=player, plugin=plugin, *args, **kwargs)
class MediaPlayRequestEvent(MediaEvent):
@ -13,13 +13,13 @@ class MediaPlayRequestEvent(MediaEvent):
Event triggered when a new media playback request is received
"""
def __init__(self, resource=None, title=None, *args, **kwargs):
def __init__(self, player=None, plugin=None, resource=None, title=None, *args, **kwargs):
"""
:param resource: File name or URI of the played video
:type resource: str
"""
super().__init__(*args, resource=resource, title=title, **kwargs)
super().__init__(*args, player=player, plugin=plugin, resource=resource, title=title, **kwargs)
class MediaPlayEvent(MediaEvent):
@ -27,13 +27,13 @@ class MediaPlayEvent(MediaEvent):
Event triggered when a new media content is played
"""
def __init__(self, resource=None, title=None, *args, **kwargs):
def __init__(self, player=None, plugin=None, resource=None, title=None, *args, **kwargs):
"""
:param resource: File name or URI of the played video
:type resource: str
"""
super().__init__(*args, resource=resource, title=title, **kwargs)
super().__init__(*args, player=player, plugin=plugin, resource=resource, title=title, **kwargs)
class MediaStopEvent(MediaEvent):
@ -41,8 +41,8 @@ class MediaStopEvent(MediaEvent):
Event triggered when a media is stopped
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, player=None, plugin=None, *args, **kwargs):
super().__init__(*args, player=player, plugin=plugin, **kwargs)
class MediaPauseEvent(MediaEvent):
@ -50,8 +50,8 @@ class MediaPauseEvent(MediaEvent):
Event triggered when a media playback is paused
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, player=None, plugin=None, *args, **kwargs):
super().__init__(*args, player=player, plugin=plugin, **kwargs)
class MediaSeekEvent(MediaEvent):
@ -59,8 +59,8 @@ class MediaSeekEvent(MediaEvent):
Event triggered when the time position in the media changes
"""
def __init__(self, position, *args, **kwargs):
super().__init__(*args, position=position, **kwargs)
def __init__(self, position, player=None, plugin=None, *args, **kwargs):
super().__init__(*args, player=player, plugin=plugin, position=position, **kwargs)
class MediaVolumeChangedEvent(MediaEvent):
@ -68,8 +68,8 @@ class MediaVolumeChangedEvent(MediaEvent):
Event triggered when the media volume changes
"""
def __init__(self, volume, *args, **kwargs):
super().__init__(*args, volume=volume, **kwargs)
def __init__(self, volume, player=None, plugin=None, *args, **kwargs):
super().__init__(*args, player=player, plugin=plugin, volume=volume, **kwargs)
class MediaMuteChangedEvent(MediaEvent):
@ -77,8 +77,8 @@ class MediaMuteChangedEvent(MediaEvent):
Event triggered when the media is muted/unmuted
"""
def __init__(self, mute, *args, **kwargs):
super().__init__(*args, mute=mute, **kwargs)
def __init__(self, mute, player=None, plugin=None, *args, **kwargs):
super().__init__(*args, player=player, plugin=plugin, mute=mute, **kwargs)
class NewPlayingMediaEvent(MediaEvent):
@ -86,13 +86,13 @@ class NewPlayingMediaEvent(MediaEvent):
Event triggered when a new media source is being played
"""
def __init__(self, resource=None, *args, **kwargs):
def __init__(self, player=None, plugin=None, resource=None, *args, **kwargs):
"""
:param resource: File name or URI of the played resource
:type resource: str
"""
super().__init__(*args, resource=resource, **kwargs)
super().__init__(*args, player=player, plugin=plugin, resource=resource, **kwargs)
# vim:sw=4:ts=4:et:

View file

@ -59,6 +59,11 @@ class MediaMpvPlugin(MediaPlugin):
self._player = mpv.MPV(**mpv_args)
self._player.register_event_callback(self._event_callback())
@staticmethod
def _post_event(evt_type, **evt):
bus = get_bus()
bus.post(evt_type(player='local', plugin='media.mpv', **evt))
def _event_callback(self):
def callback(event):
from mpv import MpvEventID as Event
@ -70,17 +75,15 @@ class MediaMpvPlugin(MediaPlugin):
if not evt:
return
bus = get_bus()
if (evt == Event.FILE_LOADED or evt == Event.START_FILE) and self._get_current_resource():
self._playback_rebounce_event.set()
bus.post(NewPlayingMediaEvent(resource=self._get_current_resource(), title=self._player.filename))
bus.post(MediaPlayEvent(resource=self._get_current_resource(), title=self._player.filename))
self._post_event(NewPlayingMediaEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.PLAYBACK_RESTART:
self._playback_rebounce_event.set()
elif evt == Event.PAUSE:
bus.post(MediaPauseEvent(resource=self._get_current_resource(), title=self._player.filename))
self._post_event(MediaPauseEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.UNPAUSE:
bus.post(MediaPlayEvent(resource=self._get_current_resource(), title=self._player.filename))
self._post_event(MediaPlayEvent, resource=self._get_current_resource(), title=self._player.filename)
elif evt == Event.SHUTDOWN or (
evt == Event.END_FILE and event.get('event', {}).get('reason')
in [EndFile.EOF_OR_INIT_FAILURE, EndFile.ABORTED, EndFile.QUIT]):
@ -90,12 +93,12 @@ class MediaMpvPlugin(MediaPlugin):
return
self._player = None
bus.post(MediaStopEvent())
self._post_event(MediaStopEvent)
for callback in self._on_stop_callbacks:
callback()
elif evt == Event.SEEK:
bus.post(MediaSeekEvent(position=self._player.playback_time))
self._post_event(MediaSeekEvent, position=self._player.playback_time)
return callback
@ -210,7 +213,7 @@ class MediaMpvPlugin(MediaPlugin):
volume = max(0, min([self._player.volume_max, volume]))
self._player.volume = volume
return {'volume': volume}
return self.status()
@action
def seek(self, position):
@ -382,31 +385,18 @@ class MediaMpvPlugin(MediaPlugin):
return {'state': PlayerState.STOP.value}
return {
'alang': getattr(self._player, 'alang'),
'aspect': getattr(self._player, 'aspect'),
'audio': getattr(self._player, 'audio'),
'audio_bitrate': getattr(self._player, 'audio_bitrate'),
'audio_buffer': getattr(self._player, 'audio_buffer'),
'audio_channels': getattr(self._player, 'audio_channels'),
'audio_client_name': getattr(self._player, 'audio_client_name'),
'audio_codec': getattr(self._player, 'audio_codec'),
'audio_codec_name': getattr(self._player, 'audio_codec_name'),
'audio_codec': getattr(self._player, 'audio_codec_name'),
'audio_delay': getattr(self._player, 'audio_delay'),
'audio_device': getattr(self._player, 'audio_device'),
'audio_device_list': getattr(self._player, 'audio_device_list'),
'audio_exclusive': getattr(self._player, 'audio_exclusive'),
'audio_output': getattr(self._player, 'current_ao'),
'audio_file_paths': getattr(self._player, 'audio_file_paths'),
'audio_files': getattr(self._player, 'audio_files'),
'audio_format': getattr(self._player, 'audio_format'),
'audio_out_params': getattr(self._player, 'audio_out_params'),
'audio_params': getattr(self._player, 'audio_params'),
'audio_mixer_device': getattr(self._player, 'alsa_mixer_device'),
'audio_mixer_index': getattr(self._player, 'alsa_mixer_index'),
'audio_mixer_name': getattr(self._player, 'alsa_mixer_name'),
'autosub': getattr(self._player, 'autosub'),
'audio_mixer': getattr(self._player, 'alsa_mixer_device'),
'autosync': getattr(self._player, 'autosync'),
'background': getattr(self._player, 'background'),
'border': getattr(self._player, 'border'),
'brightness': getattr(self._player, 'brightness'),
'chapter': getattr(self._player, 'chapter'),
'chapter_list': getattr(self._player, 'chapter_list'),
@ -416,13 +406,11 @@ class MediaMpvPlugin(MediaPlugin):
'clock': getattr(self._player, 'clock'),
'cookies': getattr(self._player, 'cookies'),
'cookies_file': getattr(self._player, 'cookies_file'),
'current_ao': getattr(self._player, 'current_ao'),
'current_vo': getattr(self._player, 'current_vo'),
'delay': getattr(self._player, 'delay'),
'display_names': getattr(self._player, 'display_names'),
'end': getattr(self._player, 'end'),
'endpos': getattr(self._player, 'endpos'),
'eof_reached': getattr(self._player, 'eof_reached'),
'displays': getattr(self._player, 'display_names'),
'duration': getattr(self._player, 'playback_time', 0) +
getattr(self._player, 'playtime_remaining', 0)
if getattr(self._player, 'playtime_remaining') else None,
'file_format': getattr(self._player, 'file_format'),
'filename': getattr(self._player, 'filename'),
'file_size': getattr(self._player, 'file_size'),
@ -432,11 +420,8 @@ class MediaMpvPlugin(MediaPlugin):
'height': getattr(self._player, 'height'),
'idle': getattr(self._player, 'idle'),
'idle_active': getattr(self._player, 'idle_active'),
'length': getattr(self._player, 'playback_time', 0) + getattr(self._player, 'playtime_remaining', 0)
if getattr(self._player, 'playtime_remaining') else None,
'loop': getattr(self._player, 'loop'),
'media_title': getattr(self._player, 'loop'),
'mpv_configuration': getattr(self._player, 'mpv_configuration'),
'media_title': getattr(self._player, 'media_title'),
'mpv_version': getattr(self._player, 'mpv_version'),
'mute': getattr(self._player, 'mute'),
'name': getattr(self._player, 'name'),
@ -464,17 +449,8 @@ class MediaMpvPlugin(MediaPlugin):
'sub_paths': getattr(self._player, 'sub_paths'),
'sub_text': getattr(self._player, 'sub_text'),
'subdelay': getattr(self._player, 'subdelay'),
'terminal': getattr(self._player, 'terminal'),
'time_start': getattr(self._player, 'time_start'),
'title': getattr(self._player, 'filename'),
'tv_alsa': getattr(self._player, 'tv_alsa'),
'tv_audio': getattr(self._player, 'tv_audio'),
'tv_audiorate': getattr(self._player, 'tv_audiorate'),
'tv_channels': getattr(self._player, 'tv_channels'),
'tv_device': getattr(self._player, 'tv_device'),
'tv_height': getattr(self._player, 'tv_height'),
'tv_volume': getattr(self._player, 'tv_volume'),
'tv_width': getattr(self._player, 'tv_width'),
'url': self._get_current_resource(),
'user_agent': getattr(self._player, 'user_agent'),
'video': getattr(self._player, 'video'),
@ -482,19 +458,18 @@ class MediaMpvPlugin(MediaPlugin):
'video_align_y': getattr(self._player, 'video_align_y'),
'video_aspect': getattr(self._player, 'video_aspect'),
'video_bitrate': getattr(self._player, 'video_bitrate'),
'video_output': getattr(self._player, 'current_vo'),
'video_codec': getattr(self._player, 'video_codec'),
'video_format': getattr(self._player, 'video_format'),
'video_params': getattr(self._player, 'video_params'),
'video_sync': getattr(self._player, 'video_sync'),
'video_zoom': getattr(self._player, 'video_zoom'),
'vlang': getattr(self._player, 'vlang'),
'volume': getattr(self._player, 'volume'),
'volume_max': getattr(self._player, 'volume_max'),
'width': getattr(self._player, 'width'),
'window_minimized': getattr(self._player, 'window_minimized'),
'window_scale': getattr(self._player, 'window_scale'),
'working_directory': getattr(self._player, 'working_directory'),
'ytdl': getattr(self._player, 'ytdl'),
}
def on_stop(self, callback):