diff --git a/README.md b/README.md index f296625..df7bc7d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ PyTube Downloader is a user-friendly application that allows users to download Y - **Automatic Download With Predefined Settings** Users can set predefined download settings such as preferred video quality, audio format, download location, and more. Once a YouTube URL is added, the video will load and then start to download automatically according to these predefined settings. - **System Tray Icon Mode:** Users can easily minimize the application to the system tray for unobtrusive operation. - **Theme Customization:** Personalize your experience with the ability to switch between dark and light themes. Additionally, customize the accent color to suit your preferences, creating a visually pleasing interface tailored to your style. +- **Scaling Preferences:** Users can scale the application interface from 100% to 200%, adjusting the size of widgets and elements for better readability and usability. --- @@ -30,8 +31,8 @@ PyTube Downloader is a user-friendly application that allows users to download Y ![1](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/72489e71-95a6-4fa8-8da4-591a7d6e7adb) ![2](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/6f4ce65d-93d5-4451-9bce-5b68ad276faa) ![3](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/154d93da-3b39-49b5-b98a-a658907ac283) -![4](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/66318196-3d48-4c13-9ac4-93bbbce52248) -![5](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/cb8f9332-025e-40ba-91d8-d66c35cc2d42) +![4](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/c5d33638-f613-4256-8b0b-cc3f1b90457a) +![5](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/fb3e7215-921c-464c-8aa7-33b104a6af91) ![6](https://github.com/Thisal-D/PyTube-Downloader/assets/93121062/3517839d-1774-4b3d-b02d-d6c3cff94811) --- diff --git a/VERSION b/VERSION index 606c765..fc1f9d1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION = '0.0.4' +VERSION = '0.0.5' diff --git a/app.py b/app.py index 6e03344..c454612 100644 --- a/app.py +++ b/app.py @@ -12,7 +12,7 @@ ) from settings import ( ThemeSettings, - GeneralSettings + GeneralSettings, ) from utils import ( FileUtility @@ -71,15 +71,11 @@ def __init__(self): def create_widgets(self): self.url_entry = ctk.CTkEntry( master=self, - height=40, placeholder_text="Enter Youtube URL" ) self.video_radio_btn = ctk.CTkRadioButton( master=self, text="Video", - radiobutton_width=16, - radiobutton_height=16, - width=60, height=18, command=lambda: self.select_download_mode("video") ) @@ -88,18 +84,12 @@ def create_widgets(self): self.playlist_radio_btn = ctk.CTkRadioButton( master=self, text="Playlist", - radiobutton_width=16, - radiobutton_height=16, - width=60, - height=18, command=lambda: self.select_download_mode("playlist") ) self.add_url_btn = ctk.CTkButton( master=self, text="Add +", - height=40, - width=100, border_width=2, command=self.add_video_playlist ) @@ -115,21 +105,18 @@ def create_widgets(self): self.navigate_added_frame_btn = ctk.CTkButton( master=self, text="Added", - height=40, command=lambda: self.place_frame(self.added_content_scroll_frame, "added") ) self.navigate_downloading_frame_btn = ctk.CTkButton( master=self, text="Downloading", - height=40, command=lambda: self.place_frame(self.downloading_content_scroll_frame, "downloading") ) self.navigate_downloaded_frame_btn = ctk.CTkButton( master=self, text="Downloaded", - height=40, command=lambda: self.place_frame(self.downloaded_content_scroll_frame, "downloaded") ) @@ -151,7 +138,8 @@ def create_widgets(self): self.settings_panel = SettingPanel( master=self, theme_settings_change_callback=self.update_theme_settings, - general_settings_change_callback=self.update_general_settings + general_settings_change_callback=self.update_general_settings, + restart_callback=self.restart ) self.settings_btn = ctk.CTkButton( @@ -159,28 +147,15 @@ def create_widgets(self): text="⚡", border_spacing=0, hover=False, - width=30, - height=40, command=self.open_settings ) self.context_menu = ContextMenu( master=self, - width=100, - height=120, + width=100 * GeneralSettings.settings["scale_r"], + height=120 * GeneralSettings.settings["scale_r"], ) - def place_widgets(self): - self.settings_btn.place(x=-5, y=4) - self.url_entry.place(x=43, y=4) - self.add_url_btn.place(y=4) - self.video_radio_btn.place(y=5) - self.playlist_radio_btn.place(y=25) - self.navigate_added_frame_btn.place(y=50, x=10) - self.navigate_downloading_frame_btn.place(y=50) - self.navigate_downloaded_frame_btn.place(y=50) - self.place_frame(self.added_content_scroll_frame, "added") - def place_forget_frames(self): self.added_content_scroll_frame.place_forget() self.downloading_content_scroll_frame.place_forget() @@ -210,13 +185,86 @@ def place_label(self, frame_name: str): def place_frame(self, frame: ctk.CTkScrollableFrame, frame_name: str): self.place_forget_frames() - frame.place(y=90, x=10) + frame.place(y=90 * GeneralSettings.settings["scale_r"], x=10) self.place_label(frame_name) + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + self.settings_btn.place(x=-5, y=4) + self.url_entry.place(x=43 * scale, y=4) + self.add_url_btn.place(y=4) + self.video_radio_btn.place(y=5) + self.playlist_radio_btn.place(y=25 * scale) + self.navigate_added_frame_btn.place(y=50 * scale, x=10) + self.navigate_downloading_frame_btn.place(y=50 * scale) + self.navigate_downloaded_frame_btn.place(y=50 * scale) + self.place_frame(self.added_content_scroll_frame, "added") + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + self.url_entry.configure( + font=ctk.CTkFont( + family="Segoe UI", + size=int(16 * scale), + weight="normal", + slant="italic", + underline=True + ) + ) + + self.video_radio_btn.configure(font=("Segoe UI", 12 * scale, "bold")) + self.playlist_radio_btn.configure(font=("Segoe UI", 12 * scale, "bold")) + self.add_url_btn.configure(font=("Segoe UI", 15 * scale, "bold")) + + font_style_1 = ctk.CTkFont( + family="Comic Sans MS", + size=int(16 * scale), + weight="bold", + slant="italic" + ) + self.added_frame_info_label.configure(font=font_style_1) + self.downloading_frame_info_label.configure(font=font_style_1) + self.downloaded_frame_info_label.configure(font=font_style_1) + + font_style_2 = ("Segoe UI", 15 * scale, "bold") + self.navigate_added_frame_btn.configure(font=font_style_2) + self.navigate_downloading_frame_btn.configure(font=font_style_2) + self.navigate_downloaded_frame_btn.configure(font=font_style_2) + self.settings_btn.configure(font=("arial", 25 * scale, "normal")) + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.url_entry.configure(height=40 * scale) + self.video_radio_btn.configure( + radiobutton_width=16 * scale, radiobutton_height=16 * scale, + width=60 * scale, height=18 * scale + ) + self.playlist_radio_btn.configure( + radiobutton_width=16 * scale, radiobutton_height=16 * scale, + width=60 * scale, height=18 * scale + ) + self.add_url_btn.configure( + height=40 * scale, + width=100 * scale, + ) + self.navigate_added_frame_btn.configure( + height=40 * scale + ) + self.navigate_downloading_frame_btn.configure( + height=40 * scale + ) + self.navigate_downloaded_frame_btn.configure( + height=40 * scale + ) + self.settings_btn.configure( + width=30 * scale, height=40 * scale, + ) + def configure_widgets_size(self): + scale = GeneralSettings.settings["scale_r"] root_width = self.winfo_width() root_height = self.winfo_height() - self.url_entry.configure(width=root_width - 250) + self.url_entry.configure(width=root_width - 250 * scale) btn_width = (root_width - 26) / 3 self.navigate_added_frame_btn.configure(width=btn_width) @@ -226,9 +274,9 @@ def configure_widgets_size(self): self.navigate_downloading_frame_btn.place(x=btn_width + 10 + 3) self.navigate_downloaded_frame_btn.place(x=btn_width * 2 + 10 + 6) - self.video_radio_btn.place(x=self.winfo_width() - 190) - self.playlist_radio_btn.place(x=self.winfo_width() - 190) - self.add_url_btn.place(x=self.winfo_width() - 110) + self.video_radio_btn.place(x=self.winfo_width() - 190 * scale) + self.playlist_radio_btn.place(x=self.winfo_width() - 190 * scale) + self.add_url_btn.place(x=self.winfo_width() - 110 * scale) if self.added_frame_info_label_placed: self.place_label("added") @@ -237,7 +285,7 @@ def configure_widgets_size(self): elif self.downloaded_frame_info_label_placed: self.place_label("downloaded") - frame_height = root_height - 105 + frame_height = root_height - 105 * scale frame_width = root_width - 40 self.added_content_scroll_frame.configure( height=frame_height, @@ -564,40 +612,6 @@ def mouse_ot_downloaded_frame_info_label(_event): self.downloaded_frame_info_label.bind("", on_mouse_enter_downloaded_frame_info_label) self.downloaded_frame_info_label.bind("", mouse_ot_downloaded_frame_info_label) - def set_widgets_fonts(self): - self.url_entry.configure( - font=ctk.CTkFont( - family="arial", - size=16, - weight="normal", - slant="italic", - underline=True - ) - ) - - self.video_radio_btn.configure(font=("Monospace", 12, "bold")) - self.playlist_radio_btn.configure(font=("Monospace", 12, "bold")) - self.add_url_btn.configure(font=("arial", 15, "bold")) - - font_style_1 = ctk.CTkFont( - family="arial", - size=16, - weight="bold", - slant="italic" - ) - self.added_frame_info_label.configure(font=font_style_1) - self.downloading_frame_info_label.configure(font=font_style_1) - self.downloaded_frame_info_label.configure(font=font_style_1) - font_style_2 = ctk.CTkFont( - family="arial", - size=15, - weight="bold", - ) - self.navigate_added_frame_btn.configure(font=font_style_2) - self.navigate_downloading_frame_btn.configure(font=font_style_2) - self.navigate_downloaded_frame_btn.configure(font=font_style_2) - self.settings_btn.configure(font=("arial", 25, "normal")) - def select_download_mode(self, download_mode): self.selected_download_mode = download_mode if download_mode == "playlist": @@ -612,7 +626,7 @@ def add_video_playlist(self): if self.selected_download_mode == "video": AddedVideo( master=self.added_content_scroll_frame, - height=70, + height=70 * GeneralSettings.settings["scale_r"], width=self.added_content_scroll_frame.winfo_width(), # video url video_url=yt_url, @@ -623,7 +637,7 @@ def add_video_playlist(self): else: AddedPlayList( master=self.added_content_scroll_frame, - height=85, + height=85 * GeneralSettings.settings["scale_r"], width=self.added_content_scroll_frame.winfo_width(), playlist_download_button_click_callback=self.download_playlist, @@ -636,7 +650,7 @@ def download_video(self, video: AddedVideo): self.downloading_frame_info_label.place_forget() DownloadingVideo( master=self.downloading_content_scroll_frame, - height=70, + height=70 * GeneralSettings.settings["scale_r"], width=self.downloading_content_scroll_frame.winfo_width(), # video info channel_url=video.channel_url, @@ -657,7 +671,7 @@ def download_playlist(self, playlist: AddedPlayList): self.downloading_frame_info_label.place_forget() DownloadingPlayList( master=self.downloading_content_scroll_frame, - height=85, + height=85 * GeneralSettings.settings["scale_r"], width=self.downloading_content_scroll_frame.winfo_width(), # video info channel_url=playlist.channel_url, @@ -677,7 +691,7 @@ def downloaded_video(self, video: DownloadingVideo): self.downloaded_frame_info_label.place_forget() DownloadedVideo( master=self.downloaded_content_scroll_frame, - height=70, + height=70 * GeneralSettings.settings["scale_r"], width=self.downloaded_content_scroll_frame.winfo_width(), thumbnails=video.thumbnails, @@ -698,7 +712,7 @@ def downloaded_playlist(self, playlist: DownloadingPlayList): self.downloaded_frame_info_label.place_forget() DownloadedPlayList( master=self.downloaded_content_scroll_frame, - height=85, + height=85 * GeneralSettings.settings["scale_r"], width=self.downloaded_content_scroll_frame.winfo_width(), # playlist url channel_url=playlist.channel_url, @@ -771,12 +785,19 @@ def on_app_closing(self): GeneralSettings.save_settings() self.clear_temporally_saved_files() self.destroy() - os._exit(0) def cancel_app_closing(self): self.bind_events() + def restart(self): + self.on_app_closing() + if os.path.exists("PyTube Downloader.exe"): + os.startfile("PyTube Downloader.exe") + if os.path.exists("main.py"): + os.startfile("main.py") + def show_close_confirmation_dialog(self): + scale = GeneralSettings.settings["scale_r"] self.restore_from_tray() AlertWindow( master=self, @@ -785,7 +806,9 @@ def show_close_confirmation_dialog(self): cancel_button_text="cancel", ok_button_callback=self.on_app_closing, cancel_button_callback=self.cancel_app_closing, - callback=self.cancel_app_closing + callback=self.cancel_app_closing, + width=int(450 * scale), + height=int(130 * scale), ) def restore_from_tray(self): diff --git a/contributors.txt b/contributors.txt index f26788d..4ccd47f 100644 --- a/contributors.txt +++ b/contributors.txt @@ -1,5 +1,5 @@ -CONTRIBUTORS INFO -https://github.com/Thisal-D@%@Thisal Dilmith -https://github.com/childeyouyu@%@youyu -https://github.com/Navindu21@%@Navindu Pahasara -https://github.com/sooryasuraweera@%@Soorya Suraweera \ No newline at end of file +CONTRIBUTORS INFO +https://github.com/Thisal-D@%@Thisal Dilmith +https://github.com/childeyouyu@%@youyu +https://github.com/Navindu21@%@Navindu Pahasara +https://github.com/sooryasuraweera@%@Soorya Suraweera diff --git a/data/general.json b/data/general.json index 4417236..1e7edf2 100644 --- a/data/general.json +++ b/data/general.json @@ -7,5 +7,7 @@ "window_geometry": "900x500+0+0", "max_simultaneous_downloads": 1, "max_simultaneous_loads": 1, - "update_delay": 0.7 + "update_delay": 0.7, + "scale": 100.0, + "scale_r": 1.0 } \ No newline at end of file diff --git a/data/info.json b/data/info.json index b4db12c..2b796c6 100644 --- a/data/info.json +++ b/data/info.json @@ -1,9 +1,9 @@ { "disclaimer": "This application is intended for personal use only. Please respect YouTube's terms of service and \n the rights of content creators when downloading videos.", "contributors": { - + }, "name": "PyTube Downloader", "site": "https://github.com/Thisal-D/PyTube-Downloader", - "version": "0.0.4" + "version": "0.0.5" } \ No newline at end of file diff --git a/data/scale.json b/data/scale.json new file mode 100644 index 0000000..f1f5de8 --- /dev/null +++ b/data/scale.json @@ -0,0 +1,65 @@ +{ + "Video" : { + "1.0" : [4, 24, 44], + "1.2" : [4, 30, 56], + "1.4" : [6, 34, 66], + "1.6" : [8, 39, 75], + "1.8" : [10, 44, 86], + "2.0" : [10, 50, 96] + }, + + "AddedVideo" : { + "1.0" : [17, 8, 44, 22], + "1.2" : [22, 10, 53, 27], + "1.4" : [26, 13, 63, 30], + "1.6" : [29, 12, 70, 35], + "1.8" : [34, 16, 81, 39], + "2.0" : [34, 16, 88, 44] + }, + + "DownloadingVideo" : { + "1.0" : [4, 4, 30, 44, 44, 44, 22, 22], + "1.2" : [4, 4, 31, 54, 54, 54, 27, 27], + "1.4" : [5, 5, 32, 63, 63, 63, 30, 30], + "1.6" : [6, 6, 32, 70, 70, 70, 35, 35], + "1.8" : [7, 7, 32, 79, 79, 79, 39, 39], + "2.0" : [8, 8, 32, 90, 90, 90, 44, 44] + }, + + "DownloadedVideo" : { + "1.0" : [14, 12, 40], + "1.2" : [16, 14, 48], + "1.4" : [19, 16, 56], + "1.6" : [22, 19, 64], + "1.8" : [25, 21, 72], + "2.0" : [28, 24, 80] + }, + + + "PlayList" : { + "1.0" : [55, 10, 34, 55], + "1.2" : [66, 12, 40, 68], + "1.4" : [77, 14, 47, 79], + "1.6" : [88, 16, 56, 94], + "1.8" : [99, 18, 61, 100], + "2.0" : [110, 20, 66, 116] + }, + + "AddedPlayList" : { + "1.0" : [22, 8, 40, 32], + "1.2" : [26, 9, 48, 38], + "1.4" : [30, 11, 56, 44], + "1.6" : [35, 12, 64, 48], + "1.8" : [39, 14, 72, 57], + "2.0" : [44, 16, 90, 64] + }, + + "DownloadingPlayList" : { + "1.0" : [4, 28, 40, 32], + "1.2" : [4, 33, 48, 38], + "1.4" : [5, 39, 56, 44], + "1.6" : [6, 44, 64, 51], + "1.8" : [7, 50, 72, 57], + "2.0" : [8, 56, 80, 64] + } +} \ No newline at end of file diff --git a/data/theme.json b/data/theme.json index a73cb8d..1af8e8f 100644 --- a/data/theme.json +++ b/data/theme.json @@ -34,7 +34,7 @@ ] } }, - "opacity": 1.0, + "opacity": 0.9733333333333334, "radio_btn": { "text_color": { "hover": [ diff --git a/main.py b/main.py index 7996155..52221bc 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,8 @@ from widgets import AlertWindow from settings import ( ThemeSettings, - GeneralSettings + GeneralSettings, + ScaleSettings ) from services import ( DownloadManager, @@ -15,6 +16,7 @@ # configure settings GeneralSettings.initialize("data\\general.json") ThemeSettings.initialize("data\\theme.json") +ScaleSettings.initialize("data\\scale.json") # configure services DownloadManager.initialize() LoadManager.initialize() @@ -26,6 +28,7 @@ # Check directory accessibility during startup. # If accessible, nothing happens if not, show an error message. +scale = GeneralSettings.settings["scale_r"] DIRECTORIES = ["temp", GeneralSettings.settings["download_directory"]] for directory in DIRECTORIES: if not FileUtility.is_accessible(directory): @@ -34,7 +37,9 @@ alert_msg="Please run this application as an administrator...!", ok_button_text="ok", ok_button_callback=app.on_app_closing, - callback=app.on_app_closing + callback=app.on_app_closing, + width=int(450 * scale), + height=int(130 * scale), ) # set the theme mode, dark or light or system, by getting from data @@ -44,7 +49,7 @@ # place the app at the last placed geometry app.geometry(GeneralSettings.settings["window_geometry"]) # set minimum window size to 900x500 -app.minsize(900, 500) +app.minsize(900 * scale, 500 * scale) # configure alpha app.attributes("-alpha", ThemeSettings.settings["opacity"]) # set the title icon @@ -53,6 +58,8 @@ app.title("PyTube Downloader") # Create the main widgets of the application app.create_widgets() +# set widgets sizes +app.set_widgets_sizes() # place main widgets app.place_widgets() # configure colors for main widgets @@ -63,5 +70,6 @@ app.set_widgets_fonts() # app event bind app.bind_events() + # just rut the app app.run() diff --git a/settings/__init__.py b/settings/__init__.py index 2a3ef37..adabcc5 100644 --- a/settings/__init__.py +++ b/settings/__init__.py @@ -1,2 +1,3 @@ from .theme_settings import ThemeSettings from .general_settings import GeneralSettings +from .scale_settings import ScaleSettings diff --git a/settings/scale_settings.py b/settings/scale_settings.py new file mode 100644 index 0000000..70ddd00 --- /dev/null +++ b/settings/scale_settings.py @@ -0,0 +1,24 @@ +from typing import Dict +from utils import JsonUtility + + +class ScaleSettings: + """ + A class to manage scale settings for the application. + """ + settings: Dict = {} + + @staticmethod + def initialize(file_path: str) -> None: + """ + Initialize settings from a JSON file. + + Args: + file_path (str): The file path to the JSON settings file. + + Returns: + ScaleSettings: An instance of GeneralSettings initialized with the settings from the JSON file. + """ + settings = JsonUtility.read_from_file(file_path) + + ScaleSettings.settings = settings diff --git a/widgets/__init__.py b/widgets/__init__.py new file mode 100644 index 0000000..451256a --- /dev/null +++ b/widgets/__init__.py @@ -0,0 +1,20 @@ +from .video import AddedVideo +from .video import DownloadingVideo +from .video import DownloadedVideo + +from .play_list import AddedPlayList +from .play_list import DownloadingPlayList +from .play_list import DownloadedPlayList + +from .components import AccentColorButton +from .components import AppearancePanel +from .components import NetworkPanel +from .components import DownloadsPanel +from .components import AboutPanel +from .components import NavigationPanel +from .components import contributor_profile_widget + +from .core_widgets import SettingPanel +from .core_widgets import AlertWindow +from .core_widgets import ContextMenu +from .core_widgets import TrayMenu diff --git a/widgets/components/__init__.py b/widgets/components/__init__.py new file mode 100644 index 0000000..001eafb --- /dev/null +++ b/widgets/components/__init__.py @@ -0,0 +1,10 @@ +from .accent_color_button import AccentColorButton +from .thumbnail_button import ThumbnailButton + +from .appearance_panel import AppearancePanel +from .network_panel import NetworkPanel +from .downloads_panel import DownloadsPanel +from .about_panel import AboutPanel +from .navigation_panel import NavigationPanel + +from .contributor_profile_widget import ContributorProfileWidget diff --git a/widgets/components/about_panel.py b/widgets/components/about_panel.py new file mode 100644 index 0000000..51bb50f --- /dev/null +++ b/widgets/components/about_panel.py @@ -0,0 +1,293 @@ +import os +import threading +import webbrowser +from typing import Any, Dict +import customtkinter as ctk +from PIL import Image +from utils import ( + GitHubUtility, + JsonUtility, + FileUtility, + ImageUtility, +) +from services import ThemeManager +from settings import ( + ThemeSettings, + ScaleSettings, + GeneralSettings +) +from .contributor_profile_widget import ContributorProfileWidget + + +class AboutPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.name_title_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="Name" + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text=":" + ) + self.name_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="" + ) + + self.version_title_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="Version" + ) + self.dash2_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text=":" + ) + self.version_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="" + ) + + self.site_title_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="Site" + ) + self.dash3_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text=":" + ) + self.site_button = ctk.CTkButton( + master=self, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + hover=False, + width=1, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="" + ) + + self.contributors_title_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="Contributors" + ) + self.dash4_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text=":" + ) + self.contributors_status_label = ctk.CTkLabel( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + text="Loading..." + ) + self.contributors_frame = ctk.CTkScrollableFrame( + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + master=self + ) + + self.disclaimer_label = ctk.CTkLabel( + master=self, + justify="left", + text="", + text_color=ThemeSettings.settings["settings_panel"]["warning_color"]["normal"] + ) + + self.app_info: Dict = JsonUtility.read_from_file("data\\info.json") + self.place_widgets() + self.set_widgets_fonts() + self.set_widgets_sizes() + self.configure_values() + self.set_accent_color() + self.bind_events() + ThemeManager.register_widget(self) + + def configure_values(self): + self.name_label.configure(text=self.app_info["name"]) + self.version_label.configure(text=self.app_info["version"]) + self.site_button.configure(text=self.app_info["site"], command=lambda: webbrowser.open(self.app_info["site"])) + self.disclaimer_label.configure(text="• " + self.app_info["disclaimer"]) + threading.Thread(target=self.configure_contributors_info, daemon=True).start() + + def update_contributors_info(self, contributors_data): + # retrieve links of contributors as list[dict] -> GitHub.com + # iterate contributors list[dict] and generate + self.app_info["contributors"] = {} + for i, contributor_data in enumerate(contributors_data): + self.app_info["contributors"][i] = { + "profile_url": contributor_data["profile_url"], + "user_name": contributor_data["user_name"] + } + + def configure_contributors_info(self): + # retrieve contributors data from GitHub repo as list[dict] + contributors_data = GitHubUtility.get_contributors_data() + # if it success -> return Dict + # if it fails -> return None + if contributors_data is not None: + # if old contributors data list length is different from new contributors data list length, + # that means contributors data is changed + if len(self.app_info["contributors"]) != len(contributors_data): + # if contributors data is changed, call update_contributors_info function to update old data dict + self.update_contributors_info(contributors_data) + + # place forget the loading label + self.contributors_status_label.grid_forget() + # place frame for show contributors info + self.contributors_frame.grid( + row=3, + column=2, + pady=(16 * GeneralSettings.settings["scale_r"], 0), + sticky="we", + columnspan=10, + ) + + # iterate through contributors + profile_images_directory = "assets//profile images//" + row = 0 + for i in self.app_info["contributors"].keys(): + contributor = self.app_info["contributors"][i] + # check if profile image saved or if not create image path + # check profile image is already downloaded if it's not download profile image right now + if contributor.get("profile_images_paths", None) is not None: + profile_images_paths = contributor["profile_images_paths"] + else: + profile_normal_image_path = FileUtility.sanitize_filename(f"{contributor["profile_url"]}-normal.png") + profile_normal_image_path = profile_images_directory + profile_normal_image_path + + profile_hover_image_path = FileUtility.sanitize_filename(f"{contributor["profile_url"]}-hover.png") + profile_hover_image_path = profile_images_directory + profile_hover_image_path + profile_images_paths = ( + FileUtility.get_available_file_name(profile_normal_image_path), + FileUtility.get_available_file_name(profile_hover_image_path) + ) + # update images path + self.app_info["contributors"][i]["profile_images_paths"] = profile_images_paths + + # check if profile image is already downloaded if it's not download profile image + if not os.path.exists(profile_images_paths[0]): + try: + # download image from GitHub + ImageUtility.download_image( + image_url=f"{contributor["profile_url"]}.png", + output_image_path=profile_images_paths[0] + ) + # add corner radius to download image + profile_image = Image.open(profile_images_paths[0]) + profile_image = ImageUtility.create_image_with_rounded_corners( + image=profile_image, + radius=int(profile_image.width/2), + ) + profile_image.save(profile_images_paths[0]) + profile_image.close() + except Exception as error: + print(f"about_panel.py : {error}") + # check if hover profile image is already generated if not generate + if not os.path.exists(profile_images_paths[1]) and os.path.exists(profile_images_paths[0]): + profile_image = Image.open(profile_images_paths[0]) + profile_image_hover = ImageUtility.create_image_with_rounded_corners( + ImageUtility.create_image_with_hover_effect( + image=profile_image, + intensity_increase=40 + ), + radius=int(profile_image.width/2) + ) + profile_image_hover.save(profile_images_paths[1]) + + if os.path.exists(profile_images_paths[1]) and os.path.exists(profile_images_paths[0]): + # create contributor + ContributorProfileWidget( + master=self.contributors_frame, + width=35, + height=35, + user_name=contributor["user_name"], + profile_url=contributor["profile_url"], + profile_images_paths=profile_images_paths, + ).grid( + row=row, + pady=0, + padx=(0, 0, 0) + ) + row += 2 + + # save info to json + JsonUtility.write_to_file("data\\info.json", self.app_info) + + def bind_events(self): + self.site_button.bind("", lambda event: self.site_button.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"])) + self.site_button.bind("", lambda event: self.site_button.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"])) + + def set_accent_color(self): + self.name_label.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.version_label.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.site_button.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... + + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + pady = 16 * scale + self.name_title_label.grid(row=0, column=0, padx=(100, 0), pady=(50, 0), sticky="w") + self.dash1_label.grid(row=0, column=1, padx=(30, 30), pady=(50, 0), sticky="w") + self.name_label.grid(row=0, column=2, pady=(50, 0), sticky="w") + + self.version_title_label.grid(row=1, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash2_label.grid(row=1, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.version_label.grid(row=1, column=2, pady=(pady, 0), sticky="w") + + self.site_title_label.grid(row=2, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash3_label.grid(row=2, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.site_button.grid(row=2, column=2, pady=(pady, 0), sticky="w") + + self.contributors_title_label.grid(row=3, column=0, padx=(100, 0), pady=(pady, 0), sticky="nw") + self.dash4_label.grid(row=3, column=1, padx=(30, 30), pady=(pady, 0), sticky="nw") + self.contributors_status_label.grid(row=3, column=2, pady=(pady, 0), sticky="w") + + self.disclaimer_label.place(x=100, rely=1, y=-60 * scale) + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.contributors_frame.configure(height=200 * scale, width=500 * scale) + self.contributors_frame._scrollbar.grid_forget() + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + title_font = ("Segoe UI", 13 * scale, "bold") + self.name_title_label.configure(font=title_font) + self.dash1_label.configure(font=title_font) + self.name_label.configure(font=title_font) + + self.version_title_label.configure(font=title_font) + self.dash2_label.configure(font=title_font) + self.version_label.configure(font=title_font) + + self.site_title_label.configure(font=title_font) + self.dash3_label.configure(font=title_font) + self.site_button.configure(font=title_font) + + self.contributors_title_label.configure(font=title_font) + self.dash4_label.configure(font=title_font) + self.contributors_status_label.configure(font=title_font) + + value_font = ("Segoe UI", 13 * scale, "normal") + self.disclaimer_label.configure(font=value_font) diff --git a/widgets/components/accent_color_button.py b/widgets/components/accent_color_button.py new file mode 100644 index 0000000..ced0f7a --- /dev/null +++ b/widgets/components/accent_color_button.py @@ -0,0 +1,71 @@ +import customtkinter as ctk + + +class AccentColorButton(ctk.CTkButton): + def __init__( + self, + master=None, + width=1, + height=1, + border_width=0, + corner_radius=0, + text="", + hover_color=None, + fg_color=None, + size_change=0): + + super().__init__( + master=master, + width=width, + height=height, + border_width=border_width, + corner_radius=corner_radius, + text=text, + fg_color=fg_color, + ) + self.size_change = size_change + self.height = height + self.width = width + self.pressed = False + self.hover_color = hover_color + self.fg_color = fg_color + + def on_mouse_enter_self(self, _event): + self.configure( + width=self.cget("width") + self.size_change, + height=self.cget("height") + self.size_change, + fg_color=self.hover_color + ) + self.grid( + padx=self.grid_info()["padx"] - self.size_change, + pady=self.grid_info()["pady"] - self.size_change + ) + + def on_mouse_leave_self(self, _event): + self.configure( + width=self.cget("width") - self.size_change, + height=self.cget("height") - self.size_change, + fg_color=self.fg_color + ) + self.grid( + padx=self.grid_info()["padx"] + self.size_change, + pady=self.grid_info()["pady"] + self.size_change + ) + + def set_pressed(self): + self.pressed = True + self.configure(state="disabled") + self.unbind("") + self.unbind("") + # self.reset_other_buttons() + + def set_unpressed(self): + self.pressed = False + self.configure(state="normal") + self.bind("", self.on_mouse_enter_self) + self.bind("", self.on_mouse_leave_self) + self.on_mouse_leave_self("event") + + def bind_event(self): + self.bind("", self.on_mouse_enter_self) + self.bind("", self.on_mouse_leave_self) diff --git a/widgets/components/appearance_panel.py b/widgets/components/appearance_panel.py new file mode 100644 index 0000000..5542464 --- /dev/null +++ b/widgets/components/appearance_panel.py @@ -0,0 +1,509 @@ +from typing import Any, List, Callable, Literal +import customtkinter as ctk +from .accent_color_button import AccentColorButton +from services import ThemeManager +from utils import SettingsValidateUtility +from settings import ( + ThemeSettings, + GeneralSettings, +) + + +class AppearancePanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + theme_settings_change_callback: Callable = None, + general_settings_change_callback: Callable = None, + restart_callback: Callable = None): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.theme_label = ctk.CTkLabel( + master=self, + text="Theme", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.theme_combo_box = ctk.CTkComboBox( + master=self, + values=["Dark", "Light"], + dropdown_fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + command=self.apply_theme_mode, + width=140 * GeneralSettings.settings["scale_r"], + height=28 * GeneralSettings.settings["scale_r"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + self.system_theme_check_box = ctk.CTkCheckBox( + master=self, + text="Sync with OS", + command=self.sync_theme_with_os, + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.accent_color_label = ctk.CTkLabel( + master=self, + text="Accent color", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.dash2_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.accent_color_frame = ctk.CTkFrame( + master=self, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + # add accent color buttons + self.accent_color_buttons: List[AccentColorButton] = [] + for accent_color in ThemeSettings.settings["settings_panel"]["accent_colors"].values(): + button = AccentColorButton( + master=self.accent_color_frame, + text="", + fg_color=accent_color["normal"], + hover_color=accent_color["hover"], + size_change=4, + corner_radius=8, + ) + button.configure(command=lambda btn=button: self.apply_accent_color(btn)) + self.accent_color_buttons.append(button) + + # add user custom accent color + self.custom_accent_color_label = ctk.CTkLabel( + master=self, + text="Custom Accent color", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + self.dash3_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.custom_accent_color_entry = ctk.CTkEntry( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.custom_accent_color_display_btn = ctk.CTkButton( + master=self, + text="", + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.custom_accent_color_apply_btn = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + command=self.apply_custom_accent_color + ) + + self.custom_accent_color_alert_text = ctk.CTkTextbox( + master=self, + text_color=ThemeSettings.settings["settings_panel"]["warning_color"]["normal"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + ) + + self.scale_label = ctk.CTkLabel( + master=self, + text="Scale", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.dash4_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.scale_change_slider = ctk.CTkSlider( + master=self, + command=self.change_scale, + number_of_steps=5, + from_=100, + to=200, + ) + + self.scale_apply_btn = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + command=self.ask_to_restart + ) + + self.scale_value_label = ctk.CTkLabel( + master=self, + text="", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.opacity_label = ctk.CTkLabel( + master=self, + text="Transparent", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.dash5_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + ) + + self.opacity_change_slider = ctk.CTkSlider( + master=self, + command=self.apply_opacity, + from_=0.6, + to=1, + ) + + # callbacks for settings changes + self.restart_callback = restart_callback + self.theme_settings_change_callback = theme_settings_change_callback + self.general_settings_change_callback = general_settings_change_callback + + self.set_widgets_fonts() + self.set_widgets_sizes() + self.set_accent_color() + self.place_widgets() + self.bind_events() + self.set_widgets_values() + + # Register widget with ThemeManager + ThemeManager.register_widget(self) + + def release_all_accent_color_buttons(self): + """ + Release all pressed accent color buttons. + """ + for accent_button in self.accent_color_buttons: + if accent_button.pressed: + accent_button.set_unpressed() + + def apply_accent_color(self, button: AccentColorButton): + """ + Apply selected accent color. + """ + ThemeSettings.settings["root"]["accent_color"] = { + "normal": button.fg_color, + "hover": button.hover_color, + "default": True + } + self.theme_settings_change_callback("accent_color") + self.release_all_accent_color_buttons() + button.set_pressed() + self.custom_accent_color_apply_btn.configure(state="normal") + + def apply_custom_accent_color(self): + """ + Apply custom accent color. + """ + colors = self.custom_accent_color_entry.get().strip().replace(" ", "").split(",") + ThemeSettings.settings["root"]["accent_color"] = { + "normal": colors[0], + "hover": colors[1], + "default": False + } + self.release_all_accent_color_buttons() + self.theme_settings_change_callback("accent_color") + self.custom_accent_color_apply_btn.configure(state="disabled") + + def apply_theme_mode(self, theme_mode: Literal["Dark", "Light", None]): + """ + Apply selected theme mode. Dark / Light + """ + ThemeSettings.settings["root"]["theme_mode"] = theme_mode.lower() + self.theme_settings_change_callback("theme_mode") + + def sync_theme_with_os(self): + """ + Synchronize theme with the OS. + """ + self.system_theme_check_box.configure(command=self.disable_sync_theme_with_os) + self.theme_combo_box.configure(state="disabled") + ThemeSettings.settings["root"]["theme_mode"] = "system" + self.theme_settings_change_callback("theme_mode") + + def disable_sync_theme_with_os(self): + """ + Disable synchronization with the OS. + """ + self.system_theme_check_box.configure(command=self.sync_theme_with_os) + self.theme_combo_box.configure(state="normal") + ThemeSettings.settings["root"]["theme_mode"] = ctk.get_appearance_mode().lower() + self.theme_settings_change_callback("theme_mode") + + def apply_opacity(self, opacity_value: float): + """ + Apply selected opacity value. + """ + ThemeSettings.settings["opacity"] = opacity_value + self.theme_settings_change_callback("opacity") + + def change_scale(self, scale_value: int): + """ + Change the scale value. + """ + self.scale_value_label.configure(text=f"{scale_value} %") + if scale_value != GeneralSettings.settings["scale"]: + self.scale_apply_btn.configure(state="normal") + else: + self.scale_apply_btn.configure(state="disabled") + + def ask_to_restart(self): + from widgets import AlertWindow + scale = GeneralSettings.settings["scale_r"] + AlertWindow( + master=self.master.master, + alert_msg="Restart Required..!", + width=int(450 * scale), + height=int(130 * scale), + ok_button_text="ok", + cancel_button_text="cancel", + ok_button_callback=self.apply_scale + ) + + def apply_scale(self): + scale_value = self.scale_change_slider.get() + GeneralSettings.settings["scale"] = scale_value + GeneralSettings.settings["scale_r"] = scale_value / 100 + self.general_settings_change_callback() + self.restart_callback() + + def validate_custom_accent_color(self, _event): + """ + Validate custom accent color entry. + """ + text = self.custom_accent_color_entry.get() + colors = text.strip().replace(" ", "") + if SettingsValidateUtility.validate_accent_color(colors): + fg_color = colors.split(",")[0] + hover_color = colors.split(",")[1] + self.custom_accent_color_display_btn.configure( + fg_color=fg_color, + hover_color=hover_color + ) + self.custom_accent_color_entry.delete(0, "end") + self.custom_accent_color_entry.insert("end", f"{fg_color}, {hover_color}") + self.custom_accent_color_apply_btn.configure(state="normal") + else: + self.custom_accent_color_display_btn.configure( + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + ) + self.custom_accent_color_apply_btn.configure(state="disabled") + + def set_accent_color(self): + """ + Set accent color for widgets. + """ + self.theme_combo_box.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + dropdown_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.custom_accent_color_entry.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.system_theme_check_box.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + self.custom_accent_color_apply_btn.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.opacity_change_slider.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + progress_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + ) + self.scale_change_slider.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + progress_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + ) + self.scale_apply_btn.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + + def update_accent_color(self): + """ + Update accent color. + """ + self.set_accent_color() + + def reset_widgets_colors(self): + """ + Reset colors of widgets. + """ + self.custom_accent_color_alert_text.tag_config( + "normal", + foreground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["settings_panel"]["text_color"] + ) + ) + + def place_widgets(self): + """ + Place widgets on the frame. + """ + scale = GeneralSettings.settings["scale_r"] + pady = 16 * scale + self.theme_label.grid(row=0, column=0, padx=(100, 0), pady=(50, 0), sticky="w") + self.dash1_label.grid(row=0, column=1, padx=(30, 30), pady=(50, 0), sticky="w") + self.theme_combo_box.grid(row=0, column=2, pady=(50, 0), sticky="w") + self.system_theme_check_box.grid(row=0, column=3, padx=(20, 0), pady=(50, 0), sticky="w") + + self.accent_color_label.grid(row=1, column=0, padx=(100, 0), pady=(pady, 0), sticky="nw") + self.dash2_label.grid(row=1, column=1, padx=(30, 30), pady=(pady, 0), sticky="nw") + self.accent_color_frame.grid(row=1, column=2, pady=(pady, 0), sticky="w") + + # place accent color buttons + max_columns = 3 + row = 0 + column = 0 + for button in self.accent_color_buttons: + button.grid(row=row, column=column, padx=6, pady=6) + column += 1 + if column > max_columns: + column = 0 + row += 1 + button.bind_event() + + self.custom_accent_color_label.grid(row=2, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash3_label.grid(row=2, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.custom_accent_color_entry.grid(row=2, column=2, pady=(pady, 0), sticky="w") + self.custom_accent_color_display_btn.grid(row=2, column=3, padx=(20, 0), pady=(pady, 0), sticky="w") + self.custom_accent_color_apply_btn.grid(row=2, column=4, padx=(0, 0), pady=(pady, 0), sticky="w") + self.custom_accent_color_alert_text.grid(row=3, column=0, columnspan=9, padx=(100, 0), pady=(10, 0), sticky="w") + + self.scale_label.grid(row=4, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash4_label.grid(row=4, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.scale_change_slider.grid(row=4, column=2, pady=(pady, 0), sticky="w") + self.scale_value_label.grid(row=4, column=3, padx=(20, 0), pady=(pady, 0), sticky="w") + self.scale_apply_btn.grid(row=4, column=4, padx=(20, 0), pady=(pady, 0), sticky="w") + + self.opacity_label.grid(row=5, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash5_label.grid(row=5, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.opacity_change_slider.grid(row=5, column=2, pady=(pady, 0), sticky="w") + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.theme_combo_box.configure(width=140 * scale, height=28 * scale) + self.system_theme_check_box.configure(checkbox_width=24 * scale, checkbox_height=24 * scale) + + for accent_color_button in self.accent_color_buttons: + accent_color_button.configure(width=30 * scale, height=30 * scale, corner_radius=6 * scale) + self.custom_accent_color_display_btn.configure(width=30 * scale, height=30 * scale, corner_radius=6 * scale) + self.custom_accent_color_entry.configure(width=140 * scale, height=28 * scale) + self.custom_accent_color_apply_btn.configure(width=50 * scale, height=24 * scale) + self.custom_accent_color_alert_text.configure(width=590 * scale, height=80 * scale) + + self.scale_change_slider.configure(width=180 * scale, height=18 * scale) + self.scale_apply_btn.configure(width=50 * scale, height=24 * scale) + self.opacity_change_slider.configure(width=180 * scale, height=18 * scale) + + def set_widgets_fonts(self): + # Segoe UI, Open Sans + scale = GeneralSettings.settings["scale_r"] + title_font = ("Segoe UI", 13 * scale, "bold") + self.theme_label.configure(font=title_font) + self.dash1_label.configure(font=title_font) + self.accent_color_label.configure(font=title_font) + self.dash2_label.configure(font=title_font) + self.custom_accent_color_label.configure(font=title_font) + self.dash3_label.configure(font=title_font) + self.scale_label.configure(font=title_font) + self.dash4_label.configure(font=title_font) + self.opacity_label.configure(font=title_font) + self.dash5_label.configure(font=title_font) + + value_font = ("Segoe UI", 13 * scale, "normal") + self.theme_combo_box.configure(font=value_font, dropdown_font=value_font) + self.system_theme_check_box.configure(font=value_font) + self.custom_accent_color_entry.configure(font=value_font) + self.custom_accent_color_alert_text.configure(font=value_font) + self.scale_value_label.configure(font=value_font) + + button_font = ("Segoe UI", 13 * scale, "bold") + self.custom_accent_color_apply_btn.configure(font=button_font) + self.scale_apply_btn.configure(font=button_font) + + # set default values to widgets + def set_widgets_values(self): + """ + set values for widgets using saved settings. + """ + self.custom_accent_color_alert_text.bind("", lambda e: "break") + self.custom_accent_color_alert_text.insert( + "end", + """* Please enter custom accent colors for normal and hover states in one of the following formats : + - Hexadecimal: #0f0f0f, #0f0f0ff + - Color names: green, lightgreen""" + ) + self.custom_accent_color_alert_text.tag_add("red", "2.31", "2.33") + self.custom_accent_color_alert_text.tag_add("green", "2.33", "2.35") + self.custom_accent_color_alert_text.tag_add("blue", "2.35", "2.37") + self.custom_accent_color_alert_text.tag_add("red", "2.42", "2.43") + self.custom_accent_color_alert_text.tag_add("green", "2.43", "2.44") + self.custom_accent_color_alert_text.tag_add("blue", "2.44", "2.45") + self.custom_accent_color_alert_text.tag_add("normal", "5.0", "7.43") + + self.custom_accent_color_alert_text.tag_config("red", foreground="#ff0000") + self.custom_accent_color_alert_text.tag_config("green", foreground="#00ff00") + self.custom_accent_color_alert_text.tag_config("blue", foreground="#0000ff") + + if ThemeSettings.settings["root"]["accent_color"]["default"]: + for button in self.accent_color_buttons: + if button.fg_color == ThemeSettings.settings["root"]["accent_color"]["normal"] and \ + button.hover_color == ThemeSettings.settings["root"]["accent_color"]["hover"]: + button.on_mouse_enter_self("event") + button.set_pressed() + else: + # add default value to entry using data + self.custom_accent_color_entry.insert( + "end", + ThemeSettings.settings["root"]["accent_color"]["normal"] + + ", " + ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.validate_custom_accent_color("event") + self.custom_accent_color_apply_btn.configure(state="disabled") + + if ThemeSettings.settings["root"]["theme_mode"] == "system": + self.sync_theme_with_os() + self.system_theme_check_box.select() + elif ThemeSettings.settings["root"]["theme_mode"] == "dark": + self.theme_combo_box.set("Dark") + if ThemeSettings.settings["root"]["theme_mode"] == "light": + self.theme_combo_box.set("Light") + + self.opacity_change_slider.set(ThemeSettings.settings["opacity"]) + + self.scale_change_slider.set(GeneralSettings.settings["scale"]) + self.scale_value_label.configure(text=f"{GeneralSettings.settings["scale"]} %") + + def bind_events(self): + """ + Bind events to widgets. + """ + self.custom_accent_color_entry.bind("", self.validate_custom_accent_color) + self.validate_custom_accent_color("event") diff --git a/widgets/components/contributor_profile_widget.py b/widgets/components/contributor_profile_widget.py new file mode 100644 index 0000000..b34af73 --- /dev/null +++ b/widgets/components/contributor_profile_widget.py @@ -0,0 +1,127 @@ +import customtkinter as ctk +import tkinter as tk +from typing import Tuple, Any, Union +from PIL import Image +import webbrowser +from services import ThemeManager +from settings import ( + ThemeSettings, + GeneralSettings +) + + +class ContributorProfileWidget: + def __init__( + self, + master: Any = None, + width: int = 35, + height: int = 35, + user_name: str = "", + profile_url: str = "", + profile_images_paths: Tuple[str, str] = None): + + self.profile_images = ( + ctk.CTkImage( + Image.open(profile_images_paths[0]), + size=(width * GeneralSettings.settings["scale_r"], height * GeneralSettings.settings["scale_r"]) + ), + ctk.CTkImage( + Image.open(profile_images_paths[1]), + size=(width * GeneralSettings.settings["scale_r"], height * GeneralSettings.settings["scale_r"]) + ) + ) + + self.profile_pic_button = ctk.CTkButton( + master=master, + hover=False, + text="", + command=lambda: webbrowser.open(profile_url), + image=self.profile_images[0], + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + ) + + self.user_name_button = ctk.CTkButton( + master=master, + text=user_name, + hover=False, + width=1, + command=lambda: webbrowser.open(profile_url), + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.profile_url_button = ctk.CTkButton( + master=master, + text=profile_url, + hover=False, + width=1, + command=lambda: webbrowser.open(profile_url), + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.hr = tk.Frame( + master=master, + height=1, + ) + + self.width = width + self.height = height + + self.set_widgets_fonts() + self.set_widgets_sizes() + self.bind_events() + self.set_accent_color() + ThemeManager.register_widget(self) + + def bind_events(self): + self.profile_pic_button.bind( + "", + lambda event_: self.profile_pic_button.configure(image=self.profile_images[1]) + ) + + self.profile_pic_button.bind( + "", + lambda event_: self.profile_pic_button.configure(image=self.profile_images[0]) + ) + + def set_accent_color(self): + self.hr.configure( + bg=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.profile_pic_button.configure( + width=self.width * scale, + height=self.height * scale + ) + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + title_font = ("Segoe UI", 13 * scale, "bold") + self.user_name_button.configure(font=title_font) + + value_font = ("Segoe UI", 13 * scale, "normal") + self.profile_url_button.configure(font=value_font) + + def grid( + self, + row: int = 0, + pady: int = 3, + padx: Tuple[ + Union[int, Tuple[int, int]], + Union[int, Tuple[int, int]], + Union[int, Tuple[int, int]]] = None) -> None: + scale = GeneralSettings.settings["scale_r"] + self.profile_pic_button.grid(row=row, column=0, padx=padx[0] * scale, pady=pady * scale, sticky="w") + self.user_name_button.grid(row=row, column=1, padx=padx[1] * scale, pady=pady * scale, sticky="w") + self.profile_url_button.grid(row=row, column=2, padx=padx[2] * scale, pady=pady * scale, sticky="w") + self.hr.grid(columnspan=3, row=row+1, column=0, sticky="ew", padx=50 * scale) + \ No newline at end of file diff --git a/widgets/components/downloads_panel.py b/widgets/components/downloads_panel.py new file mode 100644 index 0000000..c26e331 --- /dev/null +++ b/widgets/components/downloads_panel.py @@ -0,0 +1,144 @@ +import customtkinter as ctk +from typing import Callable, Any +from tkinter import filedialog +from services import ( + ThemeManager, +) +from settings import ( + ThemeSettings, + GeneralSettings, + ScaleSettings +) +from utils import SettingsValidateUtility +from utils import FileUtility + + +class DownloadsPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + general_settings_change_callback: Callable = None): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.download_path_label = ctk.CTkLabel( + master=self, + text="Download Path", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.download_path_entry = ctk.CTkEntry( + master=self, + justify="left", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.download_path_choose_button = ctk.CTkButton( + master=self, + text="📂", + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + hover=False, + command=self.select_download_path, + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.apply_changes_button = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + command=self.apply_general_settings, + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.general_settings_change_callback = general_settings_change_callback + self.configure_values() + self.set_accent_color() + self.set_widgets_sizes() + self.set_widgets_fonts() + self.place_widgets() + self.bind_events() + ThemeManager.register_widget(self) + + def apply_general_settings(self): + GeneralSettings.settings["download_directory"] = FileUtility.format_path(self.download_path_entry.get()) + self.general_settings_change_callback() + self.apply_changes_button.configure(state="disabled") + + def download_path_validate(self, _event): + path = FileUtility.format_path(self.download_path_entry.get()) + if path != GeneralSettings.settings["download_directory"]: + if SettingsValidateUtility.validate_download_path(path): + self.apply_changes_button.configure(state="normal") + else: + self.apply_changes_button.configure(state="disabled") + else: + self.apply_changes_button.configure(state="disabled") + + def select_download_path(self): + directory = filedialog.askdirectory() + if directory: + self.download_path_entry.delete(0, "end") + self.download_path_entry.insert(0, directory) + self.download_path_validate("event") + + def bind_events(self): + self.download_path_entry.bind("", self.download_path_validate) + + def set_accent_color(self): + self.download_path_choose_button.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.apply_changes_button.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.download_path_entry.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... + + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + pady = 25 * scale + + self.download_path_label.grid(row=0, column=0, padx=(100, 0), pady=(50, 0), sticky="w") + self.dash1_label.grid(row=0, column=1, padx=(30, 30), pady=(50, 0), sticky="w") + self.download_path_entry.grid(row=0, column=2, pady=(50, 0), sticky="w") + self.download_path_choose_button.grid(row=0, column=3, pady=(50, 0), padx=(20, 0), sticky="w") + self.apply_changes_button.grid(row=1, column=3, pady=(pady, 0), padx=(20, 0), sticky="w") + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.download_path_entry.configure(width=350 * scale, height=28 * scale) + self.download_path_choose_button.configure(width=30 * scale, height=30 * scale) + self.apply_changes_button.configure(width=50 * scale, height=24 * scale) + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + title_font = ("Segoe UI", 13 * scale, "bold") + self.download_path_label.configure(font=title_font) + self.dash1_label.configure(font=title_font) + + value_font = ("Segoe UI", 13 * scale, "normal") + self.download_path_entry.configure(font=value_font) + + button_font = ("Segoe UI", 13 * scale, "bold") + self.apply_changes_button.configure(font=button_font) + + button_font2 = ("Segoe UI", 28 * scale, "bold") + self.download_path_choose_button.configure(font=button_font2) + + def configure_values(self): + self.download_path_entry.insert(0, GeneralSettings.settings["download_directory"]) diff --git a/widgets/components/navigation_panel.py b/widgets/components/navigation_panel.py new file mode 100644 index 0000000..9a01624 --- /dev/null +++ b/widgets/components/navigation_panel.py @@ -0,0 +1,95 @@ +import customtkinter as ctk +from typing import Any, List, Callable +from services import ThemeManager +from settings import ( + ThemeSettings, + ScaleSettings, + GeneralSettings +) + + +class NavigationPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + navigation_panels: List[ctk.CTkFrame] = None, + navigation_button_on_click_callback: Callable = None, + navigation_buttons_text: List[str] = None, + width: int = None): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + width=width * GeneralSettings.settings["scale_r"] + ) + + self.navigation_buttons = [] + self.navigation_buttons_clicked_state = [] + for i, button_text in enumerate(navigation_buttons_text): + self.navigation_buttons.append( + ctk.CTkButton( + master=self, + corner_radius=0, + text=button_text, + hover=False, + text_color=ThemeSettings.settings["settings_panel"]["nav_text_color"] + ) + ) + self.navigation_buttons[-1].configure( + command=lambda panel=navigation_panels[i], button=self.navigation_buttons[-1]: + self.on_click_navigation_button(button, panel) + ) + self.navigation_buttons_clicked_state.append(False) + + self.navigation_button_on_click_callback = navigation_button_on_click_callback + ThemeManager.register_widget(self) + self.width = width + self.set_accent_color() + self.set_widgets_sizes() + self.set_widgets_fonts() + self.place_widgets() + # place appearance panel @ startup + self.on_click_navigation_button(self.navigation_buttons[0], navigation_panels[0]) + + def on_click_navigation_button(self, clicked_button: ctk.CTkButton, navigation_panel: ctk.CTkFrame): + for i, navigation_button in enumerate(self.navigation_buttons): + if clicked_button is navigation_button: + self.navigation_buttons_clicked_state[i] = True + navigation_button.configure(fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + else: + self.navigation_buttons_clicked_state[i] = False + navigation_button.configure(fg_color=ThemeSettings.settings["root"]["accent_color"]["hover"]) + self.navigation_button_on_click_callback(navigation_panel) + + def place_widgets(self): + self.navigation_buttons[0].pack(pady=(50 * GeneralSettings.settings["scale_r"], 0)) + for navigation_button in self.navigation_buttons[1::]: + navigation_button.pack() + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + button_font = ("Comic Sans MS", 14 * scale, "bold") + for navigation_button in self.navigation_buttons: + navigation_button.configure(font=button_font) + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + for navigation_button in self.navigation_buttons: + navigation_button.configure(height=34 * scale, width=self.width * scale) + + def update_accent_color(self) -> None: + self.set_accent_color() + + def set_accent_color(self): + for i, navigation_button in enumerate(self.navigation_buttons): + if not self.navigation_buttons_clicked_state[i]: + navigation_button.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + else: + navigation_button.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + def reset_widgets_colors(self): + ... diff --git a/widgets/components/network_panel.py b/widgets/components/network_panel.py new file mode 100644 index 0000000..79c9c34 --- /dev/null +++ b/widgets/components/network_panel.py @@ -0,0 +1,319 @@ +from typing import Any, Callable, Literal +import customtkinter as ctk +from services import ( + ThemeManager +) +from settings import ( + GeneralSettings, + ThemeSettings, + ScaleSettings +) +from utils import SettingsValidateUtility + + +# noinspection PyTypeChecker +class NetworkPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + general_settings_change_callback: Callable = None): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.load_label = ctk.CTkLabel( + master=self, + text="Maximum Simultaneous Loads", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.simultaneous_load_entry = ctk.CTkEntry( + master=self, + justify="right", + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.simultaneous_load_range_label = ctk.CTkLabel( + master=self, + text="(1-10)", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.download_label = ctk.CTkLabel( + master=self, + text="Maximum Simultaneous Downloads", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.dash2_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + self.simultaneous_download_entry = ctk.CTkEntry( + master=self, + justify="right", + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.simultaneous_download_range_label = ctk.CTkLabel( + master=self, + text="(1-10)", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.automatic_download_label = ctk.CTkLabel( + master=self, + text="Automatic video Download", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.dash3_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.switch_state = ctk.StringVar(value=None) + self.automatic_download_switch = ctk.CTkSwitch( + master=self, + text="", + command=self.change_automatic_download, + onvalue="enable", + offvalue="disable", + variable=self.switch_state + ) + + self.automatic_download_quality_label = ctk.CTkLabel( + master=self, + text="Download Quality", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + self.dash4_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + # noinspection PyTypeChecker + self.automatic_download_quality_combo_box = ctk.CTkComboBox( + master=self, + values=["Highest Quality", "Lowest Quality", "Audio Only"], + dropdown_fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + command=self.change_automatic_download_quality, + text_color=ThemeSettings.settings["settings_panel"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + width=140 * GeneralSettings.settings["scale_r"], + height=28 * GeneralSettings.settings["scale_r"] + ) + + self.automatic_download_info_label = ctk.CTkLabel( + master=self, + text="• Download videos automatically after complete loading", + ) + + self.apply_changes_button = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + height=24, + width=50, + command=self.apply_general_settings, + text_color=ThemeSettings.settings["settings_panel"]["text_color"] + ) + + # use to track anything is changed or not + self.automatic_download_state_changed: bool = False + self.automatic_download_quality_changed: bool = False + self.simultaneous_download_count_changed: bool = False + self.simultaneous_load_count_changed: bool = False + + # track values validity + self.simultaneous_load_count_valid: bool = True + self.simultaneous_download_count_valid: bool = True + + self.general_settings_change_callback = general_settings_change_callback + self.set_accent_color() + self.set_widgets_fonts() + self.set_widgets_sizes() + self.place_widgets() + self.bind_widgets() + self.configure_values() + ThemeManager.register_widget(self) + + def apply_general_settings(self): + GeneralSettings.settings["max_simultaneous_loads"] = int(self.simultaneous_load_entry.get()) + GeneralSettings.settings["max_simultaneous_downloads"] = int(self.simultaneous_download_entry.get()) + GeneralSettings.settings["automatic_download"]["status"] = self.switch_state.get() + GeneralSettings.settings["automatic_download"]["quality"] = self.automatic_download_quality_combo_box.get() + self.general_settings_change_callback() + self.apply_changes_button.configure(state="disabled") + + def change_automatic_download_quality(self, quality: Literal["Highest Quality", "Lowest Quality", "Audio Only"]): + if GeneralSettings.settings["automatic_download"]["quality"] != quality: + self.automatic_download_quality_changed = True + else: + self.automatic_download_quality_changed = False + self.set_apply_button_state() + + def change_automatic_download(self): + if GeneralSettings.settings["automatic_download"]["status"] != self.switch_state.get(): + self.automatic_download_state_changed = True + else: + self.automatic_download_state_changed = False + self.set_apply_button_state() + + def simultaneous_load_count_check(self, _event): + value = self.simultaneous_load_entry.get() + if SettingsValidateUtility.validate_simultaneous_count(value): + self.simultaneous_load_count_valid = True + if int(value) != GeneralSettings.settings["max_simultaneous_loads"]: + self.simultaneous_load_count_changed = True + else: + self.simultaneous_load_count_changed = False + else: + self.simultaneous_load_count_valid = False + self.set_apply_button_state() + + def simultaneous_download_count_check(self, _event): + value = self.simultaneous_download_entry.get() + if SettingsValidateUtility.validate_simultaneous_count(value): + self.simultaneous_download_count_valid = True + if int(value) != GeneralSettings.settings["max_simultaneous_downloads"]: + self.simultaneous_download_count_changed = True + else: + self.simultaneous_download_count_changed = False + else: + self.simultaneous_download_count_valid = False + self.set_apply_button_state() + + def set_apply_button_state(self): + if (any((self.simultaneous_download_count_changed, self.simultaneous_load_count_changed, + self.automatic_download_state_changed, self.automatic_download_quality_changed)) and + all((self.simultaneous_load_count_valid, self.simultaneous_download_count_valid))): + self.apply_changes_button.configure(state="normal") + else: + self.apply_changes_button.configure(state="disabled") + + def bind_widgets(self): + self.simultaneous_load_entry.bind("", self.simultaneous_load_count_check) + self.simultaneous_download_entry.bind("", self.simultaneous_download_count_check) + + # set default values to widgets + def configure_values(self): + self.simultaneous_load_entry.insert( + "end", + GeneralSettings.settings["max_simultaneous_loads"] + ) + self.simultaneous_download_entry.insert( + "end", + GeneralSettings.settings["max_simultaneous_downloads"] + ) + if GeneralSettings.settings["automatic_download"]["status"] == "enable": + self.automatic_download_switch.select() + self.switch_state.set("enable") + else: + self.switch_state.set("disable") + + self.automatic_download_quality_combo_box.set(GeneralSettings.settings["automatic_download"]["quality"]) + + def update_accent_color(self): + self.set_accent_color() + + def set_accent_color(self): + self.apply_changes_button.configure( + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.simultaneous_load_entry.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.simultaneous_download_entry.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.automatic_download_info_label.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.automatic_download_switch.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + progress_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.automatic_download_quality_combo_box.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + dropdown_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + pady = 16 * scale + + self.load_label.grid(row=0, column=0, padx=(100, 0), pady=(50, 0), sticky="w") + self.dash1_label.grid(row=0, column=1, padx=(30, 30), pady=(50, 0), sticky="w") + self.simultaneous_load_entry.grid(row=0, column=2, pady=(50, 0), sticky="w") + self.simultaneous_load_range_label.grid(row=0, column=3, pady=(50, 0), padx=(20, 0), sticky="w") + + self.download_label.grid(row=1, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash2_label.grid(row=1, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.simultaneous_download_entry.grid(row=1, column=2, pady=(pady, 0), sticky="w") + self.simultaneous_download_range_label.grid(row=1, column=3, pady=(pady, 0), padx=(20, 0), sticky="w") + + self.automatic_download_label.grid(row=2, column=0, padx=(100, 0), pady=(pady, 0), sticky="w") + self.dash3_label.grid(row=2, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.automatic_download_switch.grid(row=2, column=2, pady=(pady, 0), sticky="w") + + self.automatic_download_quality_label.grid(row=3, column=0, padx=(100, 0), pady=(pady, 0), sticky="e") + self.dash4_label.grid(row=3, column=1, padx=(30, 30), pady=(pady, 0), sticky="w") + self.automatic_download_quality_combo_box.grid(row=3, column=2, pady=(pady, 0), sticky="w") + + self.automatic_download_info_label.grid( + row=4, column=0, columnspan=8, + padx=(100, 0), pady=(10, 0), sticky="w" + ) + + self.apply_changes_button.grid(row=5, column=3, pady=(pady, 0), sticky="w") + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.simultaneous_load_entry.configure(width=140 * scale, height=28 * scale) + self.simultaneous_download_entry.configure(width=140 * scale, height=28 * scale) + self.automatic_download_switch.configure(switch_width=36 * scale, switch_height=18 * scale) + self.automatic_download_quality_combo_box.configure(width=140 * scale, height=28 * scale) + self.apply_changes_button.configure(width=50 * scale, height=24 * scale) + + def set_widgets_fonts(self): + scale = GeneralSettings.settings["scale_r"] + title_font = ("Segoe UI", 13 * scale, "bold") + self.load_label.configure(font=title_font) + self.dash1_label.configure(font=title_font) + self.download_label.configure(font=title_font) + self.dash2_label.configure(font=title_font) + self.automatic_download_label.configure(font=title_font) + self.dash3_label.configure(font=title_font) + self.automatic_download_quality_label.configure(font=title_font) + self.dash4_label.configure(font=title_font) + + self.simultaneous_download_range_label.configure(font=title_font) + self.simultaneous_load_range_label.configure(font=title_font) + + value_font = ("Segoe UI", 13 * scale, "normal") + self.simultaneous_download_entry.configure(font=value_font) + self.simultaneous_load_entry.configure(font=value_font) + self.automatic_download_info_label.configure(font=value_font) + self.automatic_download_quality_combo_box.configure(font=value_font, dropdown_font=value_font) + + button_font = ("Segoe UI", 13 * scale, "bold") + self.apply_changes_button.configure(font=button_font) + + def reset_widgets_colors(self): + ... diff --git a/widgets/components/thumbnail_button.py b/widgets/components/thumbnail_button.py new file mode 100644 index 0000000..c93a384 --- /dev/null +++ b/widgets/components/thumbnail_button.py @@ -0,0 +1,75 @@ +import tkinter as tk +from tkinter import PhotoImage +import threading +import time +from typing import Tuple, List, Literal, Any +from services import LoadingIndicateManager +from settings import GeneralSettings + + +class ThumbnailButton(tk.Button): + def __init__( + self, + master: Any = None, + font: Tuple[str, int, str] = ("arial", 14, "bold"), + command: callable = None, + height: int = 0, + width: int = 0, + thumbnails: List[PhotoImage] = None, + state: Literal["normal", "disabled"] = "normal", + ): + + from widgets.video.added_video import AddedVideo + + self.master: AddedVideo = master + self.loading_animation_state: Literal["enabled", "disabled", None] = None + self.loading_animation_running: bool = False + self.thumbnails = thumbnails + + super().__init__( + master=master, + width=width, + height=height, + bd=0, + font=font, + relief="sunken", + state=state, + cursor="hand2", + command=command + ) + + def run_loading_animation_thread(self): + self.configure(image="") + self.loading_animation_running = True + if self.loading_animation_state != "disabled": + self.loading_animation_state = "enabled" + while self.master.load_state != "removed": + self.configure(text="." * LoadingIndicateManager.dots_count) + time.sleep(GeneralSettings.settings["update_delay"]) + if self.loading_animation_state == "disabled" and self.master.load_state != "removed": + break + self.loading_animation_running = False + + def run_loading_animation(self): + self.loading_animation_state = "enabled" + threading.Thread(target=self.run_loading_animation_thread).start() + + def stop_loading_animation(self): + self.loading_animation_state = 'disabled' + while self.loading_animation_running: + time.sleep(GeneralSettings.settings["update_delay"]) + + def configure_thumbnail(self, thumbnails: List[PhotoImage]): + self.stop_loading_animation() + self.thumbnails = thumbnails + self.configure(image=thumbnails[0], text="") + + def show_failure_indicator(self, text_color: str): + self.stop_loading_animation() + self.configure(text="....", disabledforeground=text_color, image="") + + def on_mouse_enter(self, _event): + self.configure(image=self.thumbnails[1]) + + def on_mouse_leave(self, _event): + self.configure(image=self.thumbnails[0]) diff --git a/widgets/core_widgets/__init__.py b/widgets/core_widgets/__init__.py new file mode 100644 index 0000000..4335953 --- /dev/null +++ b/widgets/core_widgets/__init__.py @@ -0,0 +1,4 @@ +from .alert_window import AlertWindow +from .setting_panel import SettingPanel +from .context_menu import ContextMenu +from .tray_menu import TrayMenu diff --git a/widgets/core_widgets/alert_window.py b/widgets/core_widgets/alert_window.py new file mode 100644 index 0000000..b59feda --- /dev/null +++ b/widgets/core_widgets/alert_window.py @@ -0,0 +1,119 @@ +import customtkinter as ctk +from PIL import Image +from typing import Callable +from settings import ThemeSettings, GeneralSettings + + +class AlertWindow(ctk.CTkToplevel): + def __init__( + self, + master: ctk.CTk = None, + alert_msg: str = "Something went wrong,,.!", + ok_button_text: str = None, + ok_button_callback: Callable = None, + cancel_button_text: str = None, + cancel_button_callback: Callable = None, + callback: Callable = None, + width: int = 400, + height: int = 200): + + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"], + width=width, + height=height) + + scale = GeneralSettings.settings["scale_r"] + + self.master: ctk.CTk = master + self.width = width + self.height = height + self.callback = callback + self.geometry(f"{self.width}x{self.height}") + print(f"{self.width}x{self.height}") + self.configure(width=self.width), + self.configure(height=self.height) + self.resizable(False, False) + self.iconbitmap("assets\\main icon\\icon.ico") + self.title("PytubeDownloader") + self.transient(master) + self.grab_set() + + self.info_image = ctk.CTkImage(Image.open("assets\\ui images\\info.png"), size=(70 * scale, 70 * scale)) + self.info_image_label = ctk.CTkLabel( + master=self, + text="", + image=self.info_image, + width=70, height=70 + ) + self.info_image_label.pack(side="left", fill="y", padx=(30 * scale, 10 * scale)) + + self.error_msg_label = ctk.CTkLabel( + master=self, + text=alert_msg, + text_color=ThemeSettings.settings["alert_window"]["msg_color"]["normal"], + font=("Arial", 13 * scale, "bold") + ) + self.error_msg_label.pack(pady=(20 * scale, 15 * scale), padx=(0, 30 * scale)) + + if cancel_button_text is not None: + self.cancel_button = ctk.CTkButton( + border_width=2, + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + master=self, + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + command=self.on_click_cancel_button, + text=cancel_button_text, + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + width=100 * scale, + height=28 * scale, + font=("Arial", 12 * scale, "bold") + ) + self.cancel_button.pack(side="right", padx=(20 * scale, 40 * scale)) + + if ok_button_text is not None: + self.ok_button = ctk.CTkButton( + border_width=2, + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + master=self, + hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + command=self.on_click_ok_button, + text=ok_button_text, + fg_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + width=100 * scale, + height=28 * scale, + font=("Arial", 12 * scale, "bold") + ) + self.ok_button.pack(side="right", padx=(0, 20 * scale)) + + self.ok_button_callback = ok_button_callback + self.cancel_button_callback = cancel_button_callback + + self.master.bind("", self.move) + self.bind("", self.move) + self.protocol("WM_DELETE_WINDOW", self.on_closing) + self.move("event") + + def move(self, _event): + geometry_x = int(self.master.winfo_width() * 0.5 + self.master.winfo_x() - 0.5 * self.width) + geometry_y = int(self.master.winfo_height() * 0.5 + self.master.winfo_y() - 0.5 * self.height) + self.geometry(f"{self.width}x{self.height}+{geometry_x}+{geometry_y}") + + def on_closing(self): + self.transient(None) + self.grab_release() + self.unbind("") + self.master.unbind("") + self.destroy() + if self.callback is not None: + self.callback() + + def on_click_ok_button(self): + if self.ok_button_callback is not None: + self.ok_button_callback() + self.on_closing() + + def on_click_cancel_button(self): + if self.cancel_button_callback is not None: + self.cancel_button_callback() + self.on_closing() diff --git a/widgets/core_widgets/context_menu.py b/widgets/core_widgets/context_menu.py new file mode 100644 index 0000000..dfbd712 --- /dev/null +++ b/widgets/core_widgets/context_menu.py @@ -0,0 +1,138 @@ +import customtkinter as ctk +import pyautogui +from services import ThemeManager +from settings import ( + ThemeSettings, + GeneralSettings +) + + +class ContextMenu(ctk.CTkFrame): + def __init__(self, master, width, height): + super().__init__( + master=master, + width=width, + height=height, + border_width=2, + corner_radius=0, + fg_color=ThemeSettings.settings["root"]["fg_color"]["hover"] + ) + + self.select_all_button = ctk.CTkButton( + master=self, + command=self.select_all, + text="Select All", + corner_radius=0, + text_color=ThemeSettings.settings["context_menu"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["hover"] + ) + self.cut_button = ctk.CTkButton( + master=self, + command=self.cut, + text="Cut", + corner_radius=0, + text_color=ThemeSettings.settings["context_menu"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["hover"] + ) + self.copy_button = ctk.CTkButton( + master=self, + command=self.copy, + text="Copy", + corner_radius=0, + text_color=ThemeSettings.settings["context_menu"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["hover"] + ) + self.paste_button = ctk.CTkButton( + master=self, + command=self.paste, + text="Paste", + corner_radius=0, + text_color=ThemeSettings.settings["context_menu"]["text_color"], + fg_color=ThemeSettings.settings["root"]["fg_color"]["hover"] + ) + self.width = width + self.height = height + self.set_accent_color() + self.set_widgets_font() + self.set_widgets_sizes() + self.place_widgets() + ThemeManager.register_widget(self) + + def place_widgets(self): + self.select_all_button.pack(fill="x", padx=2, pady=(2, 0)) + self.cut_button.pack(fill="x", padx=2) + self.copy_button.pack(fill="x", padx=2) + self.paste_button.pack(fill="x", padx=2, pady=(0, 2)) + + def update_accent_color(self): + self.set_accent_color() + + def set_widgets_sizes(self): + self.copy_button.configure( + height=self.height / 4, + width=self.width + ) + self.cut_button.configure( + height=self.height / 4, + width=self.width + ) + self.select_all_button.configure( + height=self.height / 4, + width=self.width + ) + self.paste_button.configure( + height=self.height / 4, + width=self.width + ) + + def set_widgets_font(self): + scale = GeneralSettings.settings["scale_r"] + font = ("Segoe UI", 12 * scale, "bold") + self.copy_button.configure( + font=font + ) + self.cut_button.configure( + font=font + ) + self.select_all_button.configure( + font=font + ) + self.paste_button.configure( + font=font + ) + + def set_accent_color(self): + self.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + self.select_all_button.configure( + hover_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + self.cut_button.configure( + hover_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + self.copy_button.configure( + hover_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + self.paste_button.configure( + hover_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + + def reset_widgets_colors(self): + ... + + def select_all(self): + pyautogui.hotkey("ctrl", "a") + self.place_forget() + + def cut(self): + pyautogui.hotkey("ctrl", "x") + self.place_forget() + + def copy(self): + pyautogui.hotkey("ctrl", "c") + self.place_forget() + + def paste(self): + pyautogui.hotkey("ctrl", "v") + self.place_forget() diff --git a/widgets/core_widgets/setting_panel.py b/widgets/core_widgets/setting_panel.py new file mode 100644 index 0000000..d266977 --- /dev/null +++ b/widgets/core_widgets/setting_panel.py @@ -0,0 +1,96 @@ +from typing import Any, Callable +import customtkinter as ctk +from widgets import ( + AppearancePanel, + NetworkPanel, + DownloadsPanel, + AboutPanel, + NavigationPanel +) +from services import ( + ThemeManager +) +from settings import ( + ThemeSettings, + GeneralSettings +) + + +class SettingPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + # changes callbacks + theme_settings_change_callback: Callable = None, + general_settings_change_callback: Callable = None, + restart_callback: Callable = None): + super().__init__( + master=master, + fg_color=ThemeSettings.settings["root"]["fg_color"]["normal"] + ) + + self.appearance_panel = AppearancePanel( + master=self, + theme_settings_change_callback=theme_settings_change_callback, + general_settings_change_callback=general_settings_change_callback, + restart_callback=restart_callback + ) + + self.network_panel = NetworkPanel( + master=self, + general_settings_change_callback=general_settings_change_callback + ) + + self.downloads_panel = DownloadsPanel( + master=self, + general_settings_change_callback=general_settings_change_callback + ) + + self.about_panel = AboutPanel( + master=self + ) + + self.panels = [self.appearance_panel, self.network_panel, self.downloads_panel, self.about_panel] + self.nav_buttons = ["Appearance", "Network", "Downloads", "About"] + self.navigation_panel = NavigationPanel( + master=self, + navigation_panels=self.panels, + navigation_button_on_click_callback=self.place_panel, + navigation_buttons_text=self.nav_buttons, + width=200, + ) + + self.vertical_line = ctk.CTkFrame( + master=self, + width=2 + ) + + ThemeManager.register_widget(self) + self.set_accent_color() + self.set_widgets_sizes() + self.place_widgets() + + def place_widgets(self) -> None: + self.navigation_panel.pack(side="left", fill="y") + self.vertical_line.pack(side="left", fill="y") + self.appearance_panel.pack(side="right", fill="both", expand=True) + + def place_panel(self, selected_panel: ctk.CTkFrame) -> None: + for panel in self.panels: + if panel is not selected_panel: + panel.pack_forget() + else: + selected_panel.pack(side="right", fill="both", expand=True) + + def set_widgets_sizes(self): + scale = GeneralSettings.settings["scale_r"] + self.navigation_panel.configure(width=400 * scale) + + def set_accent_color(self): + self.vertical_line.configure(fg_color=ThemeSettings.settings["root"]["accent_color"]["hover"]) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... diff --git a/widgets/core_widgets/tray_menu.py b/widgets/core_widgets/tray_menu.py new file mode 100644 index 0000000..db1d301 --- /dev/null +++ b/widgets/core_widgets/tray_menu.py @@ -0,0 +1,44 @@ +from PIL import Image +import pystray +from typing import Callable + + +class TrayMenu: + """ + A class to manage a system tray menu for the application. + + Attributes: + tray_image (PIL.Image): The image used for the tray icon. + tray_menu (tuple): A tuple containing menu items for the tray menu. + tray_icon (pystray.Icon): The system tray icon object. + """ + + def __init__( + self, + open_command: Callable = None, + quit_command: Callable = None): + """ + Initialize the TrayMenu object. + + Args: + open_command (Callable, optional): The function to execute when "Open" menu item is clicked. + quit_command (Callable, optional): The function to execute when "Quit" menu item is clicked. + """ + self.tray_image = Image.open("assets/main icon/icon.ico") + self.tray_menu = ( + pystray.MenuItem("Open", open_command), + pystray.MenuItem("Quit", quit_command) + ) + self.tray_icon = pystray.Icon("name", self.tray_image, "PyTube Downloader", self.tray_menu) + + def run(self): + """ + Run the system tray icon. + """ + self.tray_icon.run() + + def stop(self): + """ + Stop the system tray icon. + """ + self.tray_icon.stop() diff --git a/widgets/play_list/__init__.py b/widgets/play_list/__init__.py new file mode 100644 index 0000000..50a5d1f --- /dev/null +++ b/widgets/play_list/__init__.py @@ -0,0 +1,4 @@ +from .play_list import PlayList +from .added_play_list import AddedPlayList +from .downloading_play_list import DownloadingPlayList +from .downloaded_play_list import DownloadedPlayList diff --git a/widgets/play_list/added_play_list.py b/widgets/play_list/added_play_list.py new file mode 100644 index 0000000..d0df77c --- /dev/null +++ b/widgets/play_list/added_play_list.py @@ -0,0 +1,361 @@ +import threading +import customtkinter as ctk +import pytube +from typing import Literal, Union, Any, List +from .play_list import PlayList +from widgets import AddedVideo +from utils import GuiUtils +from settings import ThemeSettings, ScaleSettings, GeneralSettings + + +class AddedPlayList(PlayList): + def __init__( + self, + master: Any = None, + width: int = None, + height: int = None, + # playlist info + playlist_url: str = None, + # callback function for download buttons + playlist_download_button_click_callback: callable = None, + video_download_button_click_callback: callable = None): + + # widgets + self.sub_frame: Union[ctk.CTkFrame, None] = None + self.resolution_select_menu: Union[ctk.CTkComboBox, None] = None + self.download_btn: Union[ctk.CTkButton, None] = None + self.status_label: Union[ctk.CTkLabel, None] = None + self.reload_btn: Union[ctk.CTkButton, None] = None + self.videos_status_label: Union[ctk.CTkLabel, None] = None + """self.loading_video_count_label: Union[ctk.CTkLabel, None] = None + self.waiting_video_count_label: Union[ctk.CTkLabel, None] = None""" + # playlist object + self.playlist: Union[pytube.Playlist, None] = None + # callback utils + self.playlist_download_button_click_callback: callable = playlist_download_button_click_callback + self.video_download_button_click_callback: callable = video_download_button_click_callback + # all video objects + self.videos: List[Union[None, AddedVideo]] = [] + # state + self.load_state: Literal[None, "waiting", "loading", "failed", "completed"] = None + # vars for state track + self.waiting_videos: List[AddedVideo] = [] + self.loading_videos: List[AddedVideo] = [] + self.failed_videos: List[AddedVideo] = [] + self.completed_videos: List[AddedVideo] = [] + + super().__init__( + master=master, + height=height, + width=width, + playlist_url=playlist_url + ) + + threading.Thread(target=self.load_playlist, daemon=True).start() + + def load_playlist(self): + self.view_btn.configure(state="disabled") + try: + self.playlist = pytube.Playlist(self.playlist_url) + self.playlist_video_count = int(self.playlist.length) + self.channel = str(self.playlist.owner) + self.playlist_title = str(self.playlist.title) + self.channel_url = str(self.playlist.owner_url) + self.view_btn.configure(state="normal") + self.channel_btn.configure(state="normal") + self.load_state = "completed" + self.set_playlist_data() + self.load_videos() + except Exception as error: + print(f"added_play_list.py : {error}") + self.indicate_loading_failure() + + def load_videos(self): + for video_url in self.playlist.video_urls: + video = AddedVideo( + master=self.playlist_item_frame, + width=self.playlist_item_frame.winfo_width() - 20, + height=70 * GeneralSettings.settings["scale_r"], + video_url=video_url, + video_download_button_click_callback=self.video_download_button_click_callback, + mode="playlist", + # videos state track + video_load_status_callback=self.videos_status_track, + ) + video.pack(fill="x", padx=(20, 0), pady=1) + self.videos.append(video) + + def videos_status_track( + self, + video: AddedVideo, + state: Literal["waiting", "loading", "completed", "failed", "removed"]): + if state == "removed": + self.videos.remove(video) + self.playlist_video_count -= 1 + if len(self.videos) == 0: + self.kill() + else: + if video in self.loading_videos: + self.loading_videos.remove(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + if video in self.waiting_videos: + self.waiting_videos.remove(video) + if video in self.completed_videos: + self.completed_videos.remove(video) + elif state == "failed": + self.failed_videos.append(video) + if video in self.loading_videos: + self.loading_videos.remove(video) + elif state == "loading": + if video in self.waiting_videos: + self.waiting_videos.remove(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + self.loading_videos.append(video) + elif state == "waiting": + self.waiting_videos.append(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + elif state == "completed": + self.completed_videos.append(video) + self.loading_videos.remove(video) + + if len(self.videos) != 0: + self.videos_status_label.configure( + text=f"Failed : {len(self.failed_videos)} | " + f"Waiting : {len(self.waiting_videos)} | " + f"Loading : {len(self.loading_videos)} | " + f"Loaded : {len(self.completed_videos)}" + ) + self.playlist_video_count_label.configure( + text=self.playlist_video_count + ) + if len(self.failed_videos) != 0: + self.indicate_loading_failure() + else: + self.clear_loading_failure() + if len(self.loading_videos) == 0 and len(self.waiting_videos) == 0 and len(self.failed_videos) == 0: + self.set_loading_completed() + + def reload_playlist(self): + self.reload_btn.place_forget() + self.status_label.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + text="Loading" + ) + if len(self.videos) != 0: + for video in self.videos: + if video.load_state == "failed": + video.reload_video() + else: + threading.Thread(target=self.load_playlist, daemon=True).start() + + def indicate_loading_failure(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["AddedPlayList"][str(scale)] + + self.status_label.configure( + text="Failed", + text_color=ThemeSettings.settings["video_object"]["error_color"]["normal"] + ) + self.reload_btn.place(relx=1, y=y[3], x=-80 * scale) + + def clear_loading_failure(self): + self.status_label.configure( + text="Loading", + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.reload_btn.place_forget() + + def set_loading_completed(self): + self.status_label.configure(text="Loaded") + self.download_btn.configure(state="normal") + self.resolution_select_menu.configure(values=["Highest Quality", "Lowest Quality", "Audio Only"]) + self.resolution_select_menu.set("Highest Quality") + self.resolution_select_menu.configure(command=self.select_download_option) + + def select_download_option(self, e): + index = None + if e == "Highest Quality": + index = 0 + elif e == "Lowest Quality": + index = -2 + elif e == "Audio Only": + index = -1 + for video in self.videos: + video.resolution_select_menu.set(video.resolution_select_menu.cget("values")[index]) + video.choose_download_type(video.resolution_select_menu.get()) + + def kill(self): + for video in self.videos: + video.video_load_status_callback = GuiUtils.do_nothing + video.kill() + super().kill() + + # create widgets + def create_widgets(self): + super().create_widgets() + scale = GeneralSettings.settings["scale_r"] + + self.sub_frame = ctk.CTkFrame( + master=self.playlist_info_widget, + height=self.height - 4, + width=340 * scale, + ) + + self.resolution_select_menu = ctk.CTkComboBox( + master=self.sub_frame, + values=["..........", "..........", ".........."], + dropdown_font=("Segoe UI", 13 * scale, "normal"), + font=("Segoe UI", 13 * scale, "normal"), + width=150 * scale, + height=28 * scale + ) + + self.download_btn = ctk.CTkButton( + master=self.sub_frame, + text="Download", + width=80 * scale, + height=25 * scale, + font=("arial", 12 * scale, "bold"), + border_width=2, + state="disabled", + hover=False, + command=lambda: self.playlist_download_button_click_callback(self) + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="Loading", + height=15, + font=("arial", 13 * scale, "bold"), + ) + + self.reload_btn = ctk.CTkButton( + self.playlist_info_widget, + text="⟳", + width=15 * scale, + height=15 * scale, + font=("arial", 22 * scale, "normal"), + command=self.reload_playlist, + hover=False, + ) + + self.videos_status_label = ctk.CTkLabel( + master=self.sub_frame, + text=f"Failed : {len(self.failed_videos)} | " + f"Waiting : {len(self.waiting_videos)} | " + f"Loading : {len(self.loading_videos)} | " + f"Loaded : {len(self.completed_videos)}", + + height=15 * scale, + font=("arial", 11 * scale, "bold"), + ) + + # configure widgets colors + def set_accent_color(self): + self.resolution_select_menu.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + dropdown_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.download_btn.configure(border_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + + self.download_btn.configure(border_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + super().set_accent_color() + + def reset_widgets_colors(self): + super().reset_widgets_colors() + + def set_widgets_colors(self): + self.sub_frame.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.reload_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.videos_status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.resolution_select_menu.configure( + dropdown_fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + ) + super().set_widgets_colors() + + def on_mouse_enter_self(self, _event): + super().on_mouse_enter_self(_event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + self.reload_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + + def on_mouse_leave_self(self, _event): + super().on_mouse_leave_self(_event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.reload_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + + def bind_widget_events(self): + super().bind_widget_events() + + def on_mouse_enter_download_btn(_event): + if self.download_btn.cget("state") == "normal": + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["hover"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.on_mouse_enter_self(_event) + + def on_mouse_leave_download_btn(_event): + if self.download_btn.cget("state") == "normal": + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["normal"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + self.download_btn.bind("", on_mouse_enter_download_btn) + self.download_btn.bind("", on_mouse_leave_download_btn) + + def on_mouse_enter_reload_btn(_event): + self.reload_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + ) + self.on_mouse_enter_self(_event) + + def on_mouse_leave_reload_btn(_event): + self.reload_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + + self.reload_btn.bind("", on_mouse_enter_reload_btn) + self.reload_btn.bind("", on_mouse_leave_reload_btn) + + # place widgets + def place_widgets(self): + super().place_widgets() + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["AddedPlayList"][str(scale)] + + self.title_label.place(width=-460 * scale) + self.channel_btn.place(width=-460 * scale) + self.url_label.place(width=-460 * scale) + + self.sub_frame.place(y=2, relx=1, x=-390 * scale) + + self.resolution_select_menu.place(y=y[0], x=0) + self.download_btn.place(x=160 * scale, y=y[1]) + self.status_label.place(x=200 * scale, anchor="n", y=y[2]) + + self.videos_status_label.place(rely=1, y=-18 * scale, relx=0.5, anchor="n") diff --git a/widgets/play_list/downloaded_play_list.py b/widgets/play_list/downloaded_play_list.py new file mode 100644 index 0000000..a2e2b4a --- /dev/null +++ b/widgets/play_list/downloaded_play_list.py @@ -0,0 +1,92 @@ +from widgets.play_list import PlayList +from widgets.video.downloaded_video import DownloadedVideo +from widgets.video.downloading_video import DownloadingVideo +from typing import Literal, List, Any +from utils import GuiUtils +import threading +from settings import GeneralSettings + + +class DownloadedPlayList(PlayList): + def __init__( + self, + master: Any = None, + width: int = 0, + height: int = 0, + # playlist info + channel_url: str = "", + playlist_url: str = "", + playlist_title: str = "---------", + channel: str = "---------", + playlist_video_count: int = 0, + # videos of playlist + videos: List[DownloadingVideo] = None): + + # playlist videos + self.downloading_videos: List[DownloadingVideo] = videos + self.videos: List[DownloadedVideo] = [] + + super().__init__( + master=master, + width=width, + height=height, + # playlist info + channel_url=channel_url, + playlist_url=playlist_url, + playlist_title=playlist_title, + channel=channel, + playlist_video_count=playlist_video_count, + ) + + threading.Thread(target=self.display_downloaded_widgets, daemon=True).start() + + def display_downloaded_widgets(self): + for downloading_video in self.downloading_videos: + video = DownloadedVideo( + master=self.playlist_item_frame, + height=70 * GeneralSettings.settings["scale_r"], + width=self.playlist_item_frame.winfo_width() - 20, + # video info + thumbnails=downloading_video.thumbnails, + video_title=downloading_video.video_title, + channel=downloading_video.channel, + channel_url=downloading_video.channel_url, + video_url=downloading_video.video_url, + file_size=downloading_video.file_size, + length=downloading_video.length, + # download info + download_path=downloading_video.download_file_name, + download_quality=downloading_video.download_quality, + download_type=downloading_video.download_type, + # state callbacks + mode=downloading_video.mode, + video_status_callback=self.videos_status_track, + ) + video.pack(fill="x", padx=(20, 0), pady=1) + self.videos.append(video) + self.view_btn.configure(state="normal") + + # status tracker + def videos_status_track( + self, + video: DownloadedVideo, + state: Literal["removed"]): + if state == "removed": + self.videos.remove(video) + self.playlist_video_count -= 1 + if self.playlist_video_count == 0: + self.kill() + else: + self.playlist_video_count_label.configure( + text=self.playlist_video_count + ) + + # configure widgets colors + def set_accent_color(self): + super().set_accent_color() + + def kill(self): + for video in self.videos: + video.video_status_callback = GuiUtils.do_nothing + video.kill() + super().kill() diff --git a/widgets/play_list/downloading_play_list.py b/widgets/play_list/downloading_play_list.py new file mode 100644 index 0000000..4c4b142 --- /dev/null +++ b/widgets/play_list/downloading_play_list.py @@ -0,0 +1,315 @@ +import customtkinter as ctk +import threading +from typing import List, Any, Literal, Union, Callable +from widgets.play_list import PlayList +from widgets.video.downloading_video import DownloadingVideo +from widgets.video.added_video import AddedVideo +from utils import GuiUtils +from settings import ThemeSettings, GeneralSettings, ScaleSettings + + +class DownloadingPlayList(PlayList): + def __init__( + self, + master: Any, + width: int = None, + height: int = None, + # playlist details + channel_url: str = None, + playlist_url: str = None, + playlist_title: str = "---------", + channel: str = "---------", + playlist_video_count: int = 0, + # videos of playlist + videos: List[AddedVideo] = None, + # playlist download completed callback utils + playlist_download_complete_callback: Callable = None): + + # widgets + self.sub_frame: Union[ctk.CTkFrame, None] = None + self.download_progress_bar: Union[ctk.CTkProgressBar, None] = None + self.download_percentage_label: Union[ctk.CTkLabel, None] = None + self.status_label: Union[ctk.CTkLabel, None] = None + self.re_download_btn: Union[ctk.CTkButton, None] = None + self.videos_status_label: Union[ctk.CTkLabel, None] = None + # callback utils + self.playlist_download_complete_callback = playlist_download_complete_callback + self.added_videos: List[AddedVideo] = videos + self.videos: List[DownloadingVideo] = [] + # vars for state track + self.waiting_videos: List[DownloadingVideo] = [] + self.downloading_videos: List[DownloadingVideo] = [] + self.paused_videos: List[DownloadingVideo] = [] + self.failed_videos: List[DownloadingVideo] = [] + self.completed_videos: List[DownloadingVideo] = [] + + super().__init__( + master=master, + height=height, + width=width, + channel_url=channel_url, + playlist_url=playlist_url, + playlist_title=playlist_title, + channel=channel, + playlist_video_count=playlist_video_count, + ) + self.channel_btn.configure(state="normal") + threading.Thread(target=self.download_videos, daemon=True).start() + + def re_download_videos(self): + self.re_download_btn.place_forget() + for video in self.videos: + if video.download_state == "failed": + video.re_download_video() + + def download_videos(self): + for added_video in self.added_videos: + video = DownloadingVideo( + master=self.playlist_item_frame, + height=70 * GeneralSettings.settings["scale_r"], + width=self.playlist_item_frame.winfo_width() - 20, + channel_url=added_video.channel_url, + video_url=added_video.video_url, + # download info + download_type=added_video.download_type, + download_quality=added_video.download_quality, + # video info + video_title=added_video.video_title, + channel=added_video.channel, + thumbnails=added_video.thumbnails, + video_stream_data=added_video.video_stream_data, + length=added_video.length, + # download mode + mode="playlist", + video_download_complete_callback=None, + # videos state, download progress track + video_download_status_callback=self.videos_status_track, + video_download_progress_callback=self.videos_progress_track, + ) + video.pack(fill="x", padx=(20, 0), pady=1) + self.videos.append(video) + self.view_btn.configure(state="normal") + + def videos_status_track( + self, + video: DownloadingVideo, + state: Literal["waiting", "downloading", "paused", "completed", "failed", "removed"]): + if state == "removed": + self.videos.remove(video) + self.playlist_video_count -= 1 + if len(self.videos) == 0: + self.kill() + else: + if video in self.downloading_videos: + self.downloading_videos.remove(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + if video in self.waiting_videos: + self.waiting_videos.remove(video) + if video in self.completed_videos: + self.completed_videos.remove(video) + if video in self.paused_videos: + self.paused_videos.remove(video) + elif state == "failed": + self.failed_videos.append(video) + if video in self.downloading_videos: + self.downloading_videos.remove(video) + if video in self.paused_videos: + self.paused_videos.remove(video) + elif state == "downloading": + self.downloading_videos.append(video) + if video in self.waiting_videos: + self.waiting_videos.remove(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + if video in self.paused_videos: + self.paused_videos.remove(video) + elif state == "paused": + self.paused_videos.append(video) + if video in self.downloading_videos: + self.downloading_videos.remove(video) + elif state == "waiting": + self.waiting_videos.append(video) + if video in self.failed_videos: + self.failed_videos.remove(video) + elif state == "completed": + self.completed_videos.append(video) + self.downloading_videos.remove(video) + + if len(self.videos) != 0: + self.videos_status_label.configure( + text=f"Failed : {len(self.failed_videos)} | " + f"Waiting : {len(self.waiting_videos)} | " + f"Downloading : {len(self.downloading_videos)} | " + f"Paused : {len(self.paused_videos)} | " + f"Downloaded : {len(self.completed_videos)}", + ) + self.playlist_video_count_label.configure( + text=self.playlist_video_count + ) + if len(self.failed_videos) != 0: + self.indicate_downloading_failure() + else: + self.clear_downloading_failure() + if len(self.downloading_videos) == 0 and len(self.waiting_videos) == 0 and \ + len(self.failed_videos) == 0 and len(self.paused_videos) == 0: + self.set_downloading_completed() + + def videos_progress_track(self): + total_completion: float = 0 + for video in self.videos: + if video.file_size != 0: + total_completion += video.bytes_downloaded / video.file_size + avg_completion = total_completion / self.playlist_video_count + self.set_playlist_download_progress(avg_completion) + + def set_playlist_download_progress(self, progress): + self.download_progress_bar.set(progress) + self.download_percentage_label.configure(text=f"{round(progress * 100, 2)} %") + + def indicate_downloading_failure(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadingPlayList"][str(scale)] + + self.re_download_btn.place(relx=1, y=y[3], x=-80 * scale) + self.status_label.configure( + text="Failed", text_color=ThemeSettings.settings["video_object"]["error_color"]["normal"] + ) + + def clear_downloading_failure(self): + self.re_download_btn.place_forget() + self.status_label.configure( + text="Downloading", text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + + def set_downloading_completed(self): + self.status_label.configure( + text="Downloaded", text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.playlist_download_complete_callback(self) + self.kill() + + def kill(self): + for video in self.videos: + video.video_download_status_callback = GuiUtils.do_nothing + video.kill() + super().kill() + + # create widgets + def create_widgets(self): + super().create_widgets() + scale = GeneralSettings.settings["scale_r"] + + self.sub_frame = ctk.CTkFrame( + self, + height=self.height - 4, + ) + + self.download_progress_bar = ctk.CTkProgressBar( + master=self.sub_frame, + height=8 * scale + ) + + self.download_percentage_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "bold"), + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "bold"), + ) + + self.re_download_btn = ctk.CTkButton( + self, + text="⟳", + width=15 * scale, + height=15 * scale, + font=("arial", 20 * scale, "normal"), + command=self.re_download_videos, + hover=False + ) + + self.videos_status_label = ctk.CTkLabel( + master=self.sub_frame, + text=f"Failed : {len(self.failed_videos)} | " + f"Waiting : {len(self.waiting_videos)} | " + f"Downloading : {len(self.downloading_videos)} | " + f"Paused : {len(self.paused_videos)} | " + f"Downloaded : {len(self.completed_videos)}", + height=15 * scale, + font=("arial", 11 * scale, "bold"), + ) + + # configure widgets colors depend on root width + def set_accent_color(self): + super().set_accent_color() + self.re_download_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + + def set_widgets_colors(self): + super().set_widgets_colors() + self.sub_frame.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.download_percentage_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + + def on_mouse_enter_self(self, _event): + super().on_mouse_enter_self(_event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + self.re_download_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + + def on_mouse_leave_self(self, _event): + super().on_mouse_leave_self(_event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.re_download_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + + def bind_widget_events(self): + super().bind_widget_events() + + def on_mouse_enter_re_download_btn(_event): + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.on_mouse_enter_self(_event) + + def on_mouse_leave_download_btn(_event): + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.re_download_btn.bind("", on_mouse_enter_re_download_btn) + self.re_download_btn.bind("", on_mouse_leave_download_btn) + + # place widgets + def place_widgets(self): + super().place_widgets() + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadingPlayList"][str(scale)] + + self.title_label.place(width=-20 * scale, relwidth=0.5) + self.channel_btn.place(width=-20 * scale, relwidth=0.5) + self.url_label.place(width=-20 * scale, relwidth=0.5) + + self.sub_frame.place(relx=0.5, y=2, x=50 * scale) + self.download_percentage_label.place(relx=0.5, anchor="n", y=y[0]) + self.download_percentage_label.configure(height=20 * scale) + self.download_progress_bar.place(relwidth=1, y=y[1]) + self.status_label.place(relx=0.775, anchor="n", y=y[2]) + self.status_label.configure(height=20 * scale) + self.videos_status_label.place(rely=1, y=-18 * scale, relx=0.5, anchor="n") + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + scale = GeneralSettings.settings["scale_r"] + self.sub_frame.configure(width=self.winfo_width() / 2 - 150 * scale) diff --git a/widgets/play_list/play_list.py b/widgets/play_list/play_list.py new file mode 100644 index 0000000..b80f550 --- /dev/null +++ b/widgets/play_list/play_list.py @@ -0,0 +1,350 @@ +import tkinter as tk +import webbrowser +import customtkinter as ctk +from typing import Any, Union +from services import ThemeManager +from settings import ThemeSettings, GeneralSettings, ScaleSettings + + +class PlayList(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + width: int = 0, + height: int = 0, + # playlist info + channel_url: str = "-------", + playlist_url: str = "-------", + playlist_title: str = "-------", + channel: str = "-------", + playlist_video_count: int = 0): + + super().__init__( + master=master, + width=width, + ) + + self.height: int = height + self.width: int = width + # playlist info + self.channel_url: str = channel_url + self.channel: str = channel + self.playlist_url: str = playlist_url + self.playlist_title: str = playlist_title + self.playlist_video_count = playlist_video_count + # widgets + self.playlist_info_widget: Union[ctk.CTkFrame, None] = None + self.view_btn: Union[ctk.CTkButton, None] = None + self.title_label: Union[tk.Label, None] = None + self.channel_btn: Union[tk.Button, None] = None + self.url_label: Union[ctk.CTkLabel, None] = None + self.remove_btn: Union[ctk.CTkButton, None] = None + self.playlist_video_count_label: Union[ctk.CTkLabel, None] = None + self.playlist_item_frame: Union[ctk.CTkFrame, None] = None + # self.on_mouse_state: Literal["enter", "leave"] = "leave" + # initialize the object + self.create_widgets() + self.set_widgets_colors() + self.reset_widgets_colors() + self.set_accent_color() + self.place_widgets() + self.bind_widget_events() + # self append to theme manger + ThemeManager.register_widget(self) + + def hide_videos(self): + scale = GeneralSettings.settings["scale_r"] + + self.view_btn.configure( + command=self.view_videos, + text=">", + font=('arial', 18 * scale, 'bold') + ) + self.playlist_item_frame.pack_forget() + + def view_videos(self): + scale = GeneralSettings.settings["scale_r"] + + self.view_btn.configure( + command=self.hide_videos, + text="V", + font=('arial', 13 * scale, 'bold') + ) + self.playlist_item_frame.pack(padx=10, fill="x", pady=2) + + def set_playlist_data(self): + self.playlist_video_count_label.configure(text=f"{self.playlist_video_count}") + self.title_label.configure(text=f"Title : {self.playlist_title}") + self.channel_btn.configure(text=f"Channel : {self.channel}") + self.url_label.configure(text=self.playlist_url) + self.channel_btn.configure(state="normal") + + def kill(self): + ThemeManager.unregister_widget(self) + self.pack_forget() + self.destroy() + + def create_widgets(self): + scale = GeneralSettings.settings["scale_r"] + + self.playlist_info_widget = ctk.CTkFrame( + master=self, + border_width=1, + height=self.height, + width=self.width + ) + + self.view_btn = ctk.CTkButton( + master=self.playlist_info_widget, + font=('arial', 18 * scale, 'bold'), + text=">", + width=1, + height=1, + hover=False, + command=self.view_videos, + state="disabled", + cursor="hand2", + ) + + self.title_label = tk.Label( + master=self.playlist_info_widget, + anchor="w", + font=('arial', int(10 * scale), 'bold'), + text=f"Title : {self.playlist_title}" + ) + + self.channel_btn = tk.Button( + master=self.playlist_info_widget, + font=('arial', int(9 * scale), 'bold'), + anchor="w", + bd=0, + command=lambda: webbrowser.open(self.channel_url), + relief="sunken", + state="disabled", + cursor="hand2", + text=f"Channel : {self.channel}" + ) + + self.url_label = tk.Label( + master=self.playlist_info_widget, anchor="w", + font=('arial', int(11 * scale), "italic underline"), + text=self.playlist_url, + ) + + self.remove_btn = ctk.CTkButton( + master=self.playlist_info_widget, + command=self.kill, + text="X", + font=("arial", 12 * scale, "bold"), + width=20 * scale, + height=20 * scale, + border_spacing=0, + hover=False, + ) + + self.playlist_video_count_label = ctk.CTkLabel( + master=self.playlist_info_widget, + width=15 * scale, height=15 * scale, + font=("arial", 13 * scale, "bold"), + justify="right", + text=f"{self.playlist_video_count}" + ) + + self.playlist_item_frame = ctk.CTkFrame( + master=self, + ) + + self.bind("", self.configure_widget_sizes) + + # configure widgets colors + def set_accent_color(self): + self.playlist_info_widget.configure( + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.view_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.url_label.configure( + fg=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + self.title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ), + activeforeground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["hover"]), + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + ) + + def set_widgets_colors(self): + self.configure( + fg_color=self.master.cget("fg_color") + ) + self.playlist_item_frame.configure( + fg_color=self.master.cget("fg_color") + ) + self.playlist_info_widget.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + ) + self.playlist_info_widget.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["normal"] + ) + self.playlist_video_count_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + + def on_mouse_enter_self(self, event): + # self.on_mouse_state = "enter" + self.playlist_info_widget.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.view_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + ) + self.title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + + # disable due to ui performance + """def on_mouse_enter_videos(): + video_object: AddedVideo + for video_object in self.playlist_item_frame.winfo_children(): + if type(video_object) is AddedVideo or \ + type(video_object) is DownloadingVideo or \ + type(video_object) is DownloadedVideo: + if self.on_mouse_state == "enter": + video_object.on_mouse_enter_self(event) + else: + break + threading.Thread(target=on_mouse_enter_videos).start()""" + + def on_mouse_leave_self(self, event): + # self.on_mouse_state = "leave" + self.playlist_info_widget.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + ) + self.title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + + # disable due to ui performance + """def on_mouse_leave_videos(): + video_object: AddedVideo + for video_object in self.playlist_item_frame.winfo_children(): + if type(video_object) is AddedVideo or \ + type(video_object) is DownloadingVideo or \ + type(video_object) is DownloadedVideo: + if self.on_mouse_state == "leave": + video_object.on_mouse_leave_self(event) + else: + break + threading.Thread(target=on_mouse_leave_videos).start()""" + + def bind_widget_events(self): + self.playlist_info_widget.bind("", self.on_mouse_enter_self) + self.playlist_info_widget.bind("", self.on_mouse_leave_self) + for child_widgets in self.playlist_info_widget.winfo_children() + self.playlist_item_frame.winfo_children(): + child_widgets.bind("", self.on_mouse_enter_self) + child_widgets.bind("", self.on_mouse_leave_self) + try: + for sub_child_widgets in child_widgets.winfo_children(): + sub_child_widgets.bind("", self.on_mouse_enter_self) + sub_child_widgets.bind("", self.on_mouse_leave_self) + except Exception as error: + print(f"1@PlayList.py > Err : {error}") + pass + + def on_mouse_enter_channel_btn(event): + self.channel_btn.configure( + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["btn_text_color"]["hover"] + ), + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_channel_btn(_event): + self.channel_btn.configure( + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["btn_text_color"]["normal"] + ), + ) + + self.channel_btn.bind("", on_mouse_enter_channel_btn) + self.channel_btn.bind("", on_mouse_leave_channel_btn) + + def on_mouse_enter_remove_btn(event): + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["hover"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["hover"] + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_remove_btn(_event): + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["normal"] + ) + + self.remove_btn.bind("", on_mouse_enter_remove_btn) + self.remove_btn.bind("", on_mouse_leave_remove_btn) + + # place widgets + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["PlayList"][str(scale)] + + self.playlist_info_widget.pack(fill="x") + + self.view_btn.place(y=y[0], x=10 * scale) + self.title_label.place(x=50 * scale, y=y[1], height=20 * scale, width=-420 * scale, relwidth=1) + self.channel_btn.place(x=50 * scale, y=y[2], height=20 * scale, width=-420 * scale, relwidth=1) + self.url_label.place(x=50 * scale, y=y[3], height=20 * scale, width=-420 * scale, relwidth=1) + + self.playlist_video_count_label.place(relx=1, x=-40 * scale, rely=1, y=-25 * scale) + self.remove_btn.place(relx=1, x=-23 * scale, y=3 * scale) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + ... diff --git a/widgets/video/__init__.py b/widgets/video/__init__.py new file mode 100644 index 0000000..2c61834 --- /dev/null +++ b/widgets/video/__init__.py @@ -0,0 +1,4 @@ +from .video import Video +from .added_video import AddedVideo +from .downloading_video import DownloadingVideo +from .downloaded_video import DownloadedVideo diff --git a/widgets/video/added_video.py b/widgets/video/added_video.py new file mode 100644 index 0000000..4126c14 --- /dev/null +++ b/widgets/video/added_video.py @@ -0,0 +1,393 @@ +import tkinter as tk +from widgets.video import Video +import customtkinter as ctk +import pytube +import threading +from typing import Literal, Union, List, Any, Callable, Tuple, Dict +from PIL import Image +from services import ( + LoadManager, + ThemeManager, +) +from settings import ( + ThemeSettings, + GeneralSettings, + ScaleSettings +) +from utils import ( + ImageUtility, + DownloadInfoUtility, + FileUtility +) + + +class AddedVideo(Video): + def __init__( + self, + master: Any, + width: int = 0, + height: int = 0, + # video info + video_url: str = "", + # callback utils for buttons + video_download_button_click_callback: Callable = None, + # state callbacks only use if mode is play list + mode: Literal["video", "playlist"] = "video", + video_load_status_callback: callable = None): + + # video info + self.video_stream_data: pytube.YouTube.streams = None + self.support_download_types: Union[List[Dict[str, int]], None] = None + # download info + self.download_quality: Literal["128kbps", "360p", "720p"] = "720p" + self.download_type: Literal["Audio", "Video"] = "Video" + # callback utils + self.video_download_button_click_callback: Callable = video_download_button_click_callback + self.video_load_status_callback: Callable = video_load_status_callback + # state + self.load_state: Literal["waiting", "loading", "failed", "completed", "removed"] = "waiting" + # widgets + self.sub_frame: Union[ctk.CTkFrame, None] = None + self.resolution_select_menu: Union[ctk.CTkComboBox, None] = None + self.download_btn: Union[ctk.CTkButton, None] = None + self.status_label: Union[ctk.CTkLabel, None] = None + self.reload_btn: Union[ctk.CTkButton, None] = None + # video object + self.video: Union[pytube.YouTube, None] = None + + self.mode: Literal["video", "playlist"] = mode + + super().__init__( + master=master, + width=width, + height=height, + video_url=video_url + ) + self.load_video() + + def reload_video(self): + self.load_state = None + self.reload_btn.place_forget() + self.thumbnail_btn.configure( + disabledforeground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + ) + self.thumbnail_btn.run_loading_animation() + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Loading" + ) + self.load_video() + + def load_video(self): + self.thumbnail_btn.run_loading_animation() + if GeneralSettings.settings["max_simultaneous_loads"] > LoadManager.active_load_count: + self.load_state = "loading" + LoadManager.active_loads.append(self) + LoadManager.active_load_count += 1 + if self.mode == "playlist": + self.video_load_status_callback(self, self.load_state) + self.status_label.configure(text="Loading") + threading.Thread(target=self.retrieve_video_data, daemon=True).start() + else: + self.set_waiting() + + def get_thumbnails(self) -> Tuple[tk.PhotoImage, tk.PhotoImage]: + thumbnail_size = (int(113 * GeneralSettings.settings["scale_r"]), int(64 * GeneralSettings.settings["scale_r"])) + thumbnail_save_directory = "temp/thumbnails/" + thumbnail_url = self.video.thumbnail_url + # Generate download path to thumbnail based on url + file_name = FileUtility.sanitize_filename(thumbnail_url) + thumbnail_download_path = FileUtility.get_available_file_name( + thumbnail_save_directory + file_name + "-og.png" + ) + ImageUtility.download_image(image_url=thumbnail_url, output_image_path=thumbnail_download_path) + # Open downloaded thumbnail as Image + thumbnail = Image.open(thumbnail_download_path) + + # getting downloaded thumbnail width and height + image_height = thumbnail.height + image_width = thumbnail.width + + if round(image_width / 4 * 3) <= image_height: + is_thumbnail_need_to_crop = True + else: + is_thumbnail_need_to_crop = False + + if is_thumbnail_need_to_crop: + ignore_pos = int(image_height * 0.25 / 2) + start_pos = (0, ignore_pos) + end_pos = (image_width, image_height - ignore_pos) + thumbnail = ImageUtility.crop_image(image=thumbnail, start_position=start_pos, end_position=end_pos) + + thumbnail_hover = ImageUtility.create_image_with_hover_effect(image=thumbnail, intensity_increase=50) + + corner_radius = int(image_width / 18) + thumbnail = ImageUtility.create_image_with_rounded_corners(thumbnail, radius=corner_radius) + thumbnail_hover = ImageUtility.create_image_with_rounded_corners(thumbnail_hover, radius=corner_radius) + + thumbnail = ImageUtility.resize_image(image=thumbnail, new_size=thumbnail_size) + thumbnail_hover = ImageUtility.resize_image(image=thumbnail_hover, new_size=thumbnail_size) + + thumbnail_normal_save_path = FileUtility.get_available_file_name( + thumbnail_save_directory + file_name + "-normal-changed.png" + ) + thumbnail_hover_save_path = FileUtility.get_available_file_name( + thumbnail_save_directory + file_name + "-hover-changed.png" + ) + thumbnail.save(thumbnail_normal_save_path) + thumbnail_hover.save(thumbnail_hover_save_path) + + thumbnail_normal = tk.PhotoImage(file=thumbnail_normal_save_path) + thumbnail_hover = tk.PhotoImage(file=thumbnail_hover_save_path) + + return thumbnail_normal, thumbnail_hover + + def retrieve_video_data(self): + try: + self.video = pytube.YouTube(self.video_url) + self.video_title = str(self.video.title) + self.channel = str(self.video.author) + self.length = int(self.video.length) + self.video_stream_data = self.video.streams + self.channel_url = str(self.video.channel_url) + self.thumbnails = self.get_thumbnails() + self.support_download_types = ( + DownloadInfoUtility.sort_download_qualities( + DownloadInfoUtility.get_supported_download_types(self.video_stream_data) + ) + ) + self.set_load_completed() + self.set_video_data() + self.download_automatically() + except Exception as error: + print(f"AddedVideo.py : {error}") + self.set_loading_failed() + + def choose_download_type(self, e: str): + self.download_quality = e.replace(" ", "").split("|")[0] + if "kbps" in self.download_quality: + self.download_type = "Audio" + elif "p" in self.download_quality: + self.download_type = "Video" + + def set_waiting(self): + self.load_state = "waiting" + if self.mode == "playlist": + self.video_load_status_callback(self, self.load_state) + LoadManager.queued_loads.append(self) + self.status_label.configure(text="Waiting") + + def select_download_quality_automatic(self): + index = None + if GeneralSettings.settings["automatic_download"]["quality"] == "Audio Only": + index = -1 + elif GeneralSettings.settings["automatic_download"]["quality"] == "Lowest Quality": + index = -2 + elif GeneralSettings.settings["automatic_download"]["quality"] == "Highest Quality": + index = 0 + self.resolution_select_menu.set(self.resolution_select_menu.cget("values")[index]) + + def download_automatically(self): + if self.mode == "video" and GeneralSettings.settings["automatic_download"]["status"] == "enable": + self.select_download_quality_automatic() + self.choose_download_type(self.resolution_select_menu.get()) + self.video_download_button_click_callback(self) + + def set_load_completed(self): + if self.load_state != "removed": + self.load_state = "completed" + if self.mode == "playlist": + self.video_load_status_callback(self, self.load_state) + self.status_label.configure(text="Loaded") + if self in LoadManager.active_loads: + LoadManager.active_loads.remove(self) + LoadManager.active_load_count -= 1 + + def set_loading_failed(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["AddedVideo"][str(scale)] + if self.load_state != "removed": + if self in LoadManager.active_loads: + LoadManager.active_loads.remove(self) + LoadManager.active_load_count -= 1 + self.load_state = "failed" + if self.mode == "playlist": + self.video_load_status_callback(self, self.load_state) + self.thumbnail_btn.show_failure_indicator( + text_color=ThemeSettings.settings["video_object"]["error_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text="Failed" + ) + self.reload_btn.place(relx=1, y=y[3], x=-80 * scale) + + def set_video_data(self): + if self.load_state != "removed": + super().set_video_data() + self.resolution_select_menu.configure( + values=DownloadInfoUtility.generate_download_options(self.support_download_types) + ) + self.resolution_select_menu.set(self.resolution_select_menu.cget("values")[0]) + self.choose_download_type(self.resolution_select_menu.get()) + self.resolution_select_menu.configure(command=self.choose_download_type) + self.channel_btn.configure(state="normal") + self.download_btn.configure(state="normal") + + def kill(self): + self.load_state = "removed" + if self in LoadManager.active_loads: + LoadManager.active_loads.remove(self) + LoadManager.active_load_count -= 1 + if self in LoadManager.queued_loads: + LoadManager.queued_loads.remove(self) + if self.mode == "playlist": + self.video_load_status_callback(self, self.load_state) + super().kill() + + # create widgets + def create_widgets(self): + scale = GeneralSettings.settings["scale_r"] + self.sub_frame = ctk.CTkFrame( + master=self, + height=self.height - 4, + width=270 * scale, + ) + + self.resolution_select_menu = ctk.CTkComboBox( + master=self.sub_frame, + values=["..........", "..........", ".........."], + dropdown_font=("Segoe UI", 13 * scale, "normal"), + font=("Segoe UI", 13 * scale, "normal"), + width=150 * scale, height=28 * scale + ) + + self.download_btn = ctk.CTkButton( + master=self.sub_frame, text="Download", + width=80 * scale, height=25 * scale, + border_width=2, + state="disabled", + hover=False, + font=("arial", 12 * scale, "bold"), + command=lambda: self.video_download_button_click_callback(self) + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + height=15 * scale, + font=("arial", 12 * scale, "bold"), + ) + + self.reload_btn = ctk.CTkButton( + master=self, + text="⟳", + width=15 * scale, height=15 * scale, + font=("arial", 20 * scale, "normal"), + command=self.reload_video, + hover=False, + ) + super().create_widgets() + + # configure widgets colors + def set_accent_color(self): + self.download_btn.configure(border_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.resolution_select_menu.configure( + button_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + dropdown_hover_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + super().set_accent_color() + + def set_widgets_colors(self): + self.reload_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["normal"] + ) + self.sub_frame.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.resolution_select_menu.configure( + dropdown_fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + ) + super().set_widgets_colors() + + def on_mouse_enter_self(self, event): + super().on_mouse_enter_self(event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + self.reload_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + + def on_mouse_leave_self(self, event): + super().on_mouse_leave_self(event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.reload_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + + def bind_widget_events(self): + super().bind_widget_events() + super().bind_widget_events() + + def on_mouse_enter_download_btn(event): + self.on_mouse_enter_self(event) + if self.download_btn.cget("state") == "normal": + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["hover"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + + def on_mouse_leave_download_btn(event): + self.on_mouse_leave_self(event) + if self.download_btn.cget("state") == "normal": + self.download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["btn_text_color"]["normal"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + self.download_btn.bind("", on_mouse_enter_download_btn) + self.download_btn.bind("", on_mouse_leave_download_btn) + + def on_mouse_enter_reload_btn(event): + self.on_mouse_enter_self(event) + self.reload_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"], + ) + + def on_mouse_leave_reload_btn(event): + self.on_mouse_leave_self(event) + self.reload_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"], + ) + + self.reload_btn.bind("", on_mouse_enter_reload_btn) + self.reload_btn.bind("", on_mouse_leave_reload_btn) + + # place widgets + def place_widgets(self): + super().place_widgets() + + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["AddedVideo"][str(scale)] + self.sub_frame.place(y=2, relx=1, x=-370 * scale) + + self.resolution_select_menu.place(y=y[0], x=20) + self.download_btn.place(x=190 * scale, y=y[1]) + self.status_label.place(x=230 * scale, anchor="n", y=y[2]) + + # static method + active_load_count = 0 + max_concurrent_loads = 1 + queued_loads = [] + active_loads = [] diff --git a/widgets/video/downloaded_video.py b/widgets/video/downloaded_video.py new file mode 100644 index 0000000..e51ba07 --- /dev/null +++ b/widgets/video/downloaded_video.py @@ -0,0 +1,151 @@ +from widgets.video import Video +import customtkinter as ctk +from tkinter import PhotoImage +from typing import List, Literal, Union, Any, Callable +import os +from utils import ( + ValueConvertUtility +) +from settings import ThemeSettings, GeneralSettings, ScaleSettings + + +class DownloadedVideo(Video): + def __init__( + self, + master: Any = None, + height: int = 0, + width: int = 0, + # video info + video_title: str = "", + channel: str = "", + video_url: str = "", + channel_url: str = "", + file_size: int = 0, + length: int = 0, + thumbnails: List[PhotoImage] = (None, None), + # download info + download_path: str = "", + download_quality: Literal["128kbps", "360p", "720p"] = "720p", + download_type: Literal["Audio", "Video"] = "Video", + # mode + mode: Literal["video", "playlist"] = 'video', + # state callbacks + video_status_callback: Callable = None): + + # video info + self.file_size: int = file_size + # download info + self.download_path: str = download_path + self.download_quality: Literal["128kbps", "360p", "720p"] = download_quality + self.download_type: Literal["Audio", "Video"] = download_type + # widgets + self.download_type_label: Union[ctk.CTkLabel, None] = None + self.file_size_label: Union[ctk.CTkLabel, None] = None + self.download_path_btn: Union[ctk.CTkButton, None] = None + # state callbacks + self.video_status_callback = video_status_callback + self.mode = mode + + super().__init__( + master=master, + width=width, + height=height, + thumbnails=thumbnails, + video_title=video_title, + channel=channel, + channel_url=channel_url, + length=length, + video_url=video_url + ) + + self.set_video_data() + + def kill(self): + if self.mode == "playlist": + self.video_status_callback(self, "removed") + super().kill() + + def create_widgets(self): + super().create_widgets() + scale = GeneralSettings.settings["scale_r"] + + self.download_type_label = ctk.CTkLabel( + master=self, + text=f"{self.download_type} : {self.download_quality}", + height=15 * scale, + font=("arial", 12 * scale, "bold"), + ) + + self.file_size_label = ctk.CTkLabel( + master=self, + text=ValueConvertUtility.convert_size(self.file_size, 2), + font=("arial", 12 * scale, "normal"), + height=15, + ) + + self.download_path_btn = ctk.CTkButton( + master=self, + text="📂", + font=("arial", 30 * scale, "bold"), + cursor="hand2", + command=lambda: os.startfile("\\".join(self.download_path.split("\\")[0:-1])), + hover=False, + height=15, + width=30 + ) + + # place widgets + def place_widgets(self): + super().place_widgets() + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadedVideo"][str(scale)] + + scale = GeneralSettings.settings["scale_r"] + + self.download_type_label.place(y=y[0], relx=1, x=-300 * scale) + self.download_path_btn.place(y=y[1], relx=1, x=-150 * scale) + self.file_size_label.place(y=y[2], relx=1, x=-300 * scale) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + ... + + # configure widgets colors + def set_accent_color(self): + super().set_accent_color() + self.download_path_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + + def reset_widgets_colors(self): + super().reset_widgets_colors() + + def set_widgets_colors(self): + super().set_widgets_colors() + self.download_type_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.file_size_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.download_path_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + + def on_mouse_enter_self(self, event): + super().on_mouse_enter_self(event) + self.download_path_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + + def on_mouse_leave_self(self, event): + super().on_mouse_leave_self(event) + self.download_path_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + + def bind_widget_events(self): + super().bind_widget_events() + + def on_mouse_enter_download_path_btn(event): + self.download_path_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["hover"]) + self.on_mouse_enter_self(event) + + def on_mouse_leave_download_path_btn(_event): + self.download_path_btn.configure(text_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.download_path_btn.bind("", on_mouse_enter_download_path_btn) + self.download_path_btn.bind("", on_mouse_leave_download_path_btn) diff --git a/widgets/video/downloading_video.py b/widgets/video/downloading_video.py new file mode 100644 index 0000000..9744d6e --- /dev/null +++ b/widgets/video/downloading_video.py @@ -0,0 +1,493 @@ +from widgets.video import Video +import customtkinter as ctk +import threading +import time +import os +from tkinter import PhotoImage +from typing import Literal, List, Union, Any +from pytube import request as pytube_request +from settings import ( + GeneralSettings, + ThemeSettings, + ScaleSettings +) +from services import ( + DownloadManager +) +from utils import ( + GuiUtils, + ValueConvertUtility, + FileUtility +) + + +class DownloadingVideo(Video): + def __init__( + self, + master: Any, + width: int = 0, + height: int = 0, + # download quality & type + download_quality: Literal["128kbps", "360p", "720p"] = "720p", + download_type: Literal["Video", "Audio"] = "Video", + # video details + video_title: str = "-------", + channel: str = "-------", + video_url: str = "-------", + channel_url: str = "-------", + length: int = 0, + thumbnails: List[PhotoImage] = (None, None), + # video stream data + video_stream_data: property = None, + # video download callback utils @ only use if mode is video + video_download_complete_callback: callable = None, + # state callback utils @ only use if mode is video + mode: Literal["video", "playlist"] = "video", + video_download_status_callback: callable = None, + video_download_progress_callback: callable = None): + + # download status track and callback + self.download_state: Literal["waiting", "downloading", "failed", "completed", "removed"] = "waiting" + self.pause_requested: bool = False + self.pause_resume_btn_command: Literal["pause", "resume"] = "pause" + # callback + self.video_download_complete_callback: callable = video_download_complete_callback + self.video_download_status_callback: callable = video_download_status_callback + self.video_download_progress_callback: callable = video_download_progress_callback + # selected download quality and type + self.download_quality: Literal["128kbps", "360p", "720p"] = download_quality + self.download_type: Literal["Video", "Audio"] = download_type + self.video_stream_data: property = video_stream_data + # playlist or video + self.mode: Literal["video", "playlist"] = mode + # widgets + self.sub_frame: Union[ctk.CTkFrame, None] = None + self.download_progress_bar: Union[ctk.CTkProgressBar, None] = None + self.download_progress_label: Union[ctk.CTkLabel, None] = None + self.download_percentage_label: Union[ctk.CTkLabel, None] = None + self.download_type_label: Union[ctk.CTkLabel, None] = None + self.net_speed_label: Union[ctk.CTkLabel, None] = None + self.status_label: Union[ctk.CTkLabel, None] = None + self.re_download_btn: Union[ctk.CTkButton, None] = None + self.pause_resume_btn: Union[ctk.CTkButton, None] = None + # download file info + self.bytes_downloaded: int = 0 + self.file_size: int = 0 + self.converted_file_size: str = "0 B" + self.download_file_name: str = "" + + super().__init__( + master=master, + height=height, + width=width, + video_url=video_url, + channel_url=channel_url, + thumbnails=thumbnails, + video_title=video_title, + channel=channel, + length=length + ) + + self.set_video_data() + threading.Thread(target=self.start_download_video, daemon=True).start() + + def start_download_video(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadingVideo"][str(scale)] + + if GeneralSettings.settings["max_simultaneous_downloads"] > DownloadManager.active_download_count: + DownloadManager.active_download_count += 1 + DownloadManager.active_downloads.append(self) + threading.Thread(target=self.download_video, daemon=True).start() + self.set_pause_btn() + self.pause_resume_btn.place(y=y[6], relx=1, x=-80 * scale) + self.net_speed_label.configure(text="0.0 B/s") + self.download_progress_bar.set(0) + self.download_percentage_label.configure(text="0.0 %") + self.download_state = "downloading" + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + self.display_status() + + else: + self.set_waiting() + + def re_download_video(self): + self.re_download_btn.place_forget() + self.start_download_video() + + def display_status(self): + if self.download_state == "failed": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text="Failed" + ) + elif self.download_state == "waiting": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Waiting" + ) + elif self.download_state == "paused": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Paused" + ) + elif self.download_state == "downloading": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Downloading" + ) + elif self.download_state == "pausing": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Pausing" + ) + elif self.download_state == "completed": + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"], + text="Downloaded" + ) + + def download_video(self): + if not os.path.exists(GeneralSettings.settings["download_directory"]): + try: + FileUtility.create_directory(GeneralSettings.settings["download_directory"]) + except Exception as error: + print("@2 : ", error) + self.set_download_failed() + return + + stream = None + self.bytes_downloaded = 0 + self.download_file_name = ( + f"{GeneralSettings.settings["download_directory"]}\\" + + f"{FileUtility.sanitize_filename(f"{self.channel} - {self.video_title}")}" + ) + try: + self.download_type_label.configure(text=f"{self.download_type} : {self.download_quality}") + if self.download_type == "Video": + stream = self.video_stream_data.get_by_resolution(self.download_quality) + self.download_file_name += ".mp4" + else: + stream = self.video_stream_data.get_audio_only() + self.download_file_name += ".mp3" + self.file_size = stream.filesize + self.converted_file_size = ValueConvertUtility.convert_size(self.file_size, 2) + self.download_file_name = FileUtility.get_available_file_name(self.download_file_name) + self.set_download_progress() + except Exception as error: + print("@1 : ", error) + self.set_download_failed() + + try: + with open(self.download_file_name, "wb") as self.video_file: + stream = pytube_request.stream(stream.url) + while 1: + try: + time_s = time.time() + if self.pause_requested: + if self.pause_resume_btn_command != "resume": + self.pause_resume_btn.configure(command=self.resume_downloading) + self.download_state = "paused" + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + self.display_status() + self.set_resume_btn() + self.pause_resume_btn_command = "resume" + time.sleep(0.3) + continue + self.download_state = "downloading" + self.pause_resume_btn_command = "pause" + chunk = next(stream, None) + time_e = time.time() + if chunk: + self.video_file.write(chunk) + self.net_speed_label.configure( + text=ValueConvertUtility.convert_size( + ((self.bytes_downloaded + len(chunk)) - self.bytes_downloaded) / (time_e - time_s), + 1 + ) + "/s" + ) + self.bytes_downloaded += len(chunk) + self.set_download_progress() + else: + if self.bytes_downloaded == self.file_size: + self.set_download_completed() + break + else: + self.set_download_failed() + break + except Exception as error: + print("@3 downloading_play_list.py : ", error) + self.set_download_failed() + break + except Exception as error: + print("@4 downloading_play_list.py : ", error) + self.set_download_failed() + + def set_resume_btn(self): + self.pause_resume_btn.configure(text="▷") + + def set_pause_btn(self): + self.pause_resume_btn.configure(text="⏸") + + def pause_downloading(self): + self.pause_resume_btn.configure(command=GuiUtils.do_nothing) + self.download_state = "pausing" + self.display_status() + self.pause_requested = True + + def resume_downloading(self): + self.pause_requested = False + self.set_pause_btn() + while self.download_state == "paused": + time.sleep(0.3) + self.pause_resume_btn.configure(command=self.pause_downloading) + self.download_state = "downloading" + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + self.display_status() + + def set_download_progress(self): + completed_percentage = self.bytes_downloaded / self.file_size + self.download_progress_bar.set(completed_percentage) + self.download_percentage_label.configure(text=f"{round(completed_percentage * 100, 2)} %") + self.download_progress_label.configure( + text=f"{ValueConvertUtility.convert_size(self.bytes_downloaded, 2)} / {self.converted_file_size}" + ) + if self.mode == "playlist": + self.video_download_progress_callback() + + def set_download_failed(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadingVideo"][str(scale)] + + if self.download_state != "removed": + self.download_state = "failed" + self.display_status() + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + if self in DownloadManager.active_downloads: + DownloadManager.active_downloads.remove(self) + DownloadManager.active_download_count -= 1 + self.pause_resume_btn.place_forget() + self.re_download_btn.place(y=y[7], relx=1, x=-80 * scale) + + def set_waiting(self): + DownloadManager.queued_downloads.append(self) + self.download_state = "waiting" + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + self.display_status() + self.pause_resume_btn.place_forget() + self.download_progress_bar.set(0.5) + self.download_percentage_label.configure(text="") + self.net_speed_label.configure(text="") + self.download_progress_label.configure(text="") + self.download_type_label.configure(text="") + + def set_download_completed(self): + if self in DownloadManager.active_downloads: + DownloadManager.active_downloads.remove(self) + DownloadManager.active_download_count -= 1 + self.pause_resume_btn.place_forget() + self.download_state = "completed" + self.display_status() + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + if self.mode == "video": + self.video_download_complete_callback(self) + self.kill() + + def kill(self): + if self in DownloadManager.queued_downloads: + DownloadManager.queued_downloads.remove(self) + if self in DownloadManager.active_downloads: + DownloadManager.active_downloads.remove(self) + DownloadManager.active_download_count -= 1 + self.download_state = "removed" + if self.mode == "playlist": + self.video_download_status_callback(self, self.download_state) + super().kill() + + # create widgets + def create_widgets(self): + super().create_widgets() + scale = GeneralSettings.settings["scale_r"] + + self.sub_frame = ctk.CTkFrame( + self, + height=self.height - 4, + ) + + self.download_progress_bar = ctk.CTkProgressBar( + master=self.sub_frame, + height=8 * scale + ) + + self.download_progress_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "bold"), + ) + + self.download_percentage_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "bold"), + ) + + self.download_type_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "normal"), + ) + + self.net_speed_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "normal"), + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12 * scale, "bold"), + ) + + self.re_download_btn = ctk.CTkButton( + master=self, + text="⟳", + width=15 * scale, + height=15 * scale, + font=("arial", 20 * scale, "normal"), + command=self.re_download_video, + hover=False + ) + + self.pause_resume_btn = ctk.CTkButton( + master=self, + text="⏸", + width=15 * scale, + height=15 * scale, + font=("arial", 20 * scale, "normal"), + command=self.pause_downloading, + hover=False + ) + + # configure widgets colors + def on_mouse_enter_self(self, event): + super().on_mouse_enter_self(event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + self.re_download_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + self.pause_resume_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + + def on_mouse_leave_self(self, event): + super().on_mouse_leave_self(event) + self.sub_frame.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.re_download_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.pause_resume_btn.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + + def set_accent_color(self): + super().set_accent_color() + self.download_progress_bar.configure( + progress_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.re_download_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.pause_resume_btn.configure( + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + def set_widgets_colors(self) -> None: + super().set_widgets_colors() + self.sub_frame.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + ) + self.download_progress_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.download_percentage_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.download_type_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.net_speed_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + self.pause_resume_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + + def bind_widget_events(self): + super().bind_widget_events() + + def on_mouse_enter_re_download_btn(event): + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_download_btn(_event): + self.re_download_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + self.re_download_btn.bind("", on_mouse_enter_re_download_btn) + self.re_download_btn.bind("", on_mouse_leave_download_btn) + + def on_mouse_enter_pause_resume_btn(event): + self.pause_resume_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + text_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_pause_resume_btn(_event): + self.pause_resume_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + text_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + + self.pause_resume_btn.bind("", on_mouse_enter_pause_resume_btn) + self.pause_resume_btn.bind("", on_mouse_leave_pause_resume_btn) + + # place widgets + def place_widgets(self): + super().place_widgets() + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["DownloadingVideo"][str(scale)] + + self.video_title_label.place(relwidth=0.5, width=-150 * scale) + self.channel_btn.place(relwidth=0.5, width=-150 * scale) + self.url_label.place(relwidth=0.5, width=-150 * scale) + + self.sub_frame.place(relx=0.5, y=2) + + self.download_progress_label.place(relx=0.25, anchor="n", y=y[0]) + self.download_progress_label.configure(height=20 * scale) + self.download_type_label.place(relx=0.75, anchor="n", y=y[1]) + self.download_type_label.configure(height=20 * scale) + self.download_progress_bar.place(relwidth=1, y=y[2] * scale) + self.download_percentage_label.place(relx=0.115, anchor="n", y=y[3]) + self.download_percentage_label.configure(height=20 * scale) + self.net_speed_label.place(relx=0.445, anchor="n", y=y[4]) + self.net_speed_label.configure(height=20 * scale) + + self.status_label.place(relx=0.775, anchor="n", y=y[5]) + self.status_label.configure(height=20 * scale) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + scale = GeneralSettings.settings["scale_r"] + self.sub_frame.configure(width=self.master.winfo_width() / 2 - 100 * scale) diff --git a/widgets/video/video.py b/widgets/video/video.py new file mode 100644 index 0000000..3275118 --- /dev/null +++ b/widgets/video/video.py @@ -0,0 +1,321 @@ +import tkinter as tk +import webbrowser +import customtkinter as ctk +from typing import List, Union, Any +from tkinter import PhotoImage +from utils import ( + ValueConvertUtility +) +from widgets.components import ThumbnailButton +from services import ThemeManager +from settings import ( + ThemeSettings, + GeneralSettings, + ScaleSettings +) + + +class Video(ctk.CTkFrame): + def __init__( + self, + master: Any, + width: int = 0, + height: int = 0, + # video info + video_url: str = "", + video_title: str = "-------", + channel: str = "-------", + thumbnails: List[PhotoImage] = (None, None), + channel_url: str = "-------", + length: int = 0): + + super().__init__( + master=master, + height=height, + width=width, + border_width=1, + corner_radius=8, + ) + + self.height: int = height + # video details + self.video_url: str = video_url + self.video_title: str = video_title + self.channel: str = channel + self.channel_url: str = channel_url + self.length: int = length + self.thumbnails: List[PhotoImage] = thumbnails + # widgets + self.url_label: Union[tk.Button, None] = None + self.video_title_label: Union[tk.Button, None] = None + self.channel_btn: Union[tk.Button, None] = None + self.len_label: Union[ctk.CTkButton, None] = None + self.thumbnail_btn: Union[ThumbnailButton, None] = None + self.remove_btn: Union[ctk.CTkButton, None] = None + # initialize the object + self.create_widgets() + self.set_widgets_colors() + self.set_accent_color() + self.reset_widgets_colors() + self.place_widgets() + self.bind_widget_events() + + # self append to theme manger + ThemeManager.register_widget(self) + + # display video data on widgets + def set_video_data(self): + self.video_title_label.configure(text=f"Title : {self.video_title}") + self.channel_btn.configure(text=f"Channel : {self.channel}", state="normal") + self.url_label.configure(text=self.video_url) + self.len_label.configure(text=ValueConvertUtility.convert_time(self.length)) + + self.thumbnail_btn.stop_loading_animation() + self.thumbnail_btn.configure_thumbnail(thumbnails=self.thumbnails) + self.thumbnail_btn.configure(state="normal") + + def on_mouse_enter_thumbnail_btn(event): + self.on_mouse_enter_self(event) + self.thumbnail_btn.on_mouse_enter(event) + + def on_mouse_leave_thumbnail_btn(event): + self.on_mouse_leave_self(event) + self.thumbnail_btn.on_mouse_leave(event) + + self.thumbnail_btn.bind("", on_mouse_enter_thumbnail_btn) + self.thumbnail_btn.bind("", on_mouse_leave_thumbnail_btn) + self.len_label.bind("", on_mouse_enter_thumbnail_btn) + self.len_label.bind("", on_mouse_leave_thumbnail_btn) + + # kill itself + def kill(self): + ThemeManager.unregister_widget(self) + self.pack_forget() + self.destroy() + + # create widgets + def create_widgets(self): + scale = GeneralSettings.settings["scale_r"] + self.thumbnail_btn = ThumbnailButton( + master=self, + font=("arial", int(14 * scale), "bold"), + state="disabled", + command=lambda: webbrowser.open(self.video_url), + ) + + self.len_label = ctk.CTkLabel( + master=self, + width=1, + height=1, + font=("arial", int(10 * scale), "bold"), + text=ValueConvertUtility.convert_time(self.length) + ) + + self.video_title_label = tk.Label( + master=self, + anchor="w", + font=('arial', int(10 * scale), 'normal'), + text=f"Title : {self.video_title}" + ) + + self.channel_btn = tk.Button( + master=self, font=('arial', int(10 * scale), 'bold'), + anchor="w", + bd=0, + command=lambda: webbrowser.open(self.channel_url), + relief="sunken", + state="disabled", + cursor="hand2", + text=f"Channel : {self.channel}" + ) + + self.url_label = tk.Label( + master=self, anchor="w", + font=('arial', int(10 * scale), "italic underline"), + text=self.video_url + ) + + self.remove_btn = ctk.CTkButton( + master=self, + command=self.kill, + text="X", + font=("arial", int(12 * scale), "bold"), + width=20 * scale, + height=20 * scale, + border_spacing=0, + hover=False, + ) + + # set widgets colors + def set_accent_color(self): + self.configure(border_color=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.thumbnail_btn.configure( + fg=(ThemeSettings.settings["root"]["accent_color"]["normal"]), + ) + self.channel_btn.configure(activeforeground=ThemeSettings.settings["root"]["accent_color"]["normal"]) + self.url_label.configure(fg=ThemeSettings.settings["root"]["accent_color"]["normal"]) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + self.thumbnail_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + disabledforeground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ), + activebackground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ) + ) + + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["text_color"]["normal"] + ) + ) + + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + ) + + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["btn_text_color"]["normal"] + ), + activebackground=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["fg_color"]["normal"] + ), + ) + + def set_widgets_colors(self): + self.configure(fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["normal"] + ) + + def on_mouse_enter_self(self, event): + self.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["hover"], + border_color=ThemeSettings.settings["root"]["accent_color"]["hover"] + ) + self.thumbnail_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["hover"]) + ) + + def on_mouse_leave_self(self, event): + self.configure( + fg_color=ThemeSettings.settings["video_object"]["fg_color"]["normal"], + border_color=ThemeSettings.settings["root"]["accent_color"]["normal"] + ) + self.thumbnail_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme_mode(ThemeSettings.settings["video_object"]["fg_color"]["normal"]) + ) + + def bind_widget_events(self): + self.bind("", self.configure_widget_sizes) + self.bind("", self.on_mouse_enter_self) + self.bind("", self.on_mouse_leave_self) + for child_widgets in self.winfo_children(): + child_widgets.bind("", self.on_mouse_enter_self) + child_widgets.bind("", self.on_mouse_leave_self) + try: + for sub_child_widgets in child_widgets.winfo_children(): + sub_child_widgets.bind("", self.on_mouse_enter_self) + sub_child_widgets.bind("", self.on_mouse_leave_self) + except Exception as error: + print(f"@1 Video.py > err : {error}") + + def on_mouse_enter_channel_btn(event): + self.channel_btn.configure( + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["btn_text_color"]["hover"] + ), + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_channel_btn(_event): + self.channel_btn.configure( + fg=ThemeManager.get_color_based_on_theme_mode( + ThemeSettings.settings["video_object"]["btn_text_color"]["normal"] + ), + ) + + self.channel_btn.bind("", on_mouse_enter_channel_btn) + self.channel_btn.bind("", on_mouse_leave_channel_btn) + + def on_mouse_enter_remove_btn(event): + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["hover"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["hover"] + ) + self.on_mouse_enter_self(event) + + def on_mouse_leave_remove_btn(event): + self.remove_btn.configure( + fg_color=ThemeSettings.settings["video_object"]["error_color"]["normal"], + text_color=ThemeSettings.settings["video_object"]["remove_btn_text_color"]["normal"] + ) + self.on_mouse_leave_self(event) + + self.remove_btn.bind("", on_mouse_enter_remove_btn) + self.remove_btn.bind("", on_mouse_leave_remove_btn) + + # place widgets + def place_widgets(self): + scale = GeneralSettings.settings["scale_r"] + y = ScaleSettings.settings["Video"][str(scale)] + thumbnail_width = int((self.height - 4) / 9 * 16) + self.remove_btn.place(relx=1, x=-23 * scale, y=3 * scale) + self.thumbnail_btn.place(x=5, y=1, relheight=1, height=-4, width=int((self.height - 4) / 9 * 16)) + self.len_label.place( + rely=1, y=-10 * scale, + x=thumbnail_width - 1, + anchor="e" + ) + self.video_title_label.place( + x=thumbnail_width + 10 * scale, + y=y[0], + height=20 * scale, + relwidth=1, + width=-500 * scale + ) + self.channel_btn.place( + x=thumbnail_width + 10 * scale, + y=y[1], + height=20 * scale, + relwidth=1, + width=-500 * scale + ) + self.url_label.place( + x=thumbnail_width + 10 * scale, + y=y[2], + height=20 * scale, + relwidth=1, + width=-500 * scale + ) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, event): + ...