From 0885f18210a05a7b08561ae2522f4e3df657c0b5 Mon Sep 17 00:00:00 2001 From: Thisal Dilmith <93121062+Thisal-D@users.noreply.github.com> Date: Thu, 9 May 2024 03:50:52 +0530 Subject: [PATCH] Add files via upload v0.0.3 --- functions/Accessible.py | 16 + functions/__init__.py | 28 ++ functions/clearTempFiles.py | 11 + functions/convertTime.py | 9 + functions/createDownloadDirectory.py | 15 + functions/formatPath.py | 13 + functions/getAvailableFileName.py | 14 + functions/getColor.py | 4 + functions/getConvertedPath.py | 2 + functions/getConvertedSize.py | 16 + functions/getConvertedTime.py | 11 + functions/getFormatedComboBoxValues.py | 11 + functions/getGeneralSettings.py | 12 + functions/getSupportedDownloadTypes.py | 34 ++ functions/getThemeSettings.py | 26 ++ functions/getThumbnails.py | 63 ++++ functions/getValidFileName.py | 8 + functions/getWindowScale.py | 5 + functions/passCommand.py | 2 + functions/removeInvalidCharts.py | 6 + functions/saveSettings.py | 7 + functions/sortDict.py | 31 ++ functions/validateColor.py | 15 + functions/validateDownloadPath.py | 18 + functions/validateSimultaneousCount.py | 9 + services/DownloadManager.py | 30 ++ services/LoadManager.py | 31 ++ services/LoadingIndicatorController.py | 25 ++ services/ThemeManager.py | 52 +++ services/__init__.py | 4 + src/icon.ico | Bin 0 -> 225342 bytes src/info.png | Bin 0 -> 14921 bytes temp/this directory is necessary | 1 + widgets/Components/AccentColorButton.py | 71 ++++ widgets/Components/AppearancePanel.py | 339 +++++++++++++++++ widgets/Components/DownloadsPanel.py | 116 ++++++ widgets/Components/NavigationPanel.py | 78 ++++ widgets/Components/NetworkPanel.py | 150 ++++++++ widgets/Components/ThumbnailButton.py | 72 ++++ widgets/Components/__init__.py | 7 + widgets/CoreWidgets/AlertWindow.py | 106 ++++++ widgets/CoreWidgets/ContextMenu.py | 101 ++++++ widgets/CoreWidgets/SettingPanel.py | 81 +++++ widgets/CoreWidgets/TrayMenu.py | 20 + widgets/CoreWidgets/__init__.py | 4 + widgets/PlayList/AddedPlayList.py | 329 +++++++++++++++++ widgets/PlayList/DownloadedPlayList.py | 91 +++++ widgets/PlayList/DownloadingPlayList.py | 304 ++++++++++++++++ widgets/PlayList/PlayList.py | 314 ++++++++++++++++ widgets/PlayList/__init__.py | 4 + widgets/Video/AddedVideo.py | 290 +++++++++++++++ widgets/Video/DownloadedVideo.py | 145 ++++++++ widgets/Video/DownloadingVideo.py | 461 ++++++++++++++++++++++++ widgets/Video/Video.py | 276 ++++++++++++++ widgets/Video/__init__.py | 4 + widgets/__init__.py | 18 + 56 files changed, 3910 insertions(+) create mode 100644 functions/Accessible.py create mode 100644 functions/__init__.py create mode 100644 functions/clearTempFiles.py create mode 100644 functions/convertTime.py create mode 100644 functions/createDownloadDirectory.py create mode 100644 functions/formatPath.py create mode 100644 functions/getAvailableFileName.py create mode 100644 functions/getColor.py create mode 100644 functions/getConvertedPath.py create mode 100644 functions/getConvertedSize.py create mode 100644 functions/getConvertedTime.py create mode 100644 functions/getFormatedComboBoxValues.py create mode 100644 functions/getGeneralSettings.py create mode 100644 functions/getSupportedDownloadTypes.py create mode 100644 functions/getThemeSettings.py create mode 100644 functions/getThumbnails.py create mode 100644 functions/getValidFileName.py create mode 100644 functions/getWindowScale.py create mode 100644 functions/passCommand.py create mode 100644 functions/removeInvalidCharts.py create mode 100644 functions/saveSettings.py create mode 100644 functions/sortDict.py create mode 100644 functions/validateColor.py create mode 100644 functions/validateDownloadPath.py create mode 100644 functions/validateSimultaneousCount.py create mode 100644 services/DownloadManager.py create mode 100644 services/LoadManager.py create mode 100644 services/LoadingIndicatorController.py create mode 100644 services/ThemeManager.py create mode 100644 services/__init__.py create mode 100644 src/icon.ico create mode 100644 src/info.png create mode 100644 temp/this directory is necessary create mode 100644 widgets/Components/AccentColorButton.py create mode 100644 widgets/Components/AppearancePanel.py create mode 100644 widgets/Components/DownloadsPanel.py create mode 100644 widgets/Components/NavigationPanel.py create mode 100644 widgets/Components/NetworkPanel.py create mode 100644 widgets/Components/ThumbnailButton.py create mode 100644 widgets/Components/__init__.py create mode 100644 widgets/CoreWidgets/AlertWindow.py create mode 100644 widgets/CoreWidgets/ContextMenu.py create mode 100644 widgets/CoreWidgets/SettingPanel.py create mode 100644 widgets/CoreWidgets/TrayMenu.py create mode 100644 widgets/CoreWidgets/__init__.py create mode 100644 widgets/PlayList/AddedPlayList.py create mode 100644 widgets/PlayList/DownloadedPlayList.py create mode 100644 widgets/PlayList/DownloadingPlayList.py create mode 100644 widgets/PlayList/PlayList.py create mode 100644 widgets/PlayList/__init__.py create mode 100644 widgets/Video/AddedVideo.py create mode 100644 widgets/Video/DownloadedVideo.py create mode 100644 widgets/Video/DownloadingVideo.py create mode 100644 widgets/Video/Video.py create mode 100644 widgets/Video/__init__.py create mode 100644 widgets/__init__.py 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 0000000000000000000000000000000000000000..009185a27e5673d1d8078d368211152515da7e4f GIT binary patch literal 225342 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 ItckY&2Rh#)L;wH) literal 0 HcmV?d00001 diff --git a/src/info.png b/src/info.png new file mode 100644 index 0000000000000000000000000000000000000000..4b382883eb0b3f78ca2c829882e56b297f1166ff GIT binary patch literal 14921 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") + 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