diff --git a/app/tools/media.py b/app/tools/media.py index 45b69723..93cad7c8 100644 --- a/app/tools/media.py +++ b/app/tools/media.py @@ -1,12 +1,17 @@ import os +import subprocess import sys from datetime import datetime +from enum import Enum +from urllib.request import urlopen from app.commons import run_task, log, _DATE_FORMAT +from app.settings import PlayStreamsMode class Player: __VLC_INSTANCE = None + __PLAY_STREAMS_MODE = PlayStreamsMode.BUILT_IN def __init__(self, rewind_callback, position_callback, error_callback, playing_callback): try: @@ -46,6 +51,10 @@ class Player: cls.__VLC_INSTANCE = Player(rewind_callback, position_callback, error_callback, playing_callback) return cls.__VLC_INSTANCE + @staticmethod + def get_play_mode(): + return Player.__PLAY_STREAMS_MODE + @run_task def play(self, mrl=None): if mrl: @@ -105,6 +114,90 @@ class Player: self._player.set_fullscreen(full) +class HttpPlayer: + """ Simple wrapper for VLC media player to interact over http. """ + + __VLC_INSTANCE = None + __PLAY_STREAMS_MODE = PlayStreamsMode.VLC + + class Commands(Enum): + STATUS = "http://127.0.0.1:{}/requests/status.xml" + PLAY = "http://127.0.0.1:{}/requests/status.xml?command=in_play&input={}" + STOP = "http://127.0.0.1:{}/requests/status.xml?command=pl_stop" + CLEAR = "http://127.0.0.1:{}/requests/status.xml?command=pl_empty" + + def __init__(self, exe, port): + from concurrent.futures import ThreadPoolExecutor as PoolExecutor + + self._executor = PoolExecutor(max_workers=1) + self._cmd = [exe, "--no-stats", "--verbose=-1", "--extraintf", "http", "--http-port", port, + "--no-playlist-skip-ads", "--one-instance", "--quiet"] + self._p = None + self._state = None + self._port = port + + @classmethod + def get_instance(cls, settings): + if not cls.__VLC_INSTANCE: + import shutil + + is_darwin = settings.is_darwin + # TODO Add options[vlc_exe and port] to the settings! + exe = "/Applications/VLC.app/Contents/MacOS/VLC" if is_darwin else "/usr/bin/vlc" + if shutil.which(exe) is None: + raise ImportError + cls.__VLC_INSTANCE = HttpPlayer(exe=exe, port=str(9090)) + return cls.__VLC_INSTANCE + + @staticmethod + def get_play_mode(): + return HttpPlayer.__PLAY_STREAMS_MODE + + @run_task + def play(self, mrl=None): + if not self._p or self._p and self._p.poll() is not None: + self._p = subprocess.Popen(self._cmd + [mrl], preexec_fn=os.setsid) + self._p.communicate() + else: + self._executor.submit(self.open_command, self.Commands.CLEAR) + self._executor.submit(self.open_command, self.Commands.PLAY, mrl) + + def open_command(self, command, url=None): + if command is self.Commands.PLAY: + url = self.Commands.PLAY.value.format(self._port, url) + else: + url = command.value.format(self._port) + + try: + with urlopen(url, timeout=5) as f: + self._state = command + except Exception as e: + log("{}[open_command, {}] error: {}".format(__class__.__name__, command, e)) + + def stop(self): + if self._state is self.Commands.PLAY: + self._executor.submit(self.open_command, self.Commands.STOP) + + def pause(self): + pass + + def set_time(self, time): + pass + + @run_task + def release(self): + if self._p and self._p.poll() is None: + import signal + # Good explanation here: https://stackoverflow.com/a/4791612 + os.killpg(os.getpgid(self._p.pid), signal.SIGTERM) + + def is_playing(self): + return self._state is self.Commands.PLAY + + def set_full_screen(self, full): + pass + + class Recorder: __VLC_REC_INSTANCE = None diff --git a/app/ui/main_app_window.py b/app/ui/main_app_window.py index 9547002f..d13883e0 100644 --- a/app/ui/main_app_window.py +++ b/app/ui/main_app_window.py @@ -8,16 +8,16 @@ from itertools import chain from gi.repository import GLib, Gio from app.commons import run_idle, log, run_task, run_with_delay, init_logger -from app.connections import HttpAPI, HttpRequestType, download_data, DownloadType, upload_data, test_http, \ - TestException, HttpApiException +from app.connections import (HttpAPI, HttpRequestType, download_data, DownloadType, upload_data, test_http, + TestException, HttpApiException) from app.eparser import get_blacklist, write_blacklist, parse_m3u from app.eparser import get_services, get_bouquets, write_bouquets, write_services, Bouquets, Bouquet, Service from app.eparser.ecommons import CAS, Flag, BouquetService from app.eparser.enigma.bouquets import BqServiceType from app.eparser.iptv import export_to_m3u from app.eparser.neutrino.bouquets import BqType -from app.settings import SettingsType, Settings, SettingsException -from app.tools.media import Player, Recorder +from app.settings import SettingsType, Settings, SettingsException, PlayStreamsMode +from app.tools.media import Player, Recorder, HttpPlayer from app.ui.epg_dialog import EpgDialog from app.ui.transmitter import LinksTransmitter from .backup import BackupDialog, backup_data, clear_data_path @@ -25,17 +25,17 @@ from .dialogs import show_dialog, DialogType, get_chooser_dialog, WaitDialog, ge from .download_dialog import DownloadDialog from .imports import ImportDialog, import_bouquet from .iptv import IptvDialog, SearchUnavailableDialog, IptvListConfigurationDialog, YtListImportDialog -from .main_helper import insert_marker, move_items, rename, ViewTarget, set_flags, locate_in_services, \ - scroll_to, get_base_model, update_picons_data, copy_picon_reference, assign_picon, remove_picon, \ - is_only_one_item_selected, gen_bouquets, BqGenType, get_iptv_url, append_picons, get_selection, get_model_data, \ - remove_all_unused_picons +from .main_helper import (insert_marker, move_items, rename, ViewTarget, set_flags, locate_in_services, + scroll_to, get_base_model, update_picons_data, copy_picon_reference, assign_picon, + remove_picon, is_only_one_item_selected, gen_bouquets, BqGenType, get_iptv_url, append_picons, + get_selection, get_model_data, remove_all_unused_picons) from .picons_downloader import PiconsDialog from .satellites_dialog import show_satellites_dialog from .search import SearchProvider from .service_details_dialog import ServiceDetailsDialog, Action from .settings_dialog import show_settings_dialog -from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, LOCKED_ICON, HIDE_ICON, IPTV_ICON, MOVE_KEYS, KeyboardKey, Column, \ - FavClickMode +from .uicommons import (Gtk, Gdk, UI_RESOURCES_PATH, LOCKED_ICON, HIDE_ICON, IPTV_ICON, MOVE_KEYS, KeyboardKey, Column, + FavClickMode) class Application(Gtk.Application): @@ -172,7 +172,7 @@ class Application(Gtk.Application): # Player self._player = None self._full_screen = False - self._drawing_area_xid = None + self._current_mrl = None # Record self._recorder = None # http api @@ -1000,7 +1000,8 @@ class Application(Gtk.Application): self.show_error_dialog(str(e)) return except Exception as e: - log("Append services error: " + str(e)) + from traceback import format_exc + log("Reading data error: {}".format(format_exc())) self.show_error_dialog(get_message("Reading data error!") + "\n" + str(e)) return else: @@ -1525,7 +1526,10 @@ class Application(Gtk.Application): if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS: if self._fav_click_mode is FavClickMode.DISABLED: return - elif self._fav_click_mode is FavClickMode.STREAM: + + self._fav_view.set_sensitive(False) + + if self._fav_click_mode is FavClickMode.STREAM: self.on_play_stream() elif self._fav_click_mode is FavClickMode.ZAP_PLAY: self.on_zap(self.on_watch) @@ -1700,6 +1704,7 @@ class Application(Gtk.Application): row = self._fav_model[path][:] if row[Column.FAV_TYPE] != BqServiceType.IPTV.name: self.show_error_dialog("Not allowed in this context!") + self.set_playback_elms_active() return url = get_iptv_url(row, self._s_type) @@ -1709,24 +1714,36 @@ class Application(Gtk.Application): self.play(url) def play(self, url): - if not self._player: + mode = self._settings.play_streams_mode + if mode is PlayStreamsMode.M3U: + self.save_stream_to_m3u(url) + return + + if mode is PlayStreamsMode.VLC: try: - self._player = Player.get_instance(rewind_callback=self.on_player_duration_changed, - position_callback=self.on_player_time_changed, - error_callback=self.on_player_error, - playing_callback=self.set_playback_elms_active) - except (ImportError, NameError, AttributeError): + if self._player and self._player.get_play_mode() is not mode: + self._player.release() + self._player = None + if not self._player: + self._player = HttpPlayer.get_instance(self._settings) + except ImportError: self.show_error_dialog("No VLC is found. Check that it is installed!") - return else: - if self._drawing_area_xid: - self._player.set_xwindow(self._drawing_area_xid) + self._player.play(url) + finally: + self.set_playback_elms_active() + else: + if not self._player_box.get_visible(): w, h = self._main_window.get_size() self._player_box.set_size_request(w * 0.6, -1) + self._current_mrl = url + self._player_box.set_visible(True) - self._player_box.set_visible(True) - self._fav_view.set_sensitive(False) - GLib.idle_add(self._player.play, url, priority=GLib.PRIORITY_LOW) + if self._player and self._player.get_play_mode() is PlayStreamsMode.BUILT_IN: + GLib.idle_add(self._player.play, url, priority=GLib.PRIORITY_LOW) + elif self._player: + self.show_error_dialog("Play mode has been changed!\nRestart the program to apply the settings.") + self.set_playback_elms_active() def on_player_stop(self, item=None): if self._player: @@ -1794,11 +1811,26 @@ class Application(Gtk.Application): return "{}{:02d}:{:02d}".format(str(h) + ":" if h else "", m, s) def on_drawing_area_realize(self, widget): - if sys.platform == "darwin": - self._player.set_nso(widget) - else: - self._drawing_area_xid = widget.get_window().get_xid() - self._player.set_xwindow(self._drawing_area_xid) + w, h = self._main_window.get_size() + widget.set_size_request(w * 0.6, -1) + + if not self._player: + try: + self._player = Player.get_instance(rewind_callback=self.on_player_duration_changed, + position_callback=self.on_player_time_changed, + error_callback=self.on_player_error, + playing_callback=self.set_playback_elms_active) + except (ImportError, NameError, AttributeError): + self.show_error_dialog("No VLC is found. Check that it is installed!") + return True + else: + if self._settings.is_darwin: + self._player.set_nso(widget) + else: + self._player.set_xwindow(widget.get_window().get_xid()) + self._player.play(self._current_mrl) + finally: + self.set_playback_elms_active() def on_player_drawing_area_draw(self, widget, cr): """ Used for black background drawing in the player drawing area. @@ -1932,17 +1964,37 @@ class Application(Gtk.Application): error_code = data.get("error_code", 0) if error_code or self._http_status_image.get_visible(): self.show_error_dialog("No connection to the receiver!") + self.set_playback_elms_active() return m3u = data.get("m3u", None) if m3u: return [s for s in m3u.split("\n") if not s.startswith("#")][0] + def save_stream_to_m3u(self, url): + path, column = self._fav_view.get_cursor() + s_name = self._fav_model.get_value(self._fav_model.get_iter(path), Column.FAV_SERVICE) if path else "stream" + + try: + response = show_dialog(DialogType.CHOOSER, self._main_window, settings=self._settings) + if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): + return + + with open("{}{}.m3u".format(response, s_name), "w", encoding="utf-8") as file: + file.writelines("#EXTM3U\n#EXTVLCOPT--http-reconnect=true\n#EXTINF:-1,{}\n{}\n".format(s_name, url)) + except IOError as e: + self.show_error_dialog(str(e)) + else: + show_dialog(DialogType.INFO, self._main_window, "Done!") + finally: + GLib.idle_add(self._fav_view.set_sensitive, True) + @run_idle def on_zap(self, callback=None): """ Switch(zap) the channel """ path, column = self._fav_view.get_cursor() if not path or not self._http_api: + self.set_playback_elms_active() return ref = self.get_service_ref(path) @@ -1957,6 +2009,9 @@ class Application(Gtk.Application): GLib.idle_add(scroll_to, path, self._fav_view) if callback: callback() + else: + self.show_error_dialog("No connection to the receiver!") + self.set_playback_elms_active() self._http_api.send(HttpRequestType.ZAP, ref, zap) @@ -1966,6 +2021,7 @@ class Application(Gtk.Application): if srv_type == BqServiceType.IPTV.name or srv_type == BqServiceType.MARKER.name: self.show_error_dialog("Not allowed in this context!") + self.set_playback_elms_active() return srv = self._services.get(fav_id, None) diff --git a/app/ui/settings_dialog.glade b/app/ui/settings_dialog.glade index 814c6798..c7980fe3 100644 --- a/app/ui/settings_dialog.glade +++ b/app/ui/settings_dialog.glade @@ -1953,6 +1953,7 @@ Author: Dmitriy Yefremov True True get_m3u_radio_button + False @@ -1984,7 +1985,6 @@ Author: Dmitriy Yefremov False True True - play_in_built_radio_button False @@ -1998,7 +1998,7 @@ Author: Dmitriy Yefremov True False - Play streams in: + Play streams mode: diff --git a/app/ui/settings_dialog.py b/app/ui/settings_dialog.py index 4c8760ed..c4532bf3 100644 --- a/app/ui/settings_dialog.py +++ b/app/ui/settings_dialog.py @@ -5,7 +5,7 @@ from enum import Enum from app.commons import run_task, run_idle from app.connections import test_telnet, test_ftp, TestException, test_http, HttpApiException from app.settings import SettingsType, Settings, PlayStreamsMode -from app.ui.dialogs import show_dialog, DialogType +from app.ui.dialogs import show_dialog, DialogType, get_message from .main_helper import update_entry_data, scroll_to from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, FavClickMode, DEFAULT_ICON @@ -51,6 +51,7 @@ class SettingsDialog: "on_network_settings_visible": self.on_network_settings_visible, "on_http_use_ssl_toggled": self.on_http_use_ssl_toggled, "on_click_mode_togged": self.on_click_mode_togged, + "on_play_mode_changed": self.on_play_mode_changed, "on_transcoding_preset_changed": self.on_transcoding_preset_changed, "on_apply_presets": self.on_apply_presets, "on_digit_entry_changed": self.on_digit_entry_changed, @@ -381,7 +382,7 @@ class SettingsDialog: def show_info_message(self, text, message_type): self._info_bar.set_visible(True) self._info_bar.set_message_type(message_type) - self._message_label.set_text(text) + self._message_label.set_text(get_message(text)) @run_idle def show_spinner(self, show): @@ -547,6 +548,13 @@ class SettingsDialog: return FavClickMode.DISABLED + def on_play_mode_changed(self, button): + if self._main_stack.get_visible_child_name() != "streaming": + return + + if button.get_active(): + self.show_info_message("Save and restart the program to apply the settings.", Gtk.MessageType.WARNING) + @run_idle def set_play_stream_mode(self, mode): self._play_in_built_radio_button.set_sensitive(not self._settings.is_darwin)