initial commit

This commit is contained in:
2025-07-20 00:51:55 +03:00
commit 42684e0cb6
29 changed files with 2615 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
from web.web import *
+3
View File
@@ -0,0 +1,3 @@
from web.custom_widgets.content_card import ContentCard
from web.custom_widgets.content_dialog import ContentDialog
from web.custom_widgets.header import draw_header
+48
View File
@@ -0,0 +1,48 @@
from nicegui import ui
import globals
from web.custom_widgets import ContentDialog
class ContentCard:
def __init__(self, tmdb_id: int):
self.content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
self.dialog = ContentDialog(tmdb_id)
self.card = ui.card()
self.card.classes("no-shadow")
self.card.style("margin: 0; padding: 6px; border-radius: 20px")
with self.card:
self.poster_image = ui.image(source=self.content.poster_url)
self.poster_image.style("width: 200px; height: 100%; border-radius: 15px")
with self.poster_image:
self.poster_column = ui.column()
self.poster_column.classes("w-full h-full justify-between")
self.poster_column.style(
"margin: 0; padding:0; opacity: 0; transition-duration: 0.5s")
with self.poster_column:
with ui.column(wrap=False).classes("w-full").style("gap: 0px; margin: 0; padding: 3px 3px 3px 10px;"):
ui.html(f"<b>{self.content.title}</b>").style("font-size: 16px")
ui.html(f"<i>{self.content.og_title}</i>").style("font-size: 10px")
with ui.column(wrap=True).classes("w-full").style("gap: 0px; margin: 0; padding: 3px 3px 10px 10px"):
ui.html(f"<b><i>{"<br>".join((self.content.genres))}</i></b>").style("font-size: 8px")
self.card.on("mouseover", self.on_card_hover)
self.card.on("mouseleave", self.on_card_unhover)
self.card.on("click", self.on_card_click)
def on_card_hover(self):
self.poster_column.style("opacity: 0.9")
self.card.style("transform: scale(1.02)")
def on_card_unhover(self):
self.poster_column.style("opacity: 0")
self.card.style("transform: scale(1.0)")
def on_card_click(self):
self.dialog.open()
+66
View File
@@ -0,0 +1,66 @@
import itertools
from nicegui import ui
import globals
from web.misc import convert_runtime
class ContentDialog(ui.dialog):
def __init__(self, tmdb_id: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
with self:
self.card = ui.card()
self.card.classes("h-max no-shadow")
self.card.style("min-width: 40%; min-height: 60%; border-radius: 15px")
with self.card:
self.title_column = ui.column(wrap=False)
self.title_column.classes("w-full")
self.title_column.style("gap: 0; margin: 0; padding: 0;")
with self.title_column:
ui.html(f"<b>{self.content.title}</b>").style("font-size: 22px")
ui.html(f"<i>{self.content.og_title}</i>").style("font-size: 14px")
ui.html(f"<b><i>{", ".join(self.content.genres)}</i></b>").style("font-size: 12px")
with self.card:
self.main_row = ui.row(wrap=False)
self.main_row.classes("w-full h-full")
with self.main_row:
ui.image(source=self.content.poster_url).style("width: 50%; border-radius: 15px")
self.additional_info_column = ui.column(wrap=False)
self.additional_info_column.style("width: 50%")
with self.additional_info_column:
release_date = self.content.release_date.split("-")[::-1]
release_date = ".".join(release_date)
ui.html(f"<b>Release Date: </b>{release_date}").style("font-size: 12px")
ui.html(f"<b>Average Score: </b>{round(self.content.vote_average, 2)}").style("font-size: 12px")
if self.content.type == "movie":
budget = "$" + f"{self.content.budget:_}".replace("_", ".")
ui.html(f"<b>Budget: </b>{budget}").style("font-size: 12px")
ui.html(f"<b>Runtime: </b>{convert_runtime(self.content.runtime)}").style("font-size: 12px")
elif self.content.type == "tv":
ui.html(f"<b>Number of Seasons: </b>{self.content.number_of_seasons}").style("font-size: 12px")
ui.html(f"<b>Number of Episodes: </b>{self.content.number_of_episodes}").style("font-size: 12px")
total_runtime = sum([ep.runtime for ep in
itertools.chain.from_iterable([s.episodes for s in self.content.seasons])])
ui.html(f"<b>Total Runtime: </b>{convert_runtime(total_runtime)}").style("font-size: 12px")
if self.content.in_production:
ui.html(f"<b><i>Currently in production</i></b>").style("font-size: 12px")
else:
ui.html(f"<b><i>Finished</i></b>").style("font-size: 12px")
with self.card, ui.row(wrap=False).classes("w-full").style("display: flex; justify-content: center;"):
ui.button("Create Room").on_click(self.create_room)
async def create_room(self):
room = await globals.ROOMS_DATABASE.create_room(self.content.tmdb_id)
ui.navigate.to(f"/room/{room.uid}")
+11
View File
@@ -0,0 +1,11 @@
from nicegui import ui
from web.misc import logout
async def draw_header():
with ui.header(wrap=False):
with ui.row(wrap=False).classes("w-full justify-between") as header_row:
ui.button(icon="home", on_click=lambda: ui.navigate.to("/"))
ui.button(text="Rooms", icon="movie", on_click=lambda: ui.navigate.to("/rooms"))
ui.button(icon="logout", on_click=lambda: logout(redirect=True))
+50
View File
@@ -0,0 +1,50 @@
from nicegui import ui, app
import globals
from users.classes import User
def convert_runtime(total_minutes: int) -> str:
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0 and minutes > 0:
return f"{hours} h {minutes} m"
elif hours > 0:
return f"{hours} h"
elif minutes > 0:
return f"{minutes} m"
else:
return "0 m"
def logout(redirect: bool = False):
if app.storage.user.get("token"):
app.storage.user.pop("token")
if redirect:
ui.navigate.to("/")
async def check_user() -> User | None:
if token := app.storage.user.get("token"):
user = await globals.USERS_DATABASE.get_user_by_token(token)
if user:
return user
else:
logout()
return None
else:
return None
async def update_user():
if token := app.storage.user.get("token"):
user = await globals.USERS_DATABASE.get_user_by_token(token)
if user:
await globals.USERS_DATABASE.update_user(user.uid)
else:
app.storage.user.pop("token")
ui.navigate.to("/")
else:
ui.navigate.to("/")
+4
View File
@@ -0,0 +1,4 @@
import web.pages.all_rooms as rooms_page
import web.pages.contents as movies_page
import web.pages.index as index_page
import web.pages.room as room_page
+30
View File
@@ -0,0 +1,30 @@
from nicegui import ui
import globals
from web.custom_widgets import draw_header
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends - Rooms")
if not await check_user():
ui.navigate.to("/")
await draw_header()
main_row = ui.row().classes("w-full")
for room in globals.ROOMS_DATABASE.rooms:
with main_row, ui.link(target=f"/room/{room.uid}"), ui.card().style("border-radius: 15px"):
content = globals.MOVIES_DATABASE.by_tmdb_id[room.tmdb_id]
with ui.row(wrap=False):
ui.image(content.poster_url).style("width: 20%")
with ui.column(wrap=False).classes("").style("gap: 0px"):
ui.html(f"{room.uid}")
ui.html(f"<b>{content.title}</b>")
users = [globals.USERS_DATABASE.by_uid[uid] for uid in room.connected_users]
ui.html(f"<i>{", ".join(u.username for u in users)}</i>")
+18
View File
@@ -0,0 +1,18 @@
from nicegui import ui
import globals
from web.custom_widgets import draw_header, ContentCard
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends - Contents")
if not await check_user():
ui.navigate.to("/")
await draw_header()
with ui.row(wrap=True).classes("w-full").style("margin: auto; gap: 16px"):
for content in globals.MOVIES_DATABASE.contents:
ContentCard(content.tmdb_id)
+43
View File
@@ -0,0 +1,43 @@
from nicegui import ui, app
import config
import globals
from users.utils import generate_token
from web.misc import check_user
async def page():
ui.page_title("Watch With Friends")
if not await check_user():
await handle_login()
ui.navigate.to("/contents")
async def handle_login():
async def try_create_user():
login = login_input.value.strip()
password = password_input.value.strip()
if password != config.PASSWORD:
ui.notify("Incorrect password!", type="negative", position="top")
return
new_user = await globals.USERS_DATABASE.create_user(login)
token = generate_token(new_user)
if token:
app.storage.user["token"] = token
dialog.close()
else:
ui.notify("Something went wrong!", type="negative", position="top")
with ui.dialog().props("persistent") as dialog, ui.card():
ui.label("Login")
with ui.column():
login_input = ui.input("Username").classes("w-full")
password_input = ui.input("Password", password=True, password_toggle_button=True).classes("w-full")
ui.button("Login", on_click=try_create_user).classes("w-full")
await dialog
+227
View File
@@ -0,0 +1,227 @@
import asyncio
import logging
from functools import partial
from nicegui import ui
import config
import globals
from rooms.state import PlayerState
from web.custom_widgets.header import draw_header
from web.misc import check_user
def _check_room(room_uid: str):
return not globals.ROOMS_DATABASE.by_uid.get(room_uid) is None
async def _join_room(room_uid: str, user_uid: str):
for _ in range(10):
if globals.ROOMS_DATABASE.by_uid.get(room_uid) is None:
await asyncio.sleep(0.1)
continue
break
if globals.ROOMS_DATABASE.by_uid.get(room_uid) is None:
ui.notify(f"Failed to join room {room_uid}, redirecting to /rooms...", type="warning")
await asyncio.sleep(1)
ui.navigate.to("/rooms")
return
globals.ROOMS_DATABASE.by_uid[room_uid].connected_users.append(user_uid)
logging.info(f"{user_uid} joined room {room_uid}")
def _leave_room(room_uid: str, user_uid: str):
if user_uid in globals.ROOMS_DATABASE.by_uid[room_uid].connected_users:
globals.ROOMS_DATABASE.by_uid[room_uid].connected_users.remove(user_uid)
logging.info(f"{user_uid} left room {room_uid}")
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):
try:
new_episode = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id].seasons[season_number - 1].episodes[
episode_number - 1]
except KeyError:
ui.notify("Episode not found", type="negative")
return
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.current_season, room.current_episode = season_number, episode_number
room.player_position = 0
room.player_state = PlayerState.PAUSED
player_data["season"], player_data["episode"] = season_number, episode_number
player_data["position"] = 0
video_player.pause()
video_player.seek(0)
video_player.pause()
video_player.set_source(new_episode.file_url)
seasons_column.clear()
_draw_seasons(room_uid, tmdb_id, seasons_column, video_player, player_data)
def _draw_users_list(room_uid: str, users_card: ui.card):
users_card.clear()
room = globals.ROOMS_DATABASE.by_uid[room_uid]
for user_uid in room.connected_users:
user = globals.USERS_DATABASE.by_uid[user_uid]
with users_card, ui.card().classes("w-full"):
ui.label(user.username)
def _draw_seasons(room_uid: str, tmdb_id: int, seasons_column: ui.column, video_player: ui.video,
player_data: dict):
room = globals.ROOMS_DATABASE.by_uid[room_uid]
tv_show = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
for season_number in range(1, tv_show.number_of_seasons + 1):
season = tv_show.seasons[season_number - 1]
with seasons_column, ui.card().classes("w-full"):
ui.html(f"<b>{season.title}</b>")
with ui.row().classes("w-full"):
episodes = tv_show.seasons[season_number - 1].episodes
for episode_number in range(1, len(episodes) + 1):
episode_button = ui.button(str(episode_number),
on_click=partial(_change_episode, room_uid, tmdb_id, season_number,
episode_number, seasons_column, video_player,
player_data))
if room.current_season == season_number and room.current_episode == episode_number:
episode_button.disable()
async def _on_seeked(room_uid: str, video_player: ui.video, player_data: dict):
try:
position = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime')
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):
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):
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):
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,
player_data: dict):
try:
player_data["position"] = await ui.run_javascript(f'getHtmlElement({video_player.id}).currentTime')
except TimeoutError:
return
room = globals.ROOMS_DATABASE.by_uid[room_uid]
if player_data["state"] != room.player_state:
match room.player_state:
case PlayerState.PLAYING:
video_player.play()
player_data["state"] = PlayerState.PLAYING
case PlayerState.PAUSED:
video_player.pause()
player_data["state"] = PlayerState.PAUSED
case PlayerState.STOPPED:
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 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
_change_episode(room_uid, tmdb_id, room.current_season, room.current_episode, seasons_column, video_player,
player_data)
async def page(room_uid: str):
if not _check_room(room_uid):
ui.navigate.to("/rooms")
return
user = await check_user()
if not user:
ui.navigate.to("/")
return
await draw_header()
await _join_room(room_uid, user.uid)
player_data = {
"state": PlayerState.PAUSED,
"position": 0,
"season": None,
"episode": None
}
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("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")
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.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("seeked", partial(_on_seeked, room_uid, video_player, player_data))
video_player.on("ended", partial(_on_stop, room_uid, video_player, player_data))
seasons_column = ui.column().classes("w-full")
seasons_column.visible = False
tmdb_id = globals.ROOMS_DATABASE.by_uid[room_uid].tmdb_id
content = globals.MOVIES_DATABASE.by_tmdb_id[tmdb_id]
if content.type == "movie":
source = content.file_url
elif content.type == "tv":
source = content.seasons[0].episodes[0].file_url
room = globals.ROOMS_DATABASE.by_uid[room_uid]
room.current_season, room.current_episode = 1, 1
player_data["season"], player_data["episode"] = 1, 1
seasons_column.visible = True
_draw_seasons(room_uid, tmdb_id, seasons_column, video_player, player_data)
else:
source = ""
video_player.set_source(source)
ui.timer(1, partial(_sync, room_uid, tmdb_id, seasons_column, video_player, player_data))
await ui.context.client.disconnected()
_leave_room(room_uid, user.uid)
+40
View File
@@ -0,0 +1,40 @@
from nicegui import ui
import config
from web.misc import update_user
from web.pages import *
@ui.page("/")
async def index():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
await index_page.page()
@ui.page("/contents")
async def movies():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await movies_page.page()
@ui.page("/rooms")
async def rooms():
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await rooms_page.page()
@ui.page("/room/{room_uid}")
async def room(room_uid: str):
ui.add_head_html("<meta name=\"referrer\" content=\"no-referrer\" />")
ui.add_head_html('''
<link href="https://vjs.zencdn.net/7.21.1/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/7.21.1/video.min.js"></script>
''')
await ui.context.client.connected(timeout=config.CONNECTION_TIMEOUT_SECONDS)
ui.timer(60, update_user)
await room_page.page(room_uid)