diff --git a/web/custom_widgets/PlyrVideoPlayer.py b/web/custom_widgets/PlyrVideoPlayer.py new file mode 100644 index 0000000..125fbe8 --- /dev/null +++ b/web/custom_widgets/PlyrVideoPlayer.py @@ -0,0 +1,118 @@ +import uuid + +from nicegui import ui + + +class PlyrVideoPlayer: + _plyr_installed = False + + def __init__(self, src: str, poster_url: str = None): + if not PlyrVideoPlayer._plyr_installed: + install_plyr() + PlyrVideoPlayer._plyr_installed = True + + self.src = src + self.poster_url = poster_url + + self.element_id = f"plyr_{uuid.uuid4().hex}" + self.player_var = f"player_{self.element_id}" + self.event_play = f"{self.element_id}_playing" + self.event_pause = f"{self.element_id}_pause" + self.event_end = f"{self.element_id}_ended" + self.event_seeked = f"{self.element_id}_seeked" + + poster_attr = f"data-poster=\"{poster_url}\"" if poster_url else "" + source_html = f"" + video_html = ( + f"" + ) + + with ui.element("div").classes("plyr-container"): + ui.html(video_html) + + options = {} + options.setdefault("settings", []) + options.setdefault("ratio", "16:9") + options.setdefault("controls", + ["play-large", "play", "rewind", "fast-forward", "current-time", "progress", "duration", + "mute", "volume", "pip", "airplay", "download", "fullscreen"] + ) + js_options = str(options).replace("True", "true").replace("'", '"') + + js = f""" + (async () => {{ + const video = document.getElementById('{self.element_id}'); + const player = new Plyr(video, {js_options}); + window.{self.player_var} = player; + + player.on('play', () => emitEvent('{self.event_play}')); + player.on('pause', () => emitEvent('{self.event_pause}')); + player.on('ended', () => emitEvent('{self.event_end}')); + player.on('seeked', () => emitEvent('{self.event_seeked}')); + }})(); + """ + ui.run_javascript(js) + + def on(self, event: str, callback: callable): + mapping = { + "play": self.event_play, + "pause": self.event_pause, + "end": self.event_end, + "seeked": self.event_seeked + } + if event not in mapping: + raise ValueError("Supported events: 'play', 'pause', 'end', 'seeked'") + ui.on(mapping[event], callback) + + async def is_seeking(self): + return await ui.run_javascript(f"window.{self.player_var}.seeking") + + async def get_audio_tracks(self): + return await ui.run_javascript(f"window.{self.player_var}.audio_tracks") + + def play(self): + ui.run_javascript(f"window.{self.player_var}.play();") + + def pause(self): + ui.run_javascript(f"window.{self.player_var}.pause();") + + def seek(self, time: float): + ui.run_javascript(f"window.{self.player_var}.currentTime = {time};") + + def set_source(self, src: str, poster_url: str = "", type: str = 'video/mp4'): + self.src = src + self.poster_url = poster_url + ui.run_javascript(f""" + window.{self.player_var}.source = {{ + type: 'video', + sources: [ + {{ + src: '{src}', + type: '{type}', + }}, + ], + poster: '{poster_url}', + }}; + """) + + async def get_current_position(self) -> float: + result = await ui.run_javascript(f"return window.{self.player_var}.currentTime;") + return float(result) + + +def install_plyr(css: str = "https://cdn.plyr.io/3.7.8/plyr.css", + js: str = "https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"): + ui.add_head_html(f''' + + + + ''') diff --git a/web/custom_widgets/__init__.py b/web/custom_widgets/__init__.py index 3acf89b..1a4be00 100644 --- a/web/custom_widgets/__init__.py +++ b/web/custom_widgets/__init__.py @@ -1,3 +1,4 @@ +from web.custom_widgets.header import draw_header from web.custom_widgets.content_dialog import ContentDialog from web.custom_widgets.content_card import ContentCard -from web.custom_widgets.header import draw_header +from web.custom_widgets.PlyrVideoPlayer import PlyrVideoPlayer diff --git a/web/pages/room.py b/web/pages/room.py index 4b288a0..1a97998 100644 --- a/web/pages/room.py +++ b/web/pages/room.py @@ -7,6 +7,7 @@ from nicegui import ui import config import globals from rooms.state import PlayerState +from web.custom_widgets import PlyrVideoPlayer from web.custom_widgets.header import draw_header from web.misc import check_user @@ -40,7 +41,7 @@ def _leave_room(room_uid: str, user_uid: str): def _change_episode(room_uid: str, tmdb_id: int, season_number: int, episode_number: int, seasons_column: ui.column, - video_player: ui.video, player_data: dict): + video_player: PlyrVideoPlayer, player_data: dict): try: new_episode = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id].seasons[season_number - 1].episodes[ episode_number - 1] @@ -73,7 +74,7 @@ def _draw_users_list(room_uid: str, users_card: ui.card): ui.label(user.username) -def _draw_seasons(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: ui.video, +def _draw_seasons(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: PlyrVideoPlayer, player_data: dict): room = globals.ROOMS_DATABASE.by_uid[room_uid] tv_show = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id] @@ -95,41 +96,38 @@ def _draw_seasons(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_ episode_button.disable() -async def _on_seeked(room_uid: str, video_player: ui.video, player_data: dict): +async def _on_seeked(room_uid: str, video_player: PlyrVideoPlayer, player_data: dict): try: - position = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime') + position = await video_player.get_current_position() except TimeoutError: return globals.ROOMS_DATABASE.by_uid[room_uid].seek(position) player_data["position"] = position -async def _on_play(room_uid: str, video_player: ui.video, player_data: dict): +async def _on_play(room_uid: str, player_data: dict): room = globals.ROOMS_DATABASE.by_uid[room_uid] room.play() player_data["state"] = PlayerState.PLAYING - await _on_seeked(room_uid, video_player, player_data) -async def _on_pause(room_uid: str, video_player: ui.video, player_data: dict): +async def _on_pause(room_uid: str, player_data: dict): room = globals.ROOMS_DATABASE.by_uid[room_uid] room.pause() player_data["state"] = PlayerState.PAUSED - await _on_seeked(room_uid, video_player, player_data) -async def _on_stop(room_uid: str, video_player: ui.video, player_data: dict): +async def _on_stop(room_uid: str, video_player: PlyrVideoPlayer, player_data: dict): room = globals.ROOMS_DATABASE.by_uid[room_uid] room.stop() player_data["state"] = PlayerState.STOPPED - await _on_seeked(room_uid, video_player, player_data) video_player.pause() -async def _sync(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: ui.video, +async def _sync(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: PlyrVideoPlayer, player_data: dict): try: - player_data["position"] = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime') + player_data["position"] = await video_player.get_current_position() except TimeoutError: return room = globals.ROOMS_DATABASE.by_uid[room_uid] @@ -146,9 +144,11 @@ async def _sync(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_pl video_player.pause() player_data["state"] = PlayerState.STOPPED - if abs(player_data["position"] - room.player_position) > config.MAX_DELAY_SECONDS: - video_player.seek(room.player_position) - player_data["position"] = room.player_position + if not await video_player.is_seeking(): + if (player_data["state"] != PlayerState.PLAYING + or abs(player_data["position"] - room.player_position) > config.MAX_DELAY_SECONDS): + video_player.seek(room.player_position) + player_data["position"] = room.player_position if player_data["season"] != room.current_season or player_data["episode"] != room.current_episode: player_data["season"], player_data["episode"] = room.current_season, room.current_episode @@ -180,23 +180,21 @@ async def page(room_uid: str): with ui.row(wrap=False).classes("w-full"): player_card = ui.card() player_card.classes("no-shadow") - player_card.style("width: 80%; height: 85vh; border-radius: 15px") + player_card.style("width: 80%; min-height: 80vh; max-height: 80vh; border-radius: 15px") player_card.style("display: flex; justify-content: center; align-items: center;") users_card = ui.card() users_card.classes("no-shadow") - users_card.style("width: 20%; height: 85vh; border-radius: 15px") + users_card.style("width: 20%; min-height: 80vh; max-height: 80vh; border-radius: 15px") ui.timer(1, partial(_draw_users_list, room_uid, users_card)) with player_card: - video_player = ui.video(src="") - video_player.classes("w-full h-full") - video_player.style("border-radius: 15px") + video_player = PlyrVideoPlayer(src="") - video_player.on("play", partial(_on_play, room_uid, video_player, player_data)) - video_player.on("pause", partial(_on_pause, room_uid, video_player, player_data)) + video_player.on("play", partial(_on_play, room_uid, player_data)) + video_player.on("pause", partial(_on_pause, room_uid, player_data)) video_player.on("seeked", partial(_on_seeked, room_uid, video_player, player_data)) - video_player.on("ended", partial(_on_stop, room_uid, video_player, player_data)) + video_player.on("end", partial(_on_stop, room_uid, video_player, player_data)) seasons_column = ui.column().classes("w-full") seasons_column.visible = False @@ -205,9 +203,11 @@ async def page(room_uid: str): content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id] if content.type == "movie": - source = content.file_url + video = content.file_url + poster = content.backdrop_url elif content.type == "tv": - source = content.seasons[0].episodes[0].file_url + video = content.seasons[0].episodes[0].file_url + poster = "" room = globals.ROOMS_DATABASE.by_uid[room_uid] room.current_season, room.current_episode = 1, 1 @@ -216,9 +216,10 @@ async def page(room_uid: str): seasons_column.visible = True _draw_seasons(room_uid, tmdb_id, seasons_column, video_player, player_data) else: - source = "" + video = "" + poster = "" - video_player.set_source(source) + video_player.set_source(video, poster) ui.timer(1, partial(_sync, room_uid, tmdb_id, seasons_column, video_player, player_data)) diff --git a/web/web.py b/web/web.py index f84b5d3..df2dadc 100644 --- a/web/web.py +++ b/web/web.py @@ -1,6 +1,7 @@ from nicegui import ui import config +from web.custom_widgets.PlyrVideoPlayer import install_plyr from web.misc import update_user from web.pages import * @@ -31,10 +32,7 @@ async def rooms(): @ui.page("/room/{room_uid}") async def room(room_uid: str): ui.add_head_html("") - ui.add_head_html(''' - - - ''') + install_plyr() await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS) ui.timer(60, update_user) await room_page.page(room_uid)