diff --git a/functions/Accessible.py b/functions/Accessible.py new file mode 100644 index 0000000..40e307d --- /dev/null +++ b/functions/Accessible.py @@ -0,0 +1,16 @@ +import os +from .getAvailableFileName import get_available_file_name +from .createDownloadDirectory import create_download_directory + + +def accessible(path: str) -> bool: + file = path + "\\" + get_available_file_name("pytube.pytube") + try: + create_download_directory(path) + with open(file, "w"): + pass + os.remove(file) + return True + except Exception as error: + print(f"@ Accessible.py : {error}") + return False diff --git a/functions/__init__.py b/functions/__init__.py new file mode 100644 index 0000000..36fca5c --- /dev/null +++ b/functions/__init__.py @@ -0,0 +1,28 @@ +from .saveSettings import save_settings +from .getGeneralSettings import get_general_settings +from .getThemeSettings import get_theme_settings + +from .Accessible import accessible + +from .createDownloadDirectory import create_download_directory +from .getAvailableFileName import get_available_file_name +from .getValidFileName import get_valid_file_name +from .getConvertedPath import get_converted_path + +from .getFormatedComboBoxValues import get_formated_combo_box_values +from .getSupportedDownloadTypes import get_supported_download_types +from .getThumbnails import get_thumbnails + +from .getWindowScale import get_window_scale + +from .sortDict import sort_dict +from .getConvertedSize import get_converted_size +from .getConvertedTime import get_converted_time + +from .passCommand import pass_command +from .clearTempFiles import clear_temp_files + +from .validateColor import validate_color +from .validateSimultaneousCount import validate_simultaneous_count +from .validateDownloadPath import validate_download_path +from .formatPath import format_path diff --git a/functions/clearTempFiles.py b/functions/clearTempFiles.py new file mode 100644 index 0000000..28a591f --- /dev/null +++ b/functions/clearTempFiles.py @@ -0,0 +1,11 @@ +import os + + +def clear_temp_files(temp_directory: str) -> None: + for file in os.listdir(temp_directory): + try: + if file != 'this directory is necessary': + os.remove(f"{temp_directory}\\{file}") + except Exception as error: + print("@1 clearTemp.py :", error) + pass diff --git a/functions/convertTime.py b/functions/convertTime.py new file mode 100644 index 0000000..0606307 --- /dev/null +++ b/functions/convertTime.py @@ -0,0 +1,9 @@ +def convertTime(s: int) -> str: + h = int(s/3600) + m = int((s - (h*3600)) / 60) + s = s - (h*3600) - (m*60) + if h>0: + converted_time = f"{h}:{m:0>2}:{s:0>2}" + else: + converted_time = f"{m}:{s:0>2}" + return converted_time \ No newline at end of file diff --git a/functions/createDownloadDirectory.py b/functions/createDownloadDirectory.py new file mode 100644 index 0000000..20a7849 --- /dev/null +++ b/functions/createDownloadDirectory.py @@ -0,0 +1,15 @@ +import os + + +def create_download_directory(path: str): + # print(path) + try: + if not os.path.exists(path): + sub_paths = path.split("\\") + for i in range(0, len(sub_paths)): + sub_path = "\\".join(sub_paths[0:i + 1]) + if not os.path.exists(sub_path): + os.mkdir(sub_path) + except Exception as error: + print("@1 createDownloadDirectory.py : ", error) + raise BufferError("download path error :/") diff --git a/functions/formatPath.py b/functions/formatPath.py new file mode 100644 index 0000000..50dbf5b --- /dev/null +++ b/functions/formatPath.py @@ -0,0 +1,13 @@ +def format_path(path: str): + path = path.strip().replace("/", "\\") + print(path) + while "\\\\" in path: + path = path.replace("\\\\", "\\") + paths = [] + for path in path.split("\\"): + if path != " ": + paths.append(path) + path = "\\".join(paths) + if path.endswith("\\"): + path = path[0:-1] + return path diff --git a/functions/getAvailableFileName.py b/functions/getAvailableFileName.py new file mode 100644 index 0000000..5e593c0 --- /dev/null +++ b/functions/getAvailableFileName.py @@ -0,0 +1,14 @@ +import os + + +def get_available_file_name(file_name_extension: str) -> str: + if os.path.exists(file_name_extension): + split_path = file_name_extension.split(".") + file_name, extension = ".".join(split_path[0:-1]), split_path[-1] + i = 0 + while os.path.exists(f"{file_name} ({i}).{extension}"): + i += 1 + + return f"{file_name} ({i}).{extension}" + else: + return file_name_extension diff --git a/functions/getColor.py b/functions/getColor.py new file mode 100644 index 0000000..821f6eb --- /dev/null +++ b/functions/getColor.py @@ -0,0 +1,4 @@ +def getColor(color, theme): + if theme == "Dark": + return color[1] + return color[0] \ No newline at end of file diff --git a/functions/getConvertedPath.py b/functions/getConvertedPath.py new file mode 100644 index 0000000..4549f80 --- /dev/null +++ b/functions/getConvertedPath.py @@ -0,0 +1,2 @@ +def get_converted_path(path): + return path.replace("/", "\\") diff --git a/functions/getConvertedSize.py b/functions/getConvertedSize.py new file mode 100644 index 0000000..ca2d5bc --- /dev/null +++ b/functions/getConvertedSize.py @@ -0,0 +1,16 @@ +from typing import Union + + +def get_converted_size(s: Union[int, float], decimal_points: int) -> str: + data_units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] + index = 0 + + while len(str(int(s))) > 3 and (index+1) < len(data_units): + s = s / 1024 + index += 1 + if decimal_points > 0: + converted_size = f"{round(s, decimal_points)} {data_units[index]}" + else: + converted_size = f"{int(s)} {data_units[index]}" + + return converted_size diff --git a/functions/getConvertedTime.py b/functions/getConvertedTime.py new file mode 100644 index 0000000..9eb169a --- /dev/null +++ b/functions/getConvertedTime.py @@ -0,0 +1,11 @@ +def get_converted_time(s: int) -> str: + h = int(s / 3600) + m = int((s - (h * 3600)) / 60) + s = s - (h * 3600) - (m * 60) + + if h > 0: + converted_time = f"{h}:{m:0>2}:{s:0>2}" + else: + converted_time = f"{m}:{s:0>2}" + + return converted_time diff --git a/functions/getFormatedComboBoxValues.py b/functions/getFormatedComboBoxValues.py new file mode 100644 index 0000000..ce66613 --- /dev/null +++ b/functions/getFormatedComboBoxValues.py @@ -0,0 +1,11 @@ +from .getConvertedSize import get_converted_size + + +def get_formated_combo_box_values(supported_download_types): + combo_box_values = [] + + for data_dict in supported_download_types: + for data_key in data_dict: + combo_box_values.append(data_key + " | " + get_converted_size(data_dict[data_key], 1)) + + return combo_box_values diff --git a/functions/getGeneralSettings.py b/functions/getGeneralSettings.py new file mode 100644 index 0000000..2a6c8ea --- /dev/null +++ b/functions/getGeneralSettings.py @@ -0,0 +1,12 @@ +import json +import os + + +def get_general_settings() -> dict: + general_settings_file_path = ".\\settings\\general.json" + general_settings = json.load(open(general_settings_file_path, "r")) + + if not general_settings["download_directory"]: + general_settings["download_directory"] = f"C:\\Users\\{os.getlogin()}\\Downloads\\PyTube Downloader" + print(general_settings) + return general_settings diff --git a/functions/getSupportedDownloadTypes.py b/functions/getSupportedDownloadTypes.py new file mode 100644 index 0000000..240d86c --- /dev/null +++ b/functions/getSupportedDownloadTypes.py @@ -0,0 +1,34 @@ +import pytube + + +def to_dict(data) -> list[dict]: + data_list = [] + for d in data: + data_list.append( + {value.split("=")[0]: value.split("=")[1] for value in str(d)[9:-1].replace('"', "").split(" ")}) + return data_list + + +def get_supported_download_types(video_streams: pytube.StreamQuery) -> list[dict]: + data = to_dict(video_streams.all()) + + support_download_types = [] + for stream_type in data: + if stream_type["type"] == "video": + try: + file_size = video_streams.get_by_resolution(stream_type["res"]).filesize + download_info = {stream_type["res"]: file_size} + if download_info not in support_download_types: + support_download_types.append(download_info) + except Exception: + pass + + try: + audio_stream = video_streams.get_audio_only() + file_size = audio_stream.filesize + audio_bit_rate = f"{str(int(audio_stream.bitrate / 1024))}kbps" + support_download_types.append({audio_bit_rate: file_size}) + except Exception: + pass + + return support_download_types diff --git a/functions/getThemeSettings.py b/functions/getThemeSettings.py new file mode 100644 index 0000000..e7c03ea --- /dev/null +++ b/functions/getThemeSettings.py @@ -0,0 +1,26 @@ +import json + + +def list_to_tuple(data: dict): + for key in data.keys(): + if type(data[key]) is dict: + for key2 in data[key].keys(): + if type(data[key][key2]) is dict: + for key3 in data[key][key2].keys(): + if type(data[key][key2][key3]) is list: + data[key][key2][key3] = tuple(data[key][key2][key3]) + elif type(data[key][key2]) is list: + data[key][key2] = tuple(data[key][key2]) + else: + if type(data[key]) is list: + data[key] = tuple(data[key]) + + print(data) + return data + + +def get_theme_settings(): + file_name = ".\\settings\\theme.json" + settings = json.load(open(file_name, "r")) + + return list_to_tuple(settings) diff --git a/functions/getThumbnails.py b/functions/getThumbnails.py new file mode 100644 index 0000000..22d5612 --- /dev/null +++ b/functions/getThumbnails.py @@ -0,0 +1,63 @@ +from .getAvailableFileName import get_available_file_name +from .getValidFileName import get_valid_file_name +from urllib import request +import tkinter +from PIL import Image, ImageDraw + + +def add_corners(im, rad): + circle = Image.new('L', (rad * 2, rad * 2), 0) + draw = ImageDraw.Draw(circle) + draw.ellipse((0, 0, rad * 2 - 1, rad * 2 - 1), fill=255) + alpha = Image.new('L', im.size, 255) + w, h = im.size + alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) + alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) + alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) + alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) + im.putalpha(alpha) + return im + + +def get_hover_thumbnail(thumbnail_download_path): + thumbnail_hover_temp = Image.open(thumbnail_download_path) + thumbnail_hover_path = ".".join(thumbnail_download_path.split(".")[0:-1]) + "-hover." + \ + thumbnail_download_path.split(".")[-1] + thumbnail_hover_temp = thumbnail_hover_temp.convert("RGB") + thumbnail_hover_data = thumbnail_hover_temp.getdata() + thumbnail_hover_data_list = [] + for item in thumbnail_hover_data: + item = list(item) + for index, i in enumerate(item): + if i + 30 < 256: + item[index] = i + 30 + else: + item[index] = 255 + thumbnail_hover_data_list.append(tuple(item)) + + thumbnail_hover_temp.putdata(thumbnail_hover_data_list) + thumbnail_hover_corner_rounded = add_corners(thumbnail_hover_temp, 6) + thumbnail_hover_corner_rounded.save(thumbnail_hover_path) + thumbnail_hover = tkinter.PhotoImage(file=thumbnail_hover_path) + + return thumbnail_hover + + +def get_thumbnails(video): + thumbnail_url = video.thumbnail_url + thumbnail_download_path = get_available_file_name("./temp/" + get_valid_file_name(thumbnail_url) + ".png") + request.urlretrieve(thumbnail_url, thumbnail_download_path) + + thumbnail_temp = Image.open(thumbnail_download_path) + + if round(thumbnail_temp.width / 4 * 3) <= 480: + thumbnail_temp = thumbnail_temp.resize((113, 64), Image.Resampling.LANCZOS).crop((0, 8, 113, 56)).resize( + (113, 64)) + else: + thumbnail_temp = thumbnail_temp.resize((113, 64), Image.Resampling.LANCZOS) + + thumbnail_temp_corner_rounded = add_corners(thumbnail_temp, 6) + thumbnail_temp_corner_rounded.save(thumbnail_download_path) + thumbnail = tkinter.PhotoImage(file=thumbnail_download_path) + + return thumbnail, get_hover_thumbnail(thumbnail_download_path) diff --git a/functions/getValidFileName.py b/functions/getValidFileName.py new file mode 100644 index 0000000..a74f219 --- /dev/null +++ b/functions/getValidFileName.py @@ -0,0 +1,8 @@ +def get_valid_file_name(url: str) -> str: + valid_file_name = url + + replaces = ["\\", "/", ":", '"', "?", "<", ">", "|", "*"] + for re in replaces: + valid_file_name = valid_file_name.replace(re, "~") + + return valid_file_name diff --git a/functions/getWindowScale.py b/functions/getWindowScale.py new file mode 100644 index 0000000..22f6a0f --- /dev/null +++ b/functions/getWindowScale.py @@ -0,0 +1,5 @@ +import customtkinter as ctk + + +def get_window_scale(widget): + return 1 / ctk.ScalingTracker.get_widget_scaling(widget) diff --git a/functions/passCommand.py b/functions/passCommand.py new file mode 100644 index 0000000..5ab771b --- /dev/null +++ b/functions/passCommand.py @@ -0,0 +1,2 @@ +def pass_command(*args_): + pass diff --git a/functions/removeInvalidCharts.py b/functions/removeInvalidCharts.py new file mode 100644 index 0000000..cdf217e --- /dev/null +++ b/functions/removeInvalidCharts.py @@ -0,0 +1,6 @@ +def removeInvalidChars(url: str) -> str: + filename = url + replaces = ["\\", "/", ":", '"', "?", "<", ">", "|", "*"] + for re in replaces: + filename = filename.replace(re, "~") + return filename diff --git a/functions/saveSettings.py b/functions/saveSettings.py new file mode 100644 index 0000000..aa18fd3 --- /dev/null +++ b/functions/saveSettings.py @@ -0,0 +1,7 @@ +import json + + +def save_settings(settings_file, settings): + file = open(settings_file, "w") + json.dump(obj=settings, fp=file, indent=8, sort_keys=True) + file.close() diff --git a/functions/sortDict.py b/functions/sortDict.py new file mode 100644 index 0000000..a714b04 --- /dev/null +++ b/functions/sortDict.py @@ -0,0 +1,31 @@ +def sort_dict(info: list[dict]) -> list[dict]: + video_keys = [] + audio_keys = [] + for data in info: + key = list(data.keys())[0] + if "kbps" in key: + audio_keys.append(key) + else: + video_keys.append(key) + + for i in range(len(video_keys)): + for i2 in range(len(video_keys)-1): + if int(video_keys[i2][:-1]) < int(video_keys[i2 + 1][:-1]): + video_keys[i2], video_keys[i2+1] = video_keys[i2+1], video_keys[i2] + + keys = video_keys+audio_keys + sorted_dict = [] + index2 = 0 + break_ = False + while True: + for dict_ in info: + if list(dict_.keys())[0] == keys[index2]: + sorted_dict.append(dict_) + index2 += 1 + if index2 >= len(keys): + break_ = True + break + if break_: + break + + return sorted_dict diff --git a/functions/validateColor.py b/functions/validateColor.py new file mode 100644 index 0000000..d2cbac4 --- /dev/null +++ b/functions/validateColor.py @@ -0,0 +1,15 @@ +import tkinter as tk + + +def validate_color(color: str): + try: + normal, hover = color.split(",") + try: + tk.Button(fg=normal, bg=hover) + return True + except Exception as error: + print(f"@1 > validateColor.py : {error}") + return False + except Exception as error: + print(f"@2 > validateColor.py : {error}") + return False diff --git a/functions/validateDownloadPath.py b/functions/validateDownloadPath.py new file mode 100644 index 0000000..ce03a0e --- /dev/null +++ b/functions/validateDownloadPath.py @@ -0,0 +1,18 @@ +import os + + +def validate_download_path(path: str) -> bool: + paths = path.split(":") + if not os.path.exists(paths[0]+':'): + return False + if len(paths) != 2: + return False + elif paths[1] != "": + if not paths[1].startswith("\\"): + return False + path = paths[1] + invalid_characters = ['?', '%', '*', ':', '|', '"', '<', '>'] + for invalid_char in invalid_characters: + if invalid_char in path: + return False + return True diff --git a/functions/validateSimultaneousCount.py b/functions/validateSimultaneousCount.py new file mode 100644 index 0000000..4403aec --- /dev/null +++ b/functions/validateSimultaneousCount.py @@ -0,0 +1,9 @@ +def validate_simultaneous_count(count: str) -> bool: + try: + count = int(count) + if 11 > count > 0: + return True + else: + return False + except ValueError: + return False diff --git a/services/DownloadManager.py b/services/DownloadManager.py new file mode 100644 index 0000000..fc43c56 --- /dev/null +++ b/services/DownloadManager.py @@ -0,0 +1,30 @@ +import threading +import time + + +class DownloadManager: + active_download_count = 0 + max_concurrent_downloads = 1 + queued_downloads = [] + active_downloads = [] + + @staticmethod + def manage_download_queue(): + def check_and_enqueue_downloads(): + while True: + # print(f"@DownloadManager.py > Active Downloads : {DownloadManager.active_download_count}") + if DownloadManager.max_concurrent_downloads > DownloadManager.active_download_count and len( + DownloadManager.queued_downloads) > 0: + try: + DownloadManager.queued_downloads[0].start_download_video() + except Exception as error: + print("@1 DownloadManager.py :", error) + pass + DownloadManager.queued_downloads.pop(0) + time.sleep(1) + + threading.Thread(target=check_and_enqueue_downloads, daemon=True).start() + + @staticmethod + def set_max_concurrent_downloads(count: int): + DownloadManager.max_concurrent_downloads = count diff --git a/services/LoadManager.py b/services/LoadManager.py new file mode 100644 index 0000000..6832da4 --- /dev/null +++ b/services/LoadManager.py @@ -0,0 +1,31 @@ +import threading +import time + + +class LoadManager: + active_load_count = 0 + max_concurrent_loads = 1 + queued_loads = [] + active_loads = [] + + @staticmethod + def manage_load_queue(): + def check_and_enqueue_loads(): + while True: + # print(f"@loadManager.py > Active loads : {LoadManager.active_load_count}") + # print(f"@loadManager.py > Queued loads : {len(LoadManager.queued_loads)}") + if (LoadManager.max_concurrent_loads > LoadManager.active_load_count and + len(LoadManager.queued_loads) > 0): + try: + LoadManager.queued_loads[0].load_video() + except Exception as error: + print("@1 LoadManager.py :", error) + pass + LoadManager.queued_loads.pop(0) + time.sleep(1) + + threading.Thread(target=check_and_enqueue_loads, daemon=True).start() + + @staticmethod + def set_max_concurrent_loads(count: int): + LoadManager.max_concurrent_loads = count diff --git a/services/LoadingIndicatorController.py b/services/LoadingIndicatorController.py new file mode 100644 index 0000000..b3eecc8 --- /dev/null +++ b/services/LoadingIndicatorController.py @@ -0,0 +1,25 @@ +import time +import threading + + +class LoadingIndicatorController: + dots_count: int = 1 + update_delay: float = 0.7 + max_dots_count: int = 4 + + @staticmethod + def start_loading_indicator(): + def update_loading_indicator(): + while True: + for LoadingIndicatorController.dots_count in range(1, LoadingIndicatorController.max_dots_count+1): + time.sleep(LoadingIndicatorController.update_delay) + + threading.Thread(target=update_loading_indicator, daemon=True).start() + + @staticmethod + def set_indicator_update_delay(update_delay: float): + LoadingIndicatorController.update_delay = update_delay + + @staticmethod + def start(): + LoadingIndicatorController.start_loading_indicator() diff --git a/services/ThemeManager.py b/services/ThemeManager.py new file mode 100644 index 0000000..bac1cf2 --- /dev/null +++ b/services/ThemeManager.py @@ -0,0 +1,52 @@ +import customtkinter as ctk +import time +from typing import List, Dict, Tuple, Any, Literal + + +class ThemeManager: + child_objects: List[Any] = [] + theme_settings: Dict = None + theme_mode: Literal["Dark", "Light", None] = None + + @staticmethod + def get_color_based_on_theme(color: Tuple[str, str]) -> str: + if ThemeManager.theme_mode == "Dark": + return color[1] + return color[0] + + @staticmethod + def theme_tracker() -> None: + while True: + if ctk.get_appearance_mode() != ThemeManager.theme_mode: + ThemeManager.theme_mode = ctk.get_appearance_mode() + for child_object in ThemeManager.child_objects: + try: + child_object.reset_widgets_colors() + except Exception as error: + print("@1 ThemeManager.py :", error) + time.sleep(1) + + @staticmethod + def update_accent_color(new_accent_color: Dict) -> None: + ThemeManager.theme_settings["root"]["accent_color"] = new_accent_color + ThemeManager.update_child_widget_accent_color() + + @staticmethod + def update_child_widget_accent_color(): + for child_object in ThemeManager.child_objects: + try: + child_object.update_accent_color() + except Exception as error: + print("@2 ThemeManager.py :", error) + + @staticmethod + def bind_widget(widget: Any) -> None: + ThemeManager.child_objects.append(widget) + + @staticmethod + def unbind_widget(widget: Any) -> None: + ThemeManager.child_objects.remove(widget) + + @staticmethod + def configure_theme_settings(theme_settings: Dict): + ThemeManager.theme_settings = theme_settings diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..0c0ff85 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,4 @@ +from .LoadingIndicatorController import LoadingIndicatorController +from .LoadManager import LoadManager +from .DownloadManager import DownloadManager +from .ThemeManager import ThemeManager diff --git a/src/icon.ico b/src/icon.ico new file mode 100644 index 0000000..009185a Binary files /dev/null and b/src/icon.ico differ diff --git a/src/info.png b/src/info.png new file mode 100644 index 0000000..4b38288 Binary files /dev/null and b/src/info.png differ diff --git a/temp/this directory is necessary b/temp/this directory is necessary new file mode 100644 index 0000000..51501d7 --- /dev/null +++ b/temp/this directory is necessary @@ -0,0 +1 @@ +temp directory :D \ No newline at end of file diff --git a/widgets/Components/AccentColorButton.py b/widgets/Components/AccentColorButton.py new file mode 100644 index 0000000..ced0f7a --- /dev/null +++ b/widgets/Components/AccentColorButton.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/AppearancePanel.py b/widgets/Components/AppearancePanel.py new file mode 100644 index 0000000..d2f89f8 --- /dev/null +++ b/widgets/Components/AppearancePanel.py @@ -0,0 +1,339 @@ +from typing import Any, List, Callable, Literal, Union +import customtkinter as ctk +from functions import validate_color +from .AccentColorButton import AccentColorButton +from services import ThemeManager + + +class AppearancePanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + theme_settings_change_callback: Callable = None, + width: int = 0): + + super().__init__( + width=width, + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + self.theme_label = ctk.CTkLabel( + master=self, + text="Theme", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.theme_combo_box = ctk.CTkComboBox( + master=self, + values=["Dark", "Light"], + dropdown_fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + command=self.apply_theme_mode, + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.accent_color_label = ctk.CTkLabel( + master=self, + text="Accent color", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.dash2_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.accent_color_frame = ctk.CTkFrame( + master=self, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + # add accent color buttons + self.accent_color_buttons: List[AccentColorButton] = [] + for accent_color in ThemeManager.theme_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, + height=30, + width=30, + 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=ThemeManager.theme_settings["settings_panel"]["text_color"], + ) + self.dash3_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.custom_accent_color_entry = ctk.CTkEntry( + master=self, + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + self.custom_accent_color_display_btn = ctk.CTkButton( + master=self, + text="", + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + height=30, + width=30, + corner_radius=8 + ) + + self.custom_accent_color_apply_btn = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + height=24, + width=50, + font=("arial", 12, "bold"), + command=self.apply_custom_accent_color + ) + + self.custom_accent_color_warning_text = ctk.CTkTextbox( + master=self, + text_color=ThemeManager.theme_settings["settings_panel"]["warning_color"]["normal"], + width=560, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + ) + + self.opacity_label = ctk.CTkLabel( + master=self, + text="Transparent", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + ) + + self.dash4_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"], + ) + + self.opacity_change_slider = ctk.CTkSlider( + master=self, + command=self.apply_opacity, + from_=0.6, + to=1, + ) + + self.custom_accent_color = "" + ThemeManager.theme_settings = ThemeManager.theme_settings + self.theme_settings_change_callback = theme_settings_change_callback + self.place_widgets() + self.configure_values() + self.bind_events() + self.set_accent_color() + ThemeManager.bind_widget(self) + + def release_all_accent_color_buttons(self): + for accent_button in self.accent_color_buttons: + if accent_button.pressed: + accent_button.set_unpressed() + + def apply_accent_color(self, button: AccentColorButton): + ThemeManager.theme_settings["root"]["accent_color"] = { + "normal": button.fg_color, + "hover": button.hover_color, + "default": True + } + self.theme_settings_change_callback(ThemeManager.theme_settings, "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): + colors = self.custom_accent_color_entry.get().strip().replace(" ", "").split(",") + ThemeManager.theme_settings["root"]["accent_color"] = { + "normal": colors[0], + "hover": colors[1], + "default": False + } + self.release_all_accent_color_buttons() + self.theme_settings_change_callback(ThemeManager.theme_settings, "accent_color") + self.custom_accent_color_apply_btn.configure(state="disabled") + + def apply_theme_mode(self, theme_mode: Union[Literal["Dark", "Light"], Any]): + ThemeManager.theme_settings["root"]["theme_mode"] = theme_mode.lower() + self.theme_settings_change_callback(ThemeManager.theme_settings, "theme_mode") + + def sync_theme_with_os(self): + self.system_theme_check_box.configure(command=self.disable_sync_theme_with_os) + self.theme_combo_box.configure(state="disabled") + ThemeManager.theme_settings["root"]["theme_mode"] = "system" + self.theme_settings_change_callback(ThemeManager.theme_settings, "theme_mode") + + def disable_sync_theme_with_os(self): + self.system_theme_check_box.configure(command=self.sync_theme_with_os) + self.theme_combo_box.configure(state="normal") + ThemeManager.theme_settings["root"]["theme_mode"] = ctk.get_appearance_mode().lower() + self.theme_settings_change_callback(ThemeManager.theme_settings, "theme_mode") + + def apply_opacity(self, opacity_value: float): + ThemeManager.theme_settings["opacity"] = opacity_value + self.theme_settings_change_callback(ThemeManager.theme_settings, "opacity") + + def validate_custom_accent_color(self, _event): + text = self.custom_accent_color_entry.get() + if self.custom_accent_color != text: + self.custom_accent_color = text + colors = text.strip().replace(" ", "") + if validate_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=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + ) + self.custom_accent_color_apply_btn.configure(state="disabled") + + def set_accent_color(self): + self.theme_combo_box.configure( + button_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + button_hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + dropdown_hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.custom_accent_color_entry.configure( + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.system_theme_check_box.configure( + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.custom_accent_color_apply_btn.configure( + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.opacity_change_slider.configure( + button_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + progress_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + button_hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + ) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + self.custom_accent_color_warning_text.tag_config( + "normal", + foreground=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["settings_panel"]["text_color"]) + ) + + def place_widgets(self): + self.theme_label.place(y=50, x=100) + self.dash1_label.place(y=50, x=240) + self.theme_combo_box.place(y=50, x=270) + self.system_theme_check_box.place(y=50, x=440) + + self.accent_color_label.place(y=90, x=100) + self.dash2_label.place(y=90, x=240) + self.accent_color_frame.place(y=90, x=270) + + # 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.place(y=270, x=100) + self.dash3_label.place(y=270, x=240) + self.custom_accent_color_entry.place(y=270, x=270) + self.custom_accent_color_display_btn.place(y=270, x=420) + self.custom_accent_color_apply_btn.place(y=272, x=470) + self.custom_accent_color_warning_text.place(y=300, x=50) + + self.opacity_label.place(y=430, x=100) + self.dash4_label.place(y=430, x=240) + self.opacity_change_slider.place(y=435, x=270) + + # set default values to widgets + def configure_values(self): + self.custom_accent_color_warning_text.bind("", lambda e: "break") + self.custom_accent_color_warning_text.insert( + "end", + """*Please enter custom accent colors for normal and hover states in one of the following formats: + - Hexadecimal format: #RRGGBB or #RGB (e.g., #0f0f0f, #0f0f0ff or #fff, #f0f ) + - Color names: Supported color names such as red, green, blue + - Separate the two colors with a comma (,) + :- Example: + : #0f0f0f, #0f0f0ff + : green, lightgreen""" + ) + self.custom_accent_color_warning_text.tag_add("red", "2.31", "2.33") + self.custom_accent_color_warning_text.tag_add("green", "2.33", "2.35") + self.custom_accent_color_warning_text.tag_add("blue", "2.35", "2.37") + self.custom_accent_color_warning_text.tag_add("red", "2.42", "2.43") + self.custom_accent_color_warning_text.tag_add("green", "2.43", "2.44") + self.custom_accent_color_warning_text.tag_add("blue", "2.44", "2.45") + self.custom_accent_color_warning_text.tag_add("normal", "5.0", "7.43") + + self.custom_accent_color_warning_text.tag_config("red", foreground="#ff0000") + self.custom_accent_color_warning_text.tag_config("green", foreground="#00ff00") + self.custom_accent_color_warning_text.tag_config("blue", foreground="#0000ff") + + if ThemeManager.theme_settings["root"]["accent_color"]["default"]: + for button in self.accent_color_buttons: + if button.fg_color == ThemeManager.theme_settings["root"]["accent_color"]["normal"] and \ + button.hover_color == ThemeManager.theme_settings["root"]["accent_color"]["hover"]: + button.on_mouse_enter_self("event") + button.set_pressed() + else: + # add default value to entry using settings + self.custom_accent_color_entry.insert( + "end", + ThemeManager.theme_settings["root"]["accent_color"]["normal"] + + ", " + ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + + if ThemeManager.theme_settings["root"]["theme_mode"] == "system": + self.sync_theme_with_os() + self.system_theme_check_box.select() + elif ThemeManager.theme_settings["root"]["theme_mode"] == "dark": + self.theme_combo_box.set("Dark") + if ThemeManager.theme_settings["root"]["theme_mode"] == "light": + self.theme_combo_box.set("Light") + + self.opacity_change_slider.set(ThemeManager.theme_settings["opacity"]) + + def bind_events(self): + self.custom_accent_color_entry.bind("", self.validate_custom_accent_color) + self.validate_custom_accent_color("event") diff --git a/widgets/Components/DownloadsPanel.py b/widgets/Components/DownloadsPanel.py new file mode 100644 index 0000000..f3a222c --- /dev/null +++ b/widgets/Components/DownloadsPanel.py @@ -0,0 +1,116 @@ +import customtkinter as ctk +from typing import Dict, Callable, Any +from tkinter import filedialog +from services import ThemeManager +from functions import validate_download_path, format_path + + +class DownloadsPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + general_settings: Dict = None, + general_settings_change_callback: Callable = None, + width: int = 0): + + super().__init__( + width=width, + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + self.download_path_label = ctk.CTkLabel( + master=self, + text="Download Path", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.download_path_entry = ctk.CTkEntry( + master=self, + justify="left", + width=350, + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.download_path_choose_button = ctk.CTkButton( + master=self, + width=30, + height=30, + font=("arial", 28, "bold"), + text="📂", + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + hover=False, + command=self.select_download_path, + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.apply_changes_btn = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + height=24, + width=50, + command=self.apply_general_settings, + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.general_settings_change_callback = general_settings_change_callback + self.general_settings = general_settings + self.configure_values() + self.set_accent_color() + self.place_widgets() + self.bind_events() + ThemeManager.bind_widget(self) + + def apply_general_settings(self): + self.general_settings["download_directory"] = format_path(self.download_path_entry.get()) + self.general_settings_change_callback(self.general_settings) + self.apply_changes_btn.configure(state="disabled") + + def download_path_validate(self, _event): + path = format_path(self.download_path_entry.get()) + if path != self.general_settings["download_directory"]: + if validate_download_path(path): + self.apply_changes_btn.configure(state="normal") + else: + self.apply_changes_btn.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=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.apply_changes_btn.configure( + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.download_path_entry.configure( + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... + + def place_widgets(self): + self.download_path_label.place(y=50, x=50) + self.dash1_label.place(y=50, x=150) + self.download_path_entry.place(y=50, x=170) + self.download_path_choose_button.place(y=42, x=540) + self.apply_changes_btn.place(y=100, x=500) + + def configure_values(self): + self.download_path_entry.insert(0, self.general_settings["download_directory"]) diff --git a/widgets/Components/NavigationPanel.py b/widgets/Components/NavigationPanel.py new file mode 100644 index 0000000..6bd9a7b --- /dev/null +++ b/widgets/Components/NavigationPanel.py @@ -0,0 +1,78 @@ +import customtkinter as ctk +from typing import Any, List, Callable +from services import ThemeManager + + +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 = 0): + + super().__init__( + width=width, + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + 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, + width=width, + corner_radius=0, + text=button_text, + hover=False, + font=("arial", 13, "bold"), + text_color=ThemeManager.theme_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.bind_widget(self) + self.set_accent_color() + 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=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + else: + self.navigation_buttons_clicked_state[i] = False + navigation_button.configure(fg_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"]) + self.navigation_button_on_click_callback(navigation_panel) + + def place_widgets(self): + self.navigation_buttons[0].pack(pady=(50, 0)) + for navigation_button in self.navigation_buttons[1::]: + navigation_button.pack() + + 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=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + else: + navigation_button.configure( + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + + def reset_widgets_colors(self): + ... diff --git a/widgets/Components/NetworkPanel.py b/widgets/Components/NetworkPanel.py new file mode 100644 index 0000000..7154763 --- /dev/null +++ b/widgets/Components/NetworkPanel.py @@ -0,0 +1,150 @@ +from typing import Dict, Any, Callable +import customtkinter as ctk +from services import ThemeManager +from functions import validate_simultaneous_count + + +class NetworkPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + general_settings: Dict = None, + general_settings_change_callback: Callable = None, + width: int = 0): + + super().__init__( + width=width, + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + self.load_label = ctk.CTkLabel( + master=self, + text="Maximum Simultaneous Loads", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.dash1_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.simultaneous_load_entry = ctk.CTkEntry( + master=self, + justify="right", + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.simultaneous_load_range_label = ctk.CTkLabel( + master=self, + text="(1-10)", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.download_label = ctk.CTkLabel( + master=self, + text="Maximum Simultaneous Downloads", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.dash2_label = ctk.CTkLabel( + master=self, + text=":", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + self.simultaneous_download_entry = ctk.CTkEntry( + master=self, + justify="right", + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"], + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.simultaneous_download_range_label = ctk.CTkLabel( + master=self, + text="(1-10)", + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.apply_changes_btn = ctk.CTkButton( + master=self, + text="Apply", + state="disabled", + height=24, + width=50, + command=self.apply_general_settings, + font=("arial", 12, "bold"), + text_color=ThemeManager.theme_settings["settings_panel"]["text_color"] + ) + + self.general_settings_change_callback = general_settings_change_callback + self.general_settings = general_settings + self.set_accent_color() + self.place_widgets() + self.bind_widgets() + self.configure_values() + ThemeManager.bind_widget(self) + + def apply_general_settings(self): + self.general_settings["simultaneous_loads"] = int(self.simultaneous_load_entry.get()) + self.general_settings["simultaneous_downloads"] = int(self.simultaneous_download_entry.get()) + self.general_settings_change_callback(self.general_settings) + self.apply_changes_btn.configure(state="disabled") + + def simultaneous_load_count_check(self, _event): + value = self.simultaneous_load_entry.get() + if validate_simultaneous_count(value) and int(value) != self.general_settings["simultaneous_loads"]: + self.apply_changes_btn.configure(state="normal") + else: + self.apply_changes_btn.configure(state="disabled") + + def simultaneous_download_count_check(self, _event): + value = self.simultaneous_download_entry.get() + if validate_simultaneous_count(value) and int(value) != self.general_settings["simultaneous_downloads"]: + self.apply_changes_btn.configure(state="normal") + else: + self.apply_changes_btn.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", + self.general_settings["simultaneous_loads"] + ) + self.simultaneous_download_entry.insert( + "end", + self.general_settings["simultaneous_downloads"] + ) + + def update_accent_color(self): + self.set_accent_color() + + def set_accent_color(self): + self.apply_changes_btn.configure( + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.simultaneous_load_entry.configure( + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.simultaneous_download_entry.configure( + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + + def place_widgets(self): + self.load_label.place(y=50, x=50) + self.dash1_label.place(y=50, x=270) + self.simultaneous_load_entry.place(y=50, x=300) + self.simultaneous_load_range_label.place(y=50, x=450) + + self.download_label.place(y=100, x=50) + self.dash2_label.place(y=100, x=270) + self.simultaneous_download_entry.place(y=100, x=300) + self.simultaneous_download_range_label.place(y=100, x=450) + + self.apply_changes_btn.place(y=150, x=380) + + def reset_widgets_colors(self): + ... diff --git a/widgets/Components/ThumbnailButton.py b/widgets/Components/ThumbnailButton.py new file mode 100644 index 0000000..f874e18 --- /dev/null +++ b/widgets/Components/ThumbnailButton.py @@ -0,0 +1,72 @@ +import tkinter as tk +from tkinter import PhotoImage +import threading +import time +from typing import Tuple, List, Literal, Any +from services import LoadingIndicatorController + + +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.AddedVideo import AddedVideo + + self.master: AddedVideo = master + self.loading_animation_state: Literal["enabled", "disabled"] = "disabled" + 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 + self.loading_animation_state = "enabled" + while self.master.load_state != "removed": + self.configure(text="." * LoadingIndicatorController.dots_count) + time.sleep(LoadingIndicatorController.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): + 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(LoadingIndicatorController.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/Components/__init__.py b/widgets/Components/__init__.py new file mode 100644 index 0000000..a6237e7 --- /dev/null +++ b/widgets/Components/__init__.py @@ -0,0 +1,7 @@ +from .AccentColorButton import AccentColorButton +from .ThumbnailButton import ThumbnailButton + +from .AppearancePanel import AppearancePanel +from .NetworkPanel import NetworkPanel +from .DownloadsPanel import DownloadsPanel +from .NavigationPanel import NavigationPanel diff --git a/widgets/CoreWidgets/AlertWindow.py b/widgets/CoreWidgets/AlertWindow.py new file mode 100644 index 0000000..ebb3590 --- /dev/null +++ b/widgets/CoreWidgets/AlertWindow.py @@ -0,0 +1,106 @@ +import customtkinter as ctk +from PIL import Image +from typing import Callable +from services import ThemeManager + + +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 = 450, + height: int = 130): + + super().__init__( + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"]) + self.master: ctk.CTk = master + self.width = width + self.height = height + self.callback = callback + + self.resizable(False, False) + self.iconbitmap("src\\icon.ico") + self.title("PytubeDownloader") + self.transient(master) + self.grab_set() + + self.info_image = ctk.CTkImage(Image.open("src\\info.png"), size=(60, 60)) + self.info_image_label = ctk.CTkLabel( + master=self, + text="", + image=self.info_image + ) + self.info_image_label.pack(side="left", fill="y", padx=(30, 10)) + + self.error_msg_label = ctk.CTkLabel( + master=self, + text=alert_msg, + text_color=ThemeManager.theme_settings["alert_window"]["msg_color"]["normal"], + font=("Arial", 13, "bold") + ) + self.error_msg_label.pack(pady=(20, 15), padx=(0, 30)) + + if cancel_button_text is not None: + self.cancel_button = ctk.CTkButton( + border_width=2, + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + master=self, + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + command=self.on_click_cancel_button, + text=cancel_button_text, + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + width=100 + ) + self.cancel_button.pack(side="right", padx=(20, 40)) + + if ok_button_text is not None: + self.ok_button = ctk.CTkButton( + border_width=2, + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + master=self, + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + command=self.on_click_ok_button, + text=ok_button_text, + fg_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + width=100 + ) + self.ok_button.pack(side="right", padx=(0, 20)) + + 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 + 7) + geometry_y = int(self.master.winfo_height() * 0.5 + self.master.winfo_y() - 0.5 * self.height + 20) + 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/CoreWidgets/ContextMenu.py b/widgets/CoreWidgets/ContextMenu.py new file mode 100644 index 0000000..7cff7b8 --- /dev/null +++ b/widgets/CoreWidgets/ContextMenu.py @@ -0,0 +1,101 @@ +import customtkinter as ctk +import pyautogui +from services import ThemeManager + + +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=ThemeManager.theme_settings["root"]["fg_color"]["hover"] + ) + + self.select_all_button = ctk.CTkButton( + master=self, + command=self.select_all, + text="Select All", + width=width, + corner_radius=0, + text_color=ThemeManager.theme_settings["context_menu"]["text_color"], + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["hover"] + ) + self.cut_button = ctk.CTkButton( + master=self, + command=self.cut, + text="Cut", + width=width, + corner_radius=0, + text_color=ThemeManager.theme_settings["context_menu"]["text_color"], + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["hover"] + ) + self.copy_button = ctk.CTkButton( + master=self, + command=self.copy, + text="Copy", + width=width, + corner_radius=0, + text_color=ThemeManager.theme_settings["context_menu"]["text_color"], + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["hover"] + ) + self.paste_button = ctk.CTkButton( + master=self, + command=self.paste, + text="Paste", + width=width, + corner_radius=0, + text_color=ThemeManager.theme_settings["context_menu"]["text_color"], + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["hover"] + ) + + self.set_accent_color() + self.place_widgets() + ThemeManager.bind_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_accent_color(self): + self.configure( + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.select_all_button.configure( + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.cut_button.configure( + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.copy_button.configure( + hover_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"], + ) + self.paste_button.configure( + hover_color=ThemeManager.theme_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/CoreWidgets/SettingPanel.py b/widgets/CoreWidgets/SettingPanel.py new file mode 100644 index 0000000..24be036 --- /dev/null +++ b/widgets/CoreWidgets/SettingPanel.py @@ -0,0 +1,81 @@ +from typing import Dict, Any, Callable +import customtkinter as ctk +from widgets import ( + AppearancePanel, + NetworkPanel, + DownloadsPanel, + NavigationPanel +) +from services import ThemeManager + + +class SettingPanel(ctk.CTkFrame): + def __init__( + self, + master: Any = None, + # general info + general_settings: Dict = None, + # changes callbacks + theme_settings_change_callback: Callable = None, + general_settings_change_callback: Callable = None): + super().__init__( + master=master, + fg_color=ThemeManager.theme_settings["root"]["fg_color"]["normal"] + ) + + self.appearance_panel = AppearancePanel( + master=self, + theme_settings_change_callback=theme_settings_change_callback, + ) + + self.network_panel = NetworkPanel( + master=self, + general_settings=general_settings, + general_settings_change_callback=general_settings_change_callback + ) + + self.downloads_panel = DownloadsPanel( + master=self, + general_settings=general_settings, + general_settings_change_callback=general_settings_change_callback + ) + + self.panels = [self.appearance_panel, self.network_panel, self.downloads_panel] + + self.navigation_panel = NavigationPanel( + master=self, + width=250, + navigation_panels=self.panels, + navigation_button_on_click_callback=self.place_panel, + navigation_buttons_text=["Appearance", "Network", "Downloads"] + ) + + self.vertical_line = ctk.CTkFrame( + master=self, + width=2 + ) + + ThemeManager.bind_widget(self) + self.set_accent_color() + 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_accent_color(self): + self.vertical_line.configure(fg_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"]) + + def update_accent_color(self): + self.set_accent_color() + + def reset_widgets_colors(self): + ... diff --git a/widgets/CoreWidgets/TrayMenu.py b/widgets/CoreWidgets/TrayMenu.py new file mode 100644 index 0000000..73e2559 --- /dev/null +++ b/widgets/CoreWidgets/TrayMenu.py @@ -0,0 +1,20 @@ +from PIL import Image +import pystray +from typing import Callable + + +class TrayMenu: + def __init__( + self, + open_command: Callable = None, + quit_command: Callable = None): + + self.tray_image = Image.open("src/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): + self.tray_icon.run() + + def stop(self): + self.tray_icon.stop() diff --git a/widgets/CoreWidgets/__init__.py b/widgets/CoreWidgets/__init__.py new file mode 100644 index 0000000..92d52f6 --- /dev/null +++ b/widgets/CoreWidgets/__init__.py @@ -0,0 +1,4 @@ +from .AlertWindow import AlertWindow +from .SettingPanel import SettingPanel +from .ContextMenu import ContextMenu +from .TrayMenu import TrayMenu diff --git a/widgets/PlayList/AddedPlayList.py b/widgets/PlayList/AddedPlayList.py new file mode 100644 index 0000000..8923edc --- /dev/null +++ b/widgets/PlayList/AddedPlayList.py @@ -0,0 +1,329 @@ +import threading +import customtkinter as ctk +import pytube +from typing import Literal, Union, Any, List +from .PlayList import PlayList +from widgets import AddedVideo +from functions import pass_command +from services import ThemeManager + + +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 functions + 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"@1 > AddedPlayList.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, + 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=ThemeManager.theme_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): + self.status_label.configure(text="Failed", text_color=ThemeManager.theme_settings["video_object"]["error_color"]["normal"]) + self.reload_btn.place(relx=1, y=32, x=-80) + + def clear_loading_failure(self): + self.status_label.configure(text="Loading", text_color=ThemeManager.theme_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_option(video.resolution_select_menu.cget("values")[index]) + + def kill(self): + for video in self.videos: + video.video_load_status_callback = pass_command + video.kill() + super().kill() + + # create widgets + def create_widgets(self): + super().create_widgets() + + self.sub_frame = ctk.CTkFrame( + master=self.playlist_info_widget, + height=self.height - 4, + width=340, + ) + + self.resolution_select_menu = ctk.CTkComboBox( + master=self.sub_frame, + values=["..........", "..........", ".........."] + ) + + self.download_btn = ctk.CTkButton( + master=self.sub_frame, + text="Download", + width=80, + height=25, + 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, "bold"), + ) + + self.reload_btn = ctk.CTkButton( + self.playlist_info_widget, + text="⟳", + width=15, height=15, + font=("arial", 22, "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, + font=("arial", 11, "bold"), + ) + + # configure widgets colors + def set_accent_color(self): + super().set_accent_color() + self.download_btn.configure(border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + + self.download_btn.configure(border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + + def reset_widgets_colors(self): + super().reset_widgets_colors() + + def set_widgets_colors(self): + super().set_widgets_colors() + self.sub_frame.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.download_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.reload_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.videos_status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + + def on_mouse_enter_self(self, _event): + super().on_mouse_enter_self(_event) + self.sub_frame.configure(fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + self.reload_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.reload_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["hover"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["hover"], + border_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["normal"], + border_color=ThemeManager.theme_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=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + ) + self.on_mouse_enter_self(_event) + + def on_mouse_leave_reload_btn(_event): + self.reload_btn.configure( + text_color=ThemeManager.theme_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): + self.playlist_info_widget.pack(fill="x") + self.view_btn.place(y=55, x=10) + self.title_label.place(x=50, y=10, height=20, width=-460, relwidth=1) + self.channel_btn.place(x=50, y=34, height=20, width=-460, relwidth=1) + self.url_label.place(x=50, y=54, height=20, width=-460, relwidth=1) + self.playlist_video_count_label.place(relx=1, x=-40, rely=1, y=-25) + self.remove_btn.place(relx=1, x=-25, y=3) + + self.sub_frame.place(y=2, relx=1, x=-390) + self.resolution_select_menu.place(y=22, x=0) + self.download_btn.place(x=160, y=8) + self.status_label.place(x=200, anchor="n", y=40) + self.videos_status_label.place(rely=1, y=-18, relx=0.5, anchor="n") diff --git a/widgets/PlayList/DownloadedPlayList.py b/widgets/PlayList/DownloadedPlayList.py new file mode 100644 index 0000000..9890d72 --- /dev/null +++ b/widgets/PlayList/DownloadedPlayList.py @@ -0,0 +1,91 @@ +from widgets.PlayList import PlayList +from widgets.Video.DownloadedVideo import DownloadedVideo +from widgets.Video.DownloadingVideo import DownloadingVideo +from typing import Literal, List, Any +from functions import pass_command +import threading + + +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, + 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 = pass_command + video.kill() + super().kill() diff --git a/widgets/PlayList/DownloadingPlayList.py b/widgets/PlayList/DownloadingPlayList.py new file mode 100644 index 0000000..971b211 --- /dev/null +++ b/widgets/PlayList/DownloadingPlayList.py @@ -0,0 +1,304 @@ +import customtkinter as ctk +import threading +from typing import List, Any, Literal, Union, Callable +from widgets.PlayList import PlayList +from widgets.Video.DownloadingVideo import DownloadingVideo +from widgets.Video.AddedVideo import AddedVideo +from functions import pass_command +from services import ThemeManager + + +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, + # download directory + download_directory: str = "", + # playlist download completed callback functions + 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 functions + self.playlist_download_complete_callback = playlist_download_complete_callback + self.added_videos: List[AddedVideo] = videos + self.download_directory = download_directory + 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, + 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, + download_directory=self.download_directory, + # 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): + self.re_download_btn.place(relx=1, y=32, x=-80) + self.status_label.configure(text="Failed", text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["text_color"]["normal"]) + + def set_downloading_completed(self): + self.status_label.configure(text="Downloaded", text_color=ThemeManager.theme_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 = pass_command + video.kill() + super().kill() + + # create widgets + def create_widgets(self): + super().create_widgets() + + self.sub_frame = ctk.CTkFrame( + self, + height=self.height - 4, + ) + + self.download_progress_bar = ctk.CTkProgressBar( + master=self.sub_frame, + height=8 + ) + + self.download_percentage_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "bold"), + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "bold"), + ) + + self.re_download_btn = ctk.CTkButton( + self, + text="⟳", + width=15, height=15, + font=("arial", 20, "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, + font=("arial", 11, "bold"), + ) + + # configure widgets colors depend on root width + def set_accent_color(self): + super().set_accent_color() + self.re_download_btn.configure(text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + + def set_widgets_colors(self): + super().set_widgets_colors() + self.sub_frame.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.download_percentage_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.re_download_btn.configure( + fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + self.re_download_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.re_download_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + text_color=ThemeManager.theme_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() + self.title_label.place(x=50, y=10, height=20, width=-20, relwidth=0.5) + self.channel_btn.place(x=50, y=34, height=20, width=-20, relwidth=0.5) + self.url_label.place(x=50, y=54, height=20, width=-20, relwidth=0.5) + + self.sub_frame.place(relx=0.5, y=2, x=50) + self.download_percentage_label.place(relx=0.5, anchor="n", y=4) + self.download_percentage_label.configure(height=20) + self.download_progress_bar.place(relwidth=1, y=28) + self.status_label.place(relx=0.775, anchor="n", y=40) + self.status_label.configure(height=20) + self.videos_status_label.place(rely=1, y=-18, relx=0.5, anchor="n") + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + self.sub_frame.configure(width=self.winfo_width() / 2 - 150) diff --git a/widgets/PlayList/PlayList.py b/widgets/PlayList/PlayList.py new file mode 100644 index 0000000..b4797be --- /dev/null +++ b/widgets/PlayList/PlayList.py @@ -0,0 +1,314 @@ +import tkinter as tk +import webbrowser +import customtkinter as ctk +from typing import Any, Union +from widgets import AddedVideo +from widgets import DownloadingVideo +from widgets import DownloadedVideo +from services import ThemeManager + + +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 + # initialize the object + self.create_widgets() + self.set_widgets_colors() + self.reset_widgets_colors() + self.set_accent_color() + self.place_widgets() + # self append to theme manger + ThemeManager.bind_widget(self) + + def hide_videos(self): + self.view_btn.configure( + command=self.view_videos, + text=">", + font=('arial', 18, 'bold') + ) + self.playlist_item_frame.pack_forget() + + def view_videos(self): + self.view_btn.configure( + command=self.hide_videos, + text="V", + font=('arial', 13, '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.unbind_widget(self) + self.pack_forget() + self.destroy() + + def create_widgets(self): + 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, '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', 10, 'bold'), + text=f"Title : {self.playlist_title}" + ) + + self.channel_btn = tk.Button( + master=self.playlist_info_widget, + font=('arial', 9, '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', 11, "italic underline"), + text=self.playlist_url, + ) + + self.remove_btn = ctk.CTkButton( + master=self.playlist_info_widget, + command=self.kill, + text="X", + font=("arial", 12, "bold"), + width=12, height=20, + border_spacing=0, + hover=False, + ) + + self.playlist_video_count_label = ctk.CTkLabel( + master=self.playlist_info_widget, + width=15, height=15, + font=("arial", 13, "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=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.view_btn.configure(text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.url_label.configure( + fg=ThemeManager.theme_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(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["text_color"]["normal"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["text_color"]["normal"]), + activeforeground=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["text_color"]["hover"]), + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + ) + self.playlist_info_widget.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.remove_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["remove_btn_text_color"]["normal"] + ) + self.playlist_video_count_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.bind_widget_events() + + def on_mouse_enter_self(self, event): + self.playlist_info_widget.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.view_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + ) + self.title_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + + 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: + video_object.on_mouse_enter_self(event) + + def on_mouse_leave_self(self, event): + self.playlist_info_widget.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.view_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + ) + self.title_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + + 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: + video_object.on_mouse_leave_self(event) + + 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(ThemeManager.theme_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(ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["error_color"]["hover"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], + text_color=ThemeManager.theme_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): + self.playlist_info_widget.pack(fill="x") + self.view_btn.place(y=55, x=10) + self.title_label.place(x=50, y=10, height=20, width=-420, relwidth=1) + self.channel_btn.place(x=50, y=34, height=20, width=-420, relwidth=1) + self.url_label.place(x=50, y=54, height=20, width=-420, relwidth=1) + self.playlist_video_count_label.place(relx=1, x=-40, rely=1, y=-25) + self.remove_btn.place(relx=1, x=-25, y=3) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + ... diff --git a/widgets/PlayList/__init__.py b/widgets/PlayList/__init__.py new file mode 100644 index 0000000..8d25328 --- /dev/null +++ b/widgets/PlayList/__init__.py @@ -0,0 +1,4 @@ +from .PlayList import PlayList +from .AddedPlayList import AddedPlayList +from .DownloadingPlayList import DownloadingPlayList +from .DownloadedPlayList import DownloadedPlayList diff --git a/widgets/Video/AddedVideo.py b/widgets/Video/AddedVideo.py new file mode 100644 index 0000000..b2bd45e --- /dev/null +++ b/widgets/Video/AddedVideo.py @@ -0,0 +1,290 @@ +from widgets.Video import Video +import customtkinter as ctk +import pytube +import threading +from typing import Literal, Union, List, Any, Callable +from services import ( + LoadManager, + ThemeManager +) +from functions import ( + get_thumbnails, + get_supported_download_types, + sort_dict, + get_formated_combo_box_values, +) + + +class AddedVideo(Video): + def __init__( + self, + master: Any, + width: int = 0, + height: int = 0, + # video info + video_url: str = "", + # callback functions 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[str], None] = None + # download info + self.download_quality: Literal["128kbps", "360p", "720p"] = "720p" + self.download_type: Literal["Audio", "Video"] = "Video" + # callback functions + 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(ThemeManager.theme_settings["video_object"]["text_color"]["normal"]) + ) + self.thumbnail_btn.run_loading_animation() + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], + text="Loading" + ) + self.load_video() + + def load_video(self): + self.thumbnail_btn.run_loading_animation() + if LoadManager.max_concurrent_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 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 = get_thumbnails(self.video) + self.support_download_types = sort_dict(get_supported_download_types(self.video_stream_data)) + self.set_load_completed() + self.set_video_data() + except Exception as error: + print(f"@1AddedVideo.py > {error}") + self.set_loading_failed() + + def choose_download_option(self, e: str): + self.download_quality = e.replace(" ", "").split("|")[0] + if "kbps" in self.download_quality: + self.download_type = "Audio" + else: + 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 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): + 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=ThemeManager.theme_settings["video_object"]["error_color"]["normal"]) + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], + text="Failed" + ) + self.reload_btn.place(relx=1, y=22, x=-80) + + def set_video_data(self): + if self.load_state != "removed": + super().set_video_data() + self.resolution_select_menu.configure( + values=get_formated_combo_box_values(self.support_download_types) + ) + self.resolution_select_menu.set(self.resolution_select_menu.cget("values")[0]) + self.choose_download_option(self.resolution_select_menu.get()) + self.resolution_select_menu.configure(command=self.choose_download_option) + 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): + + self.sub_frame = ctk.CTkFrame( + master=self, + height=self.height - 4, + width=250, + ) + + self.resolution_select_menu = ctk.CTkComboBox( + master=self.sub_frame, + values=["..........", "..........", ".........."] + ) + + self.download_btn = ctk.CTkButton( + master=self.sub_frame, text="Download", width=80, height=25, + border_width=2, + state="disabled", + hover=False, + command=lambda: self.video_download_button_click_callback(self) + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + height=15, + font=("arial", 12, "bold"), + ) + + self.reload_btn = ctk.CTkButton( + master=self, + text="⟳", + width=15, height=15, + font=("arial", 20, "normal"), + command=self.reload_video, + hover=False, + ) + super().create_widgets() + + # configure widgets colors + def set_accent_color(self): + self.download_btn.configure(border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.reload_btn.configure(text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + super().set_accent_color() + + def set_widgets_colors(self): + self.reload_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.download_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["normal"] + ) + self.sub_frame.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + self.reload_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.reload_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["hover"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["hover"], + border_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["btn_fg_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["btn_text_color"]["normal"], + border_color=ThemeManager.theme_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=ThemeManager.theme_settings["root"]["accent_color"]["hover"], + ) + + def on_mouse_leave_reload_btn(event): + self.on_mouse_leave_self(event) + self.reload_btn.configure( + text_color=ThemeManager.theme_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() + + self.sub_frame.place(y=2, relx=1, x=-350) + self.resolution_select_menu.place(y=15, x=20) + self.download_btn.place(x=170, y=8) + self.status_label.place(x=210, anchor="n", y=44) + + # static method + active_load_count = 0 + max_concurrent_loads = 1 + queued_loads = [] + active_loads = [] diff --git a/widgets/Video/DownloadedVideo.py b/widgets/Video/DownloadedVideo.py new file mode 100644 index 0000000..7bf6029 --- /dev/null +++ b/widgets/Video/DownloadedVideo.py @@ -0,0 +1,145 @@ +from widgets.Video import Video +import customtkinter as ctk +from tkinter import PhotoImage +from typing import List, Literal, Union, Any, Callable +import os +from functions import ( + get_converted_size +) +from services import ThemeManager + + +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() + + self.download_type_label = ctk.CTkLabel( + master=self, + text=f"{self.download_type} : {self.download_quality}", + height=15, + font=("arial", 12, "bold"), + ) + + self.file_size_label = ctk.CTkLabel( + master=self, + text=get_converted_size(self.file_size, 2), + font=("arial", 12, "normal"), + height=15, + ) + + self.download_path_btn = ctk.CTkButton( + master=self, + text="📂", + font=("arial", 30, "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() + self.download_type_label.place(y=14, relx=1, x=-300) + self.file_size_label.place(y=40, relx=1, x=-300) + self.download_path_btn.place(y=12, relx=1, x=-150) + + # 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=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.file_size_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.download_path_btn.configure( + fg_color=ThemeManager.theme_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=ThemeManager.theme_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=ThemeManager.theme_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=ThemeManager.theme_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=ThemeManager.theme_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/DownloadingVideo.py b/widgets/Video/DownloadingVideo.py new file mode 100644 index 0000000..3dfbcdd --- /dev/null +++ b/widgets/Video/DownloadingVideo.py @@ -0,0 +1,461 @@ +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 services import DownloadManager +from functions import ( + pass_command, + get_converted_size, + get_valid_file_name, + get_available_file_name, + create_download_directory +) +from services import ThemeManager + + +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 functions @ only use if mode is video + video_download_complete_callback: callable = None, + # state callback functions @ only use if mode is video + mode: Literal["video", "playlist"] = "video", + video_download_status_callback: callable = None, + video_download_progress_callback: callable = None, + # download method and directory + download_directory: str = ""): + + # 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.download_directory: str = download_directory + 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): + if DownloadManager.max_concurrent_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=22, relx=1, x=-80) + 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=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], text="Failed") + elif self.download_state == "waiting": + self.status_label.configure(text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], text="Waiting") + elif self.download_state == "paused": + self.status_label.configure(text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], text="Paused") + elif self.download_state == "downloading": + self.status_label.configure(text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], text="Downloading") + elif self.download_state == "pausing": + self.status_label.configure(text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], text="Pausing") + elif self.download_state == "completed": + self.status_label.configure(text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"], text="Downloaded") + + def download_video(self): + if not os.path.exists(self.download_directory): + try: + create_download_directory(self.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"{self.download_directory}\\" + f"{get_valid_file_name(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 = get_converted_size(self.file_size, 2) + self.download_file_name = 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=get_converted_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 DownloadingPlayList.py : ", error) + self.set_download_failed() + break + except Exception as error: + print("@4 DownloadingPlayList.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=pass_command) + 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"{get_converted_size(self.bytes_downloaded, 2)} / {self.converted_file_size}" + ) + if self.mode == "playlist": + self.video_download_progress_callback() + + def set_download_failed(self): + 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=22, relx=1, x=-80) + + 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() + + self.sub_frame = ctk.CTkFrame( + self, + height=self.height - 4, + ) + + self.download_progress_bar = ctk.CTkProgressBar( + master=self.sub_frame, + height=8 + ) + + self.download_progress_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "bold"), + ) + + self.download_percentage_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "bold"), + ) + + self.download_type_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "normal"), + ) + + self.net_speed_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "normal"), + ) + + self.status_label = ctk.CTkLabel( + master=self.sub_frame, + text="", + font=("arial", 12, "bold"), + ) + + self.re_download_btn = ctk.CTkButton( + master=self, + text="⟳", + width=15, + height=15, + font=("arial", 20, "normal"), + command=self.re_download_video, + hover=False + ) + + self.pause_resume_btn = ctk.CTkButton( + master=self, + text="⏸", + width=15, + height=15, + font=("arial", 20, "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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + self.re_download_btn.configure(fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + self.pause_resume_btn.configure(fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.re_download_btn.configure(fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.pause_resume_btn.configure(fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + + def set_accent_color(self): + super().set_accent_color() + self.download_progress_bar.configure( + progress_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.re_download_btn.configure( + text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.pause_resume_btn.configure( + text_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + + def set_widgets_colors(self) -> None: + super().set_widgets_colors() + self.sub_frame.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + ) + self.download_progress_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.download_percentage_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.download_type_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.net_speed_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.status_label.configure( + text_color=ThemeManager.theme_settings["video_object"]["text_color"]["normal"] + ) + self.re_download_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"] + ) + self.pause_resume_btn.configure( + fg_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + text_color=ThemeManager.theme_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): + self.remove_btn.place(relx=1, x=-24, y=4) + self.thumbnail_btn.place(x=5, y=2, relheight=1, height=-4, width=int((self.height - 4) / 9 * 16)) + self.video_title_label.place(x=130, y=4, height=20, relwidth=0.5, width=-150) + self.channel_btn.place(x=130, y=24, height=20, relwidth=0.5, width=-150) + self.url_label.place(x=130, y=44, height=20, relwidth=0.5, width=-150) + self.len_label.place(rely=1, y=-10, x=117, anchor="e") + self.sub_frame.place(relx=0.5, y=2) + self.download_progress_label.place(relx=0.25, anchor="n", y=4) + self.download_progress_label.configure(height=20) + self.download_percentage_label.place(relx=0.115, anchor="n", y=40) + self.download_percentage_label.configure(height=20) + self.download_progress_bar.place(relwidth=1, y=30) + self.download_type_label.place(relx=0.75, anchor="n", y=4) + self.download_type_label.configure(height=20) + self.net_speed_label.place(relx=0.445, anchor="n", y=40) + self.net_speed_label.configure(height=20) + self.status_label.place(relx=0.775, anchor="n", y=40) + self.status_label.configure(height=20) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, e): + self.sub_frame.configure(width=self.master.winfo_width() / 2 - 100) diff --git a/widgets/Video/Video.py b/widgets/Video/Video.py new file mode 100644 index 0000000..86b5688 --- /dev/null +++ b/widgets/Video/Video.py @@ -0,0 +1,276 @@ +import tkinter as tk +import webbrowser +import customtkinter as ctk +from typing import List, Union, Any +from tkinter import PhotoImage +from functions import ( + get_converted_time, +) +from widgets.Components import ThumbnailButton +from services import ThemeManager + + +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 append to theme manger + ThemeManager.bind_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=get_converted_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.unbind_widget(self) + self.pack_forget() + self.destroy() + + # create widgets + def create_widgets(self): + self.thumbnail_btn = ThumbnailButton( + master=self, + font=("arial", 14, "bold"), + state="disabled", + command=lambda: webbrowser.open(self.video_url), + ) + + self.len_label = ctk.CTkLabel( + master=self, + width=1, + height=1, + font=("arial", 11, "bold"), + text=get_converted_time(self.length) + ) + + self.video_title_label = tk.Label( + master=self, + anchor="w", + font=('arial', 10, 'normal'), + text=f"Title : {self.video_title}" + ) + + self.channel_btn = tk.Button( + master=self, font=('arial', 9, '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', 10, "italic underline"), + text=self.video_url + ) + + self.remove_btn = ctk.CTkButton( + master=self, + command=self.kill, + text="X", + font=("arial", 12, "bold"), + width=12, + height=20, + border_spacing=0, + hover=False, + ) + + self.bind("", self.configure_widget_sizes) + + # set widgets colors + def set_accent_color(self): + self.configure(border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.thumbnail_btn.configure( + fg=(ThemeManager.theme_settings["root"]["accent_color"]["normal"]), + ) + self.channel_btn.configure(activeforeground=ThemeManager.theme_settings["root"]["accent_color"]["normal"]) + self.url_label.configure(fg=ThemeManager.theme_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(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + disabledforeground=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["text_color"]["normal"]), + activebackground=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["text_color"]["normal"]) + ) + + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + ) + + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + fg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["btn_text_color"]["normal"]), + activebackground=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]), + ) + + def set_widgets_colors(self): + self.configure(fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + self.remove_btn.configure( + fg_color=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], + text_color=ThemeManager.theme_settings["video_object"]["remove_btn_text_color"]["normal"] + ) + self.bind_widget_events() + + def on_mouse_enter_self(self, event): + self.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["hover"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["hover"] + ) + self.thumbnail_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["hover"]) + ) + + def on_mouse_leave_self(self, event): + self.configure( + fg_color=ThemeManager.theme_settings["video_object"]["fg_color"]["normal"], + border_color=ThemeManager.theme_settings["root"]["accent_color"]["normal"] + ) + self.thumbnail_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + self.video_title_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + self.channel_btn.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + self.url_label.configure( + bg=ThemeManager.get_color_based_on_theme(ThemeManager.theme_settings["video_object"]["fg_color"]["normal"]) + ) + + def bind_widget_events(self): + 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(ThemeManager.theme_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(ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["error_color"]["hover"], + text_color=ThemeManager.theme_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=ThemeManager.theme_settings["video_object"]["error_color"]["normal"], + text_color=ThemeManager.theme_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): + self.remove_btn.place(relx=1, x=-25, y=3) + self.thumbnail_btn.place(x=5, y=2, relheight=1, height=-4, width=int((self.height - 4) / 9 * 16)) + self.len_label.place(rely=1, y=-10, x=117, anchor="e") + self.video_title_label.place(x=130, y=4, height=20, relwidth=1, width=-480) + self.channel_btn.place(x=130, y=24, height=20, relwidth=1, width=-480) + self.url_label.place(x=130, y=44, height=20, relwidth=1, width=-480) + + # configure widgets sizes and place location depend on root width + def configure_widget_sizes(self, event): + ... diff --git a/widgets/Video/__init__.py b/widgets/Video/__init__.py new file mode 100644 index 0000000..fb96d0b --- /dev/null +++ b/widgets/Video/__init__.py @@ -0,0 +1,4 @@ +from .Video import Video +from .AddedVideo import AddedVideo +from .DownloadingVideo import DownloadingVideo +from .DownloadedVideo import DownloadedVideo diff --git a/widgets/__init__.py b/widgets/__init__.py new file mode 100644 index 0000000..f0ecf4d --- /dev/null +++ b/widgets/__init__.py @@ -0,0 +1,18 @@ +from .Video import AddedVideo +from .Video import DownloadingVideo +from .Video import DownloadedVideo + +from .PlayList import AddedPlayList +from .PlayList import DownloadingPlayList +from .PlayList import DownloadedPlayList + +from .Components import AccentColorButton +from .Components import AppearancePanel +from .Components import NetworkPanel +from .Components import DownloadsPanel +from .Components import NavigationPanel + +from .CoreWidgets import SettingPanel +from .CoreWidgets import AlertWindow +from .CoreWidgets import ContextMenu +from .CoreWidgets import TrayMenu