From 32a4a157be8c6d8d6b67b3e034af406d901a5a90 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Sun, 28 Jun 2026 17:52:15 +0200 Subject: [PATCH] rebuild --- app/core/main_manager.py | 12 +- app/core/metadata_manager.py | 82 ---- app/core/settings_manager.py | 41 +- app/core/theme_manager.py | 19 +- app/core/youtube_manager.py | 575 ------------------------- app/ui/layouts/flow_layout.py | 279 ++++++++++++ app/ui/main_window.py | 30 +- app/ui/widgets/grid_element.py | 48 +++ app/ui/widgets/grid_widget.py | 30 ++ app/ui/widgets/tabs_widget.py | 6 +- app/ui/windows/add_music_window.py | 400 ----------------- app/ui/windows/download_list_window.py | 563 ------------------------ app/ui/windows/editing_window.py | 31 ++ app/ui/windows/settings_window.py | 86 +--- data/assets/edit.svg | 1 + data/assets/icon.ico | Bin 0 -> 8591 bytes data/lang/en.json | 86 +--- data/lang/fr.json | 86 +--- data/others/defaults_settings.json | 4 +- main.py | 83 ++-- requirements.txt | 6 +- tools/build.command | 88 ++++ tools/open.command | 0 23 files changed, 544 insertions(+), 2012 deletions(-) delete mode 100644 app/core/metadata_manager.py delete mode 100644 app/core/youtube_manager.py create mode 100644 app/ui/layouts/flow_layout.py create mode 100644 app/ui/widgets/grid_element.py create mode 100644 app/ui/widgets/grid_widget.py delete mode 100644 app/ui/windows/add_music_window.py delete mode 100644 app/ui/windows/download_list_window.py create mode 100644 app/ui/windows/editing_window.py create mode 100644 data/assets/edit.svg create mode 100644 data/assets/icon.ico create mode 100644 tools/build.command create mode 100644 tools/open.command diff --git a/app/core/main_manager.py b/app/core/main_manager.py index eb78748..b6f9077 100644 --- a/app/core/main_manager.py +++ b/app/core/main_manager.py @@ -5,8 +5,6 @@ from app.core.settings_manager import SettingsManager from app.core.alert_manager import AlertManager from app.core.update_manager import UpdateManager from app.core.license_manager import LicenseManager -from app.core.youtube_manager import YoutubeManager -from app.core.metadata_manager import MetadataManager from typing import Optional @@ -25,8 +23,6 @@ class MainManager: self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager) self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager) self.license_manager: LicenseManager = LicenseManager(self.settings_manager) - self.youtube_manager: YoutubeManager = YoutubeManager(self.settings_manager) - self.metadata_manager: MetadataManager = MetadataManager() @classmethod def get_instance(cls) -> 'MainManager': if cls._instance is None: @@ -52,10 +48,4 @@ class MainManager: return self.update_manager def get_license_manager(self) -> LicenseManager: - return self.license_manager - - def get_youtube_manager(self) -> YoutubeManager: - return self.youtube_manager - - def get_metadata_manager(self) -> MetadataManager: - return self.metadata_manager \ No newline at end of file + return self.license_manager \ No newline at end of file diff --git a/app/core/metadata_manager.py b/app/core/metadata_manager.py deleted file mode 100644 index 3c55ad5..0000000 --- a/app/core/metadata_manager.py +++ /dev/null @@ -1,82 +0,0 @@ -from pathlib import Path -import re -from mutagen.id3 import ID3, TALB, TIT2, TPE1, delete as id3_delete - -class MetadataManager: - def apply_mp3_tags(self, file_path: Path, metadata: dict, fallback_title: str) -> None: - track_title = str(metadata.get("mp3_title") or metadata.get("title") or fallback_title).strip() - track_artist = str(metadata.get("artist", "")).strip() - track_album = str(metadata.get("album", "")).strip() - track_cover = str(metadata.get("cover_path", "")).strip() - - try: - id3_delete(str(file_path)) - except Exception: - pass - - tags = ID3() - tags.delall("TIT2") - tags.add(TIT2(encoding=3, text=track_title or fallback_title)) - tags.delall("TPE1") - if track_artist: - tags.add(TPE1(encoding=3, text=track_artist)) - tags.delall("TALB") - if track_album: - tags.add(TALB(encoding=3, text=track_album)) - tags.save(str(file_path), v2_version=4) - - # Use eyeD3 for cover art (Windows Explorer compatibility) - if track_cover: - try: - import eyed3 - from PIL import Image - import io - audiofile = eyed3.load(str(file_path)) - if audiofile is not None: - if audiofile.tag is None: - audiofile.initTag() - cover_file = Path(track_cover) - if cover_file.exists() and cover_file.is_file(): - # Always convert to JPEG and resize to 999x999 - try: - img = Image.open(cover_file) - img = img.convert("RGB") - img = img.resize((999, 999), Image.LANCZOS) - buf = io.BytesIO() - img.save(buf, format="JPEG") - image_data = buf.getvalue() - mime_type = "image/jpeg" - except Exception as e: - print(f"[eyeD3] Failed to process image: {e}") - image_data = cover_file.read_bytes() - mime_type = "image/jpeg" - audiofile.tag.images.set(3, image_data, mime_type, u"Cover") - audiofile.tag.version = (2, 3, 0) - audiofile.tag.save(version=(2, 3, 0)) - except Exception as e: - print(f"[eyeD3] Failed to embed cover: {e}") - - def sanitize_filename_part(self, value: str) -> str: - clean_value = re.sub(r"[<>:\"/\\|?*]", "_", value).strip() - clean_value = re.sub(r"\s+", " ", clean_value) - return clean_value.strip(". ") - - def rename_output_file(self, file_path: Path, title: str, artist: str) -> Path: - safe_title = self.sanitize_filename_part(title) - safe_artist = self.sanitize_filename_part(artist) - if safe_artist and safe_title: - base_name = f"{safe_artist} - {safe_title}" - else: - base_name = safe_title or self.sanitize_filename_part(file_path.stem) - target_path = file_path.with_name(f"{base_name}{file_path.suffix}") - if target_path == file_path: - return file_path - counter = 2 - while target_path.exists(): - target_path = file_path.with_name(f"{base_name} ({counter}){file_path.suffix}") - counter += 1 - try: - file_path.rename(target_path) - return target_path - except Exception: - return file_path diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py index e035633..c9e138b 100644 --- a/app/core/settings_manager.py +++ b/app/core/settings_manager.py @@ -178,43 +178,4 @@ class SettingsManager: try: self.settings.setValue("maximized", maximized) except Exception as e: - logger.error(f"Error setting maximized state: {e}") - - # Audio download settings - def get_audio_format(self) -> str: - """Get preferred audio output format""" - try: - value = self.settings.value("audio_format", self.default_settings.get("audio_format", "mp3")) - if value in ("mp3", "best"): - return value - return "mp3" - except Exception as e: - logger.error(f"Error getting audio format: {e}") - return "mp3" - - def set_audio_format(self, audio_format: str) -> None: - """Set preferred audio output format""" - try: - if audio_format in ("mp3", "best") and audio_format != self.get_audio_format(): - self.settings.setValue("audio_format", audio_format) - except Exception as e: - logger.error(f"Error setting audio format: {e}") - - def get_audio_quality(self) -> str: - """Get preferred audio quality for mp3 format""" - try: - value = self.settings.value("audio_quality", self.default_settings.get("audio_quality", "320")) - if value in ("320", "192", "128"): - return value - return "320" - except Exception as e: - logger.error(f"Error getting audio quality: {e}") - return "320" - - def set_audio_quality(self, quality: str) -> None: - """Set preferred audio quality for mp3 format""" - try: - if quality in ("320", "192", "128") and quality != self.get_audio_quality(): - self.settings.setValue("audio_quality", quality) - except Exception as e: - logger.error(f"Error setting audio quality: {e}") \ No newline at end of file + logger.error(f"Error setting maximized state: {e}") \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py index d42f4b9..f603464 100644 --- a/app/core/theme_manager.py +++ b/app/core/theme_manager.py @@ -82,7 +82,6 @@ class ThemeManager: background-color: {self.current_theme.get_color("background_tertiary_color")}; color: {self.current_theme.get_color("text_color")}; }} - QDateEdit {{ border: 2px solid {self.current_theme.get_color("border_color")}; padding: 5px; @@ -90,10 +89,6 @@ class ThemeManager: font-size: 14px; min-height: 30px; }} - QDateEdit::drop-down {{ - border: none; - background: transparent; - }} QDateEdit:hover {{ border-color: {self.current_theme.get_color("primary_hover_color")}; }} @@ -127,9 +122,6 @@ class ThemeManager: border: none; background: transparent; }} - QComboBox::down-arrow {{ - image: none; - }} QComboBox:hover {{ border-color: {self.current_theme.get_color("primary_hover_color")}; }} @@ -215,4 +207,15 @@ class ThemeManager: #tab_bar {{ background-color: {self.current_theme.get_color("background_color")}; }} + + #icon_label {{ + background: transparent; + border: none; + }} + + #text_label {{ + font-size: 14px; + background: transparent; + border: none; + }} """ \ No newline at end of file diff --git a/app/core/youtube_manager.py b/app/core/youtube_manager.py deleted file mode 100644 index 0915bb6..0000000 --- a/app/core/youtube_manager.py +++ /dev/null @@ -1,575 +0,0 @@ -from pathlib import Path -import re -from urllib.parse import quote_plus -from typing import Optional - -from PyQt6.QtCore import QObject, QThread, pyqtSignal -from mutagen.id3 import APIC, ID3, TALB, TIT2, TPE1, delete as id3_delete -from yt_dlp import YoutubeDL - -import app.utils.paths as paths -from app.core.settings_manager import SettingsManager - - -class AudioDownloadWorker(QThread): - progress_text = pyqtSignal(str) - progress_value = pyqtSignal(int) - completed = pyqtSignal(str) - failed = pyqtSignal(str) - - def __init__(self, url: str, output_dir: str, audio_format: str, audio_quality: str, ffmpeg_location: Optional[str] = None, track_metadata: Optional[dict] = None) -> None: - super().__init__() - self.url = url - self.output_dir = output_dir - self.audio_format = audio_format - self.audio_quality = audio_quality - self.ffmpeg_location = ffmpeg_location - self.track_metadata = track_metadata if isinstance(track_metadata, dict) else {} - - def _progress_hook(self, data: dict) -> None: - status = data.get("status") - - if status == "downloading": - percent_text = str(data.get("_percent_str", "")).strip() - percent_text = re.sub(r"\x1b\[[0-9;]*m", "", percent_text) - percent_match = re.search(r"(\d+(?:\.\d+)?)%", percent_text) - - if percent_match: - value = int(float(percent_match.group(1))) - self.progress_value.emit(max(0, min(100, value))) - - self.progress_text.emit(percent_text or "Downloading...") - - elif status == "finished": - self.progress_value.emit(100) - - def run(self) -> None: - try: - ydl_opts = { - "format": "bestaudio/best", - "noplaylist": True, - "outtmpl": str(Path(self.output_dir) / "%(title)s.%(ext)s"), - "progress_hooks": [self._progress_hook], - "quiet": True, - "no_warnings": True, - } - - if self.ffmpeg_location: - ydl_opts["ffmpeg_location"] = self.ffmpeg_location - - if self.audio_format == "mp3": - ydl_opts.update( - { - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": self.audio_quality, - } - ] - } - ) - - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=True) - title = info.get("title", "audio") - self.progress_text.emit(title) - - if self.audio_format == "mp3": - downloaded_file = self._resolve_downloaded_file(info) - if downloaded_file: - self._apply_mp3_tags(downloaded_file, title) - - self.completed.emit(title) - except Exception as error: - self.failed.emit(str(error)) - - def _resolve_downloaded_file(self, info: dict) -> Optional[Path]: - candidates: list[Path] = [] - - requested_downloads = info.get("requested_downloads") if isinstance(info, dict) else None - if isinstance(requested_downloads, list): - for download_data in requested_downloads: - if not isinstance(download_data, dict): - continue - for key in ("filepath", "_filename"): - candidate_path = download_data.get(key) - if isinstance(candidate_path, str) and candidate_path: - candidates.append(Path(candidate_path)) - - for key in ("filepath", "_filename"): - candidate_path = info.get(key) if isinstance(info, dict) else None - if isinstance(candidate_path, str) and candidate_path: - candidates.append(Path(candidate_path)) - - for candidate in candidates: - if candidate.exists() and candidate.is_file() and candidate.suffix.lower() == ".mp3": - return candidate - mp3_candidate = candidate.with_suffix(".mp3") - if mp3_candidate.exists() and mp3_candidate.is_file(): - return mp3_candidate - - output_dir = Path(self.output_dir) - if output_dir.exists(): - mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda file_path: file_path.stat().st_mtime, reverse=True) - if mp3_files: - return mp3_files[0] - return None - - def _apply_mp3_tags(self, file_path: Path, fallback_title: str) -> None: - from app.core.metadata_manager import MetadataManager - metadata_manager = MetadataManager() - metadata_manager.apply_mp3_tags(file_path, self.track_metadata, fallback_title) - renamed_file = metadata_manager.rename_output_file(file_path, self.track_metadata.get("mp3_title") or fallback_title, self.track_metadata.get("artist", "")) - if renamed_file is not None: - self.progress_text.emit(renamed_file.stem) - - -class YoutubeSearchWorker(QThread): - finished_search = pyqtSignal(list) - failed_search = pyqtSignal(str) - - def __init__(self, query: str) -> None: - super().__init__() - self.query = query - - def run(self) -> None: - try: - results: list[dict] = [] - seen_urls: set[str] = set() - ydl_opts = {"quiet": True, "no_warnings": True, "extract_flat": True} - - with YoutubeDL(ydl_opts) as ydl: - video_result = ydl.extract_info(f"ytsearch10:{self.query}", download=False) - playlist_url = f"https://www.youtube.com/results?search_query={quote_plus(self.query)}&sp=EgIQAw%253D%253D" - playlist_result = ydl.extract_info(playlist_url, download=False) - - video_entries = video_result.get("entries", []) if isinstance(video_result, dict) else [] - for entry in video_entries: - item = self._normalize_video_entry(entry) - if item and item["url"] not in seen_urls: - seen_urls.add(item["url"]) - results.append(item) - - playlist_entries = playlist_result.get("entries", []) if isinstance(playlist_result, dict) else [] - for entry in playlist_entries: - item = self._normalize_playlist_entry(entry) - if item and item["url"] not in seen_urls: - seen_urls.add(item["url"]) - results.append(item) - - self.finished_search.emit(results) - except Exception: - self.failed_search.emit("search_failed") - - def _normalize_video_entry(self, entry: dict) -> Optional[dict]: - if not isinstance(entry, dict): - return None - - video_id = entry.get("id") - if not video_id: - return None - title = entry.get("title") or "Untitled" - channel = entry.get("uploader") or entry.get("channel") or "" - raw_duration = int(entry.get("duration")) - duration = self._format_duration(raw_duration) - thumbnail_url = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" - - return { - "kind": "video", - "title": title, - "url": f"https://www.youtube.com/watch?v={video_id}", - "channel": channel, - "duration": duration, - "item_count": "", - "thumbnail_url": thumbnail_url, - "thumbnail_bytes": b"", - } - - def _normalize_playlist_entry(self, entry: dict) -> Optional[dict]: - if not isinstance(entry, dict): - return None - - playlist_id = entry.get("id") - if not playlist_id: - return None - - title = entry.get("title") or "Untitled playlist" - channel = entry.get("uploader") or entry.get("channel") or "" - count = entry.get("playlist_count") or entry.get("n_entries") or 0 - thumbnails = entry.get("thumbnails") or [] - thumbnail_url = "" - if thumbnails and isinstance(thumbnails, list): - thumbnail_url = (thumbnails[-1] or {}).get("url", "") - if not thumbnail_url: - thumbnail_url = f"https://i.ytimg.com/vi/{playlist_id}/hqdefault.jpg" - - return { - "kind": "playlist", - "title": title, - "url": f"https://www.youtube.com/playlist?list={playlist_id}", - "channel": channel, - "duration": "", - "item_count": str(count) if count else "", - "thumbnail_url": thumbnail_url, - "thumbnail_bytes": b"", - } - - def _format_duration(self, seconds: Optional[int]) -> str: - if not isinstance(seconds, int) or seconds < 0: - return "" - minutes, sec = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours}:{minutes:02}:{sec:02}" - return f"{minutes}:{sec:02}" - - -class PreviewStreamWorker(QThread): - finished_preview = pyqtSignal(str, str) - failed_preview = pyqtSignal(str) - - def __init__(self, url: str, title: str) -> None: - super().__init__() - self.url = url - self.title = title - - def run(self) -> None: - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - "format": "best[height<=720][vcodec!=none][acodec!=none]/best[ext=mp4][height<=720]/best", - "noplaylist": True, - } - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=False) - stream_url = info.get("url") if isinstance(info, dict) else None - if stream_url: - self.finished_preview.emit(stream_url, self.title) - else: - self.failed_preview.emit("preview_unavailable") - except Exception: - self.failed_preview.emit("preview_unavailable") - - -class YoutubeManager(QObject): - def apply_album_cover(self, album_name: str, cover_path: str) -> None: - """Applique la cover à tous les morceaux de l'album (business logic)""" - for idx, track in enumerate(self.track_list): - if str(track.get("album", "")).strip() == album_name: - self.update_track_metadata( - idx, - str(track.get("mp3_title") or track.get("title", "")), - str(track.get("artist", "")), - album_name, - cover_path, - ) - started = pyqtSignal() - progress_text = pyqtSignal(str) - progress_value = pyqtSignal(int) - completed = pyqtSignal(str) - failed = pyqtSignal(str) - list_changed = pyqtSignal(list) - queue_finished = pyqtSignal() - downloading_state_changed = pyqtSignal(bool) - search_started = pyqtSignal() - search_results = pyqtSignal(list) - preview_started = pyqtSignal() - preview_ready = pyqtSignal(str, str) - preview_failed = pyqtSignal(str) - - def __init__(self, settings_manager: SettingsManager) -> None: - super().__init__() - self.settings_manager = settings_manager - self.download_worker: Optional[AudioDownloadWorker] = None - self.search_worker: Optional[YoutubeSearchWorker] = None - self.preview_worker: Optional[PreviewStreamWorker] = None - self.track_list: list[dict[str, str]] = [] - self.download_queue: list[dict[str, str]] = [] - self.queue_output_dir: str = "" - self.last_playlist_added_count: int = 0 - - def get_tracks(self) -> list[dict[str, str]]: - return list(self.track_list) - - def _build_track_payload(self, title: str, url: str, artist: str = "", album: str = "", cover_path: str = "") -> dict[str, str]: - clean_title = title.strip() - clean_url = url.strip() - return { - "title": clean_title, - "url": clean_url, - "mp3_title": clean_title, - "artist": artist.strip(), - "album": album.strip(), - "cover_path": cover_path.strip(), - } - - def add_track(self, url: str) -> bool: - clean_url = url.strip() - if not self.is_valid_youtube_url(clean_url): - self.failed.emit("invalid_youtube_url") - return False - - title = self._extract_title_from_url(clean_url) - if not title: - self.failed.emit("metadata_fetch_failed") - return False - - self.track_list.append(self._build_track_payload(title=title, url=clean_url)) - self.list_changed.emit(self.get_tracks()) - return True - - def add_search_result(self, search_result: dict) -> bool: - self.last_playlist_added_count = 0 - kind = search_result.get("kind", "video") - url = search_result.get("url", "") - title = search_result.get("title", "") - - if kind == "playlist": - tracks = self._extract_playlist_tracks(url) - if not tracks: - self.failed.emit("playlist_expand_failed") - return False - - self.track_list.extend(tracks) - self.last_playlist_added_count = len(tracks) - self.list_changed.emit(self.get_tracks()) - return True - - return self.add_track_with_title(url, title) - - def add_track_with_title(self, url: str, title: str) -> bool: - clean_url = url.strip() - clean_title = title.strip() - - if not self.is_valid_youtube_url(clean_url): - self.failed.emit("invalid_youtube_url") - return False - - if not clean_title: - self.failed.emit("metadata_fetch_failed") - return False - - self.track_list.append(self._build_track_payload(title=clean_title, url=clean_url)) - self.list_changed.emit(self.get_tracks()) - return True - - def update_track_metadata(self, index: int, mp3_title: str, artist: str, album: str, cover_path: str) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - - track = self.track_list[index] - track["mp3_title"] = mp3_title.strip() - track["artist"] = artist.strip() - track["album"] = album.strip() - track["cover_path"] = cover_path.strip() - self.list_changed.emit(self.get_tracks()) - return True - - def search_tracks(self, query: str) -> bool: - clean_query = query.strip() - if len(clean_query) < 2: - self.failed.emit("invalid_search_query") - return False - - if self.search_worker and self.search_worker.isRunning(): - self.failed.emit("search_in_progress") - return False - - self.search_worker = YoutubeSearchWorker(clean_query) - self.search_worker.finished_search.connect(self.search_results.emit) - self.search_worker.failed_search.connect(self.failed.emit) - - self.search_started.emit() - self.search_worker.start() - return True - - def start_preview(self, search_result: dict) -> bool: - url = search_result.get("url", "") - title = search_result.get("title", "") - kind = search_result.get("kind", "video") - - if kind != "video": - self.preview_failed.emit("preview_playlist_unavailable") - return False - - if not self.is_valid_youtube_url(url): - self.preview_failed.emit("invalid_youtube_url") - return False - - if self.preview_worker and self.preview_worker.isRunning(): - self.preview_failed.emit("preview_in_progress") - return False - - self.preview_worker = PreviewStreamWorker(url, title) - self.preview_worker.finished_preview.connect(self.preview_ready.emit) - self.preview_worker.failed_preview.connect(self.preview_failed.emit) - self.preview_started.emit() - self.preview_worker.start() - return True - - def remove_track(self, index: int) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - - self.track_list.pop(index) - self.list_changed.emit(self.get_tracks()) - return True - - def clear_tracks(self) -> None: - self.track_list.clear() - self.list_changed.emit(self.get_tracks()) - - def download_track_at(self, index: int, output_dir: str) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - return self.download_audio(self.track_list[index]["url"], output_dir, self.track_list[index]) - - def download_all_tracks(self, output_dir: str) -> bool: - if not self.track_list: - self.failed.emit("empty_download_list") - return False - - if not output_dir or not Path(output_dir).exists(): - self.failed.emit("invalid_output_folder") - return False - - if self.download_worker and self.download_worker.isRunning(): - self.failed.emit("download_in_progress") - return False - - self.download_queue = list(self.track_list) - self.queue_output_dir = output_dir - self._download_next_in_queue() - return True - - def download_audio(self, url: str, output_dir: str, track_metadata: Optional[dict] = None) -> bool: - if not self.is_valid_youtube_url(url): - self.failed.emit("invalid_youtube_url") - return False - - if not output_dir or not Path(output_dir).exists(): - self.failed.emit("invalid_output_folder") - return False - - if self.download_worker and self.download_worker.isRunning(): - self.failed.emit("download_in_progress") - return False - - audio_format = self.settings_manager.get_audio_format() - audio_quality = self.settings_manager.get_audio_quality() - ffmpeg_location = self.resolve_ffmpeg_location() - - self.download_worker = AudioDownloadWorker(url, output_dir, audio_format, audio_quality, ffmpeg_location, track_metadata) - self.download_worker.progress_text.connect(self.progress_text.emit) - self.download_worker.progress_value.connect(self.progress_value.emit) - self.download_worker.completed.connect(self._on_worker_completed) - self.download_worker.failed.connect(self._on_worker_failed) - - self.started.emit() - self.downloading_state_changed.emit(True) - self.download_worker.start() - return True - - def _download_next_in_queue(self) -> None: - if not self.download_queue: - self.queue_output_dir = "" - self.downloading_state_changed.emit(False) - self.queue_finished.emit() - return - - next_track = self.download_queue.pop(0) - self.download_audio(next_track["url"], self.queue_output_dir, next_track) - - def _on_worker_completed(self, title: str) -> None: - self.completed.emit(title) - - if self.download_queue: - self._download_next_in_queue() - return - - self.downloading_state_changed.emit(False) - - def _on_worker_failed(self, error_text: str) -> None: - self.download_queue = [] - self.queue_output_dir = "" - self.downloading_state_changed.emit(False) - if "ffmpeg" in error_text.lower(): - self.failed.emit("ffmpeg_required") - else: - self.failed.emit("audio_download_error") - - def is_valid_youtube_url(self, url: str) -> bool: - if not url.startswith(("http://", "https://")): - return False - return "youtube.com" in url or "youtu.be" in url - - def _extract_title_from_url(self, url: str) -> Optional[str]: - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - } - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info.get("title") if isinstance(info, dict) else None - except Exception: - return None - - def _extract_playlist_tracks(self, playlist_url: str) -> list[dict[str, str]]: - # Try with extract_flat first, fallback to full extraction if it fails - for flat in (True, False): - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - } - if flat: - ydl_opts["extract_flat"] = True - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(playlist_url, download=False) - - entries = info.get("entries", []) if isinstance(info, dict) else [] - tracks: list[dict[str, str]] = [] - for entry in entries: - if not isinstance(entry, dict): - continue - - video_id = entry.get("id") - title = entry.get("title") or "Untitled" - url = entry.get("url") - - if video_id: - video_url = f"https://www.youtube.com/watch?v={video_id}" - elif isinstance(url, str) and url.startswith("http"): - video_url = url - elif isinstance(url, str): - video_url = f"https://www.youtube.com/watch?v={url}" - else: - continue - - tracks.append(self._build_track_payload(title=title, url=video_url)) - - if tracks: - return tracks - except Exception as e: - print(f"[yt-dlp playlist extraction error] flat={flat}: {e}") - return [] - - def resolve_ffmpeg_location(self) -> Optional[str]: - candidates = [ - Path("others") / "ffmpeg.exe", - Path("data") / "others" / "ffmpeg.exe", - Path(paths.resource_path("others/ffmpeg.exe")), - Path(paths.resource_path("data/others/ffmpeg.exe")), - ] - - for candidate in candidates: - if candidate.exists(): - return str(candidate.parent) - - return None \ No newline at end of file diff --git a/app/ui/layouts/flow_layout.py b/app/ui/layouts/flow_layout.py new file mode 100644 index 0000000..04613bd --- /dev/null +++ b/app/ui/layouts/flow_layout.py @@ -0,0 +1,279 @@ +from enum import Enum + +from PyQt6.QtCore import Qt, QRect, QSize, QPoint, QMargins +from PyQt6.QtWidgets import QLayout + + +class JustifyMode(Enum): + NONE = 0 + SPACE_BETWEEN = 1 + SPACE_AROUND = 2 + SPACE_EVENLY = 3 + CENTER = 4 + + +class LastRowMode(Enum): + LEFT = 0 + JUSTIFY = 1 + MATCH_PREVIOUS = 2 + CENTER = 3 + + +class FlowLayout(QLayout): + def __init__( + self, + parent=None, + justify_mode=JustifyMode.SPACE_BETWEEN, + last_row_mode=LastRowMode.LEFT, + ): + super().__init__(parent) + + if parent is not None: + self.setContentsMargins(QMargins(0, 0, 0, 0)) + + self._items = [] + self.justify_mode = justify_mode + self.last_row_mode = last_row_mode + + # ------------------------------------------------------------------ + # QLayout API + # ------------------------------------------------------------------ + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self._items: + size = size.expandedTo(item.minimumSize()) + + margins = self.contentsMargins() + + size += QSize( + margins.left() + margins.right(), + margins.top() + margins.bottom(), + ) + + return size + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _build_rows(self, available_width): + rows = [] + + current_row = [] + current_width = 0 + + base_spacing = max(0, self.spacing()) + + for item in self._items: + width = item.sizeHint().width() + + if not current_row: + current_row.append(item) + current_width = width + continue + + projected = current_width + base_spacing + width + + if projected > available_width: + rows.append(current_row) + + current_row = [item] + current_width = width + else: + current_row.append(item) + current_width = projected + + if current_row: + rows.append(current_row) + + return rows + + def _layout_row( + self, + row, + rect, + y, + test_only, + justify_mode, + spacing_override=None, + ): + widths = [item.sizeHint().width() for item in row] + heights = [item.sizeHint().height() for item in row] + + total_width = sum(widths) + row_height = max(heights) + + count = len(row) + gaps = max(0, count - 1) + + available_width = rect.width() + + x = rect.x() + + used_spacing = self.spacing() + + # -------------------------------------------------------------- + # Explicit spacing override (MATCH_PREVIOUS) + # -------------------------------------------------------------- + + if spacing_override is not None: + used_spacing = spacing_override + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # LEFT / NONE + # -------------------------------------------------------------- + + if justify_mode == JustifyMode.NONE: + used_spacing = max(0, self.spacing()) + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # CENTER + # -------------------------------------------------------------- + + if justify_mode == JustifyMode.CENTER: + used_spacing = max(0, self.spacing()) + + content_width = total_width + + if count > 1: + content_width += gaps * used_spacing + + x += max(0, (available_width - content_width) / 2) + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # SPACE_* + # -------------------------------------------------------------- + + free_space = max(0, available_width - total_width) + + if justify_mode == JustifyMode.SPACE_BETWEEN: + if gaps > 0: + used_spacing = free_space / gaps + else: + used_spacing = 0 + + elif justify_mode == JustifyMode.SPACE_AROUND: + used_spacing = free_space / count if count else 0 + x += used_spacing / 2 + + elif justify_mode == JustifyMode.SPACE_EVENLY: + used_spacing = free_space / (count + 1) if count else 0 + x += used_spacing + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + def _do_layout(self, rect, test_only): + rows = self._build_rows(rect.width()) + + y = rect.y() + + vertical_spacing = max(0, self.spacing()) + + previous_spacing = None + + for row_index, row in enumerate(rows): + is_last_row = row_index == len(rows) - 1 + + spacing_override = None + row_mode = self.justify_mode + + if is_last_row: + + if self.last_row_mode == LastRowMode.LEFT: + row_mode = JustifyMode.NONE + + elif self.last_row_mode == LastRowMode.CENTER: + row_mode = JustifyMode.CENTER + + elif self.last_row_mode == LastRowMode.JUSTIFY: + row_mode = self.justify_mode + + elif ( + self.last_row_mode == LastRowMode.MATCH_PREVIOUS + and previous_spacing is not None + ): + spacing_override = previous_spacing + row_mode = JustifyMode.NONE + + row_height, used_spacing = self._layout_row( + row=row, + rect=rect, + y=y, + test_only=test_only, + justify_mode=row_mode, + spacing_override=spacing_override, + ) + + previous_spacing = used_spacing + + y += row_height + vertical_spacing + + if rows: + y -= vertical_spacing + + return y - rect.y() + \ No newline at end of file diff --git a/app/ui/main_window.py b/app/ui/main_window.py index a10dd08..d85923c 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel +from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QWidget from PyQt6.QtGui import QResizeEvent, QCloseEvent from PyQt6.QtCore import QSize, QEvent from app.core.main_manager import MainManager, NotificationType @@ -6,8 +6,7 @@ from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition from app.ui.windows.settings_window import SettingsWindow from app.ui.windows.suggestion_window import SuggestionWindow from app.ui.windows.activation_window import ActivationWindow -from app.ui.windows.add_music_window import AddMusicWindow -from app.ui.windows.download_list_window import DownloadListWindow +from app.ui.windows.editing_window import EditingWindow import app.utils.paths as paths, shutil from typing import Optional @@ -204,6 +203,9 @@ class MainWindow(QMainWindow): for widget_type in widget_types: for child in widget.findChildren(widget_type): + + if child.accessibleName() == "ignore": + continue # Ignorer les widgets marqués pour être ignorés widget_id = id(child) # Récupérer la taille de base @@ -251,22 +253,20 @@ class MainWindow(QMainWindow): self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, BorderSide.BOTTOM, TabSide.TOP) - self.add_music_window = AddMusicWindow(self) - self.add_music_tab_index = self.side_menu.add_widget(self.add_music_window, self.language_manager.get_text("tab_add_music"), None, position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.editing_window = EditingWindow(self) + self.side_menu.add_widget(self.editing_window, self.language_manager.get_text("tab_editing"), paths.get_asset_svg_path("edit"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + - self.download_list_window = DownloadListWindow(self) - self.download_list_tab_index = self.side_menu.add_widget(self.download_list_window, self.language_manager.get_text("tab_download_list"), None, position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) - self.suggestion_window = SuggestionWindow(self) - self.suggestion_tab_index = self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) self.settings_window = SettingsWindow(self) - self.settings_tab_index = self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) # Ajouter la tab d'activation uniquement si le système de licence est activé if self.settings_manager.get_config("enable_licensing"): self.activation_window = ActivationWindow(self) - self.licensing_tab_index = self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) self.setCentralWidget(self.side_menu) @@ -279,9 +279,7 @@ class MainWindow(QMainWindow): def update_language(self) -> None: # Mettre à jour les textes des onglets - self.side_menu.update_button_text(self.suggestion_tab_index, self.language_manager.get_text("tab_suggestions")) - self.side_menu.update_button_text(self.add_music_tab_index, self.language_manager.get_text("tab_add_music")) - self.side_menu.update_button_text(self.download_list_tab_index, self.language_manager.get_text("tab_download_list")) - self.side_menu.update_button_text(self.settings_tab_index, self.language_manager.get_text("tab_settings")) + self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions")) + self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) if self.settings_manager.get_config("enable_licensing"): - self.side_menu.update_button_text(self.licensing_tab_index, self.language_manager.get_text("tab_licensing")) \ No newline at end of file + self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing")) \ No newline at end of file diff --git a/app/ui/widgets/grid_element.py b/app/ui/widgets/grid_element.py new file mode 100644 index 0000000..83f9ddb --- /dev/null +++ b/app/ui/widgets/grid_element.py @@ -0,0 +1,48 @@ +from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout +from PyQt6.QtGui import QPixmap + + +class GridElement(QWidget): + def __init__(self, parent: QWidget = None, name: str = None, image: str = "", linked_object: object = None) -> None: + super().__init__(parent) + self.image = QPixmap(image) + self.image_path = image + self.name = name + self.linked_object = linked_object + self.setup_ui() + + def setup_ui(self) -> None: + layout = QVBoxLayout(self) + self.image_label = QLabel(self) + self.image_label.setPixmap(self.image) + self.name_label = QLabel(self.name, self) + self.name_label.setAccessibleName("ignore") + self.name_label.setObjectName("text_label") + layout.addWidget(self.image_label) + layout.addWidget(self.name_label) + self.set_image_size(70, 70) # Set default image size + def set_image(self, image: str) -> None: + self.image = QPixmap(image) + self.image_path = image + + def set_name(self, name: str) -> None: + self.name = name + + def set_linked_object(self, linked_object: object) -> None: + self.linked_object = linked_object + + def set_image_size(self, width: int, height: int) -> None: + self.image = self.image.scaled(width, height) + self.image_label.setPixmap(self.image) + + def get_image(self) -> QPixmap: + return self.image + + def get_image_path(self) -> str: + return self.image_path + + def get_name(self) -> str: + return self.name + + def get_linked_object(self) -> object: + return self.linked_object \ No newline at end of file diff --git a/app/ui/widgets/grid_widget.py b/app/ui/widgets/grid_widget.py new file mode 100644 index 0000000..ad06fcd --- /dev/null +++ b/app/ui/widgets/grid_widget.py @@ -0,0 +1,30 @@ +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt +from app.ui.layouts.flow_layout import FlowLayout, JustifyMode, LastRowMode +from app.ui.widgets.grid_element import GridElement +class GridWidget(QWidget): + def __init__(self, elements: list[GridElement] = [], parent=None) -> None: + super().__init__(parent) + self.elements = elements + + self.setup_ui() + + def setup_ui(self) -> None: + layout = FlowLayout(self, justify_mode=JustifyMode.SPACE_BETWEEN, last_row_mode=LastRowMode.MATCH_PREVIOUS) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + if self.elements: + for element in self.elements: + layout.addWidget(element) + else: + for _ in range(5000): # Add 58 placeholder elements + layout.addWidget(GridElement(self, name="Placeholder", image="data/assets/icon.png")) + + + def add_element(self, element: GridElement) -> None: + layout: FlowLayout = self.layout() + layout.addWidget(element) + + def remove_element(self, element: GridElement) -> None: + layout: FlowLayout = self.layout() + layout.removeWidget(element) + element.setParent(None) diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py index 1ad226b..6e7b60c 100644 --- a/app/ui/widgets/tabs_widget.py +++ b/app/ui/widgets/tabs_widget.py @@ -369,7 +369,7 @@ class TabsWidget(QWidget): # Store reference for resize updates self._square_buttons.append(button) - def eventFilter(self, obj, event): + def eventFilter(self, obj : QPushButton, event): """Handle hover events for buttons""" if obj in self.buttons: if event.type() == event.Type.Enter: @@ -728,9 +728,9 @@ class TabsWidget(QWidget): continue lname = tag.split('}')[-1] # local name if lname in ("svg", "g", "path", "rect", "circle", "ellipse", "polygon", "line", "polyline"): - if 'fill' in el.attrib: + if 'fill' in el.attrib and el.attrib['fill'] != 'none': el.attrib['fill'] = color - if 'stroke' in el.attrib: + if 'stroke' in el.attrib and el.attrib['stroke'] != 'none': el.attrib['stroke'] = color style = el.attrib.get('style') diff --git a/app/ui/windows/add_music_window.py b/app/ui/windows/add_music_window.py deleted file mode 100644 index 5ae1e07..0000000 --- a/app/ui/windows/add_music_window.py +++ /dev/null @@ -1,400 +0,0 @@ -from typing import Optional - -from PyQt6.QtCore import Qt, QUrl, QSize, pyqtSignal -from PyQt6.QtGui import QIcon, QPixmap -from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest -from PyQt6.QtWidgets import ( - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from app.core.main_manager import MainManager, NotificationType -from app.ui.widgets.loading_bar import LoadingBar - - -class AddMusicWindow(QWidget): - from PyQt6.QtCore import QThread, pyqtSignal - from app.ui.widgets.loading_bar import LoadingBar - - class PlaylistAddThread(QThread): - progress = pyqtSignal(int, int) - finished = pyqtSignal(bool, int) - failed = pyqtSignal(str) - def __init__(self, download_manager, url, parent=None): - super().__init__(parent) - self.download_manager = download_manager - self.url = url - def run(self): - try: - tracks = self.download_manager._extract_playlist_tracks(self.url) - total = len(tracks) - if not tracks: - self.failed.emit("playlist_expand_failed") - self.finished.emit(False, 0) - return - for i, track in enumerate(tracks, 1): - self.download_manager.track_list.append(track) - self.progress.emit(i, total) - self.download_manager.last_playlist_added_count = total - self.download_manager.list_changed.emit(self.download_manager.get_tracks()) - self.finished.emit(True, total) - except Exception as e: - self.failed.emit(str(e)) - self.finished.emit(False, 0) - RESULTS_VIEW_INDEX = 0 - PLAYER_VIEW_INDEX = 1 - - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.main_manager = MainManager.get_instance() - self.language_manager = self.main_manager.get_language_manager() - self.alert_manager = self.main_manager.get_alert_manager() - self.youtube_manager = self.main_manager.get_youtube_manager() - self.metadata_manager = self.main_manager.get_metadata_manager() - - self.observer_manager = self.main_manager.get_observer_manager() - self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - - self.youtube_manager.failed.connect(self.on_manager_error) - self.youtube_manager.search_started.connect(self.on_search_started) - self.youtube_manager.search_results.connect(self.on_search_results) - self.youtube_manager.preview_ready.connect(self.on_preview_ready) - self.youtube_manager.preview_failed.connect(self.on_preview_failed) - - self.thumbnail_manager = QNetworkAccessManager(self) - self.thumbnail_replies: dict[QNetworkReply, QListWidgetItem] = {} - self.current_video: Optional[dict] = None - - self.audio_output = QAudioOutput(self) - self.video_player = QMediaPlayer(self) - self.video_player.setAudioOutput(self.audio_output) - - self.player_widget = None - self.setup_ui() - # Always initialize search_results and related widgets - self.content_stack = QStackedWidget(self) - self.layout().addWidget(self.content_stack, 1) - - self.results_view = QWidget(self) - results_layout = QVBoxLayout(self.results_view) - results_layout.setContentsMargins(0, 0, 0, 0) - results_layout.setSpacing(0) - - self.search_results = QListWidget(self.results_view) - self.search_results.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.search_results.setIconSize(QSize(120, 68)) - self.search_results.setSpacing(6) - self.search_results.itemClicked.connect(self.on_result_selected) - results_layout.addWidget(self.search_results) - - self.player_view = QWidget(self) - player_layout = QVBoxLayout(self.player_view) - player_layout.setContentsMargins(0, 0, 0, 0) - player_layout.setSpacing(10) - - player_top_row = QHBoxLayout() - self.back_to_results_button = QPushButton(self.language_manager.get_text("back_to_results"), self.player_view) - self.back_to_results_button.clicked.connect(self.go_back_to_results) - player_top_row.addWidget(self.back_to_results_button) - player_top_row.addStretch(1) - player_layout.addLayout(player_top_row) - - self.player_widget = self._create_player_widget() - player_layout.addWidget(self.player_widget, 1) - - actions_row = QHBoxLayout() - self.add_selected_button = QPushButton(self.language_manager.get_text("add_selected_result"), self) - self.add_selected_button.clicked.connect(self.add_selected_result) - self.add_selected_button.setEnabled(False) - actions_row.addStretch(1) - actions_row.addWidget(self.add_selected_button) - player_layout.addLayout(actions_row) - - self.content_stack.addWidget(self.results_view) - self.content_stack.addWidget(self.player_view) - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - if self.player_widget and isinstance(self.player_widget, QVideoWidget): - self.video_player.setVideoOutput(self.player_widget) - - def setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - layout.setSpacing(16) - layout.setContentsMargins(20, 20, 20, 20) - - search_row = QHBoxLayout() - self.search_input = QLineEdit(self) - self.search_input.setPlaceholderText(self.language_manager.get_text("search_placeholder")) - search_row.addWidget(self.search_input, 1) - - self.search_button = QPushButton(self.language_manager.get_text("search_youtube"), self) - self.search_button.clicked.connect(self.search_tracks) - search_row.addWidget(self.search_button) - - self.link_input = QLineEdit(self) - self.link_input.setPlaceholderText(self.language_manager.get_text("add_link_placeholder")) - search_row.addWidget(self.link_input, 1) - self.add_link_button = QPushButton(self.language_manager.get_text("add_link_button"), self) - self.add_link_button.clicked.connect(self.add_link) - search_row.addWidget(self.add_link_button) - layout.addLayout(search_row) - self.setLayout(layout) - - def add_link(self) -> None: - url = self.link_input.text().strip() - if not url: - self.alert_manager.show_error("invalid_link", self) - return - if "list=" in url: - self._show_loading_bar(self.language_manager.get_text("downloading_audio")) - # Always assign as self.playlist_thread and parent to self - self.playlist_thread = self.PlaylistAddThread(self.youtube_manager, url, parent=self) - self.playlist_thread.progress.connect(self.on_playlist_progress) - self.playlist_thread.finished.connect(self.on_playlist_finished) - self.playlist_thread.failed.connect(self.on_playlist_failed) - self.playlist_thread.start() - else: - if not self.youtube_manager.add_track(url): - self.alert_manager.show_error("invalid_link", self) - - def _show_loading_bar(self, label: str): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.deleteLater() - self.loading_bar = LoadingBar(label, self) - self.layout().addWidget(self.loading_bar) - self.loading_bar.set_label(label) - self.loading_bar.set_progress(0) - - def on_playlist_progress(self, current, total): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(f"Ajout {current}/{total} musiques...") - self.loading_bar.set_progress(int(current / total * 100) if total else 0) - - def on_playlist_finished(self, success, count): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text("playlist_added_count").replace("{count}", str(count))) - self.loading_bar.set_progress(100) - self.loading_bar.deleteLater() - self.loading_bar = None - self.alert_manager.show_info(self.language_manager.get_text("playlist_added_count").replace("{count}", str(count)), self) - - def on_playlist_failed(self, error_key): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text(error_key)) - self.loading_bar.set_progress(0) - self.loading_bar.deleteLater() - self.loading_bar = None - self.alert_manager.show_error(error_key, self) - - self.content_stack = QStackedWidget(self) - self.layout().addWidget(self.content_stack, 1) - - self.results_view = QWidget(self) - results_layout = QVBoxLayout(self.results_view) - results_layout.setContentsMargins(0, 0, 0, 0) - results_layout.setSpacing(0) - - self.search_results = QListWidget(self.results_view) - self.search_results.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.search_results.setIconSize(QSize(120, 68)) - self.search_results.setSpacing(6) - self.search_results.itemClicked.connect(self.on_result_selected) - results_layout.addWidget(self.search_results) - - self.player_view = QWidget(self) - player_layout = QVBoxLayout(self.player_view) - player_layout.setContentsMargins(0, 0, 0, 0) - player_layout.setSpacing(10) - - player_top_row = QHBoxLayout() - self.back_to_results_button = QPushButton(self.language_manager.get_text("back_to_results"), self.player_view) - self.back_to_results_button.clicked.connect(self.go_back_to_results) - player_top_row.addWidget(self.back_to_results_button) - player_top_row.addStretch(1) - player_layout.addLayout(player_top_row) - - self.player_widget = self._create_player_widget() - player_layout.addWidget(self.player_widget, 1) - - actions_row = QHBoxLayout() - self.add_selected_button = QPushButton(self.language_manager.get_text("add_selected_result"), self) - self.add_selected_button.clicked.connect(self.add_selected_result) - self.add_selected_button.setEnabled(False) - actions_row.addStretch(1) - actions_row.addWidget(self.add_selected_button) - player_layout.addLayout(actions_row) - - self.content_stack.addWidget(self.results_view) - self.content_stack.addWidget(self.player_view) - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - def search_tracks(self) -> None: - self.youtube_manager.search_tracks(self.search_input.text()) - - def on_search_started(self) -> None: - self.search_button.setEnabled(False) - self.search_button.setText(self.language_manager.get_text("searching")) - - def on_search_results(self, tracks: list) -> None: - self._clear_thumbnail_replies() - self.search_results.clear() - self.current_video = None - self.add_selected_button.setEnabled(False) - self.video_player.stop() - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - video_tracks = [track for track in tracks if track.get("kind", "video") == "video"] - playlist_tracks = [track for track in tracks if track.get("kind", "") == "playlist"] - - for track in video_tracks: - title = track.get("title", "Untitled") - channel = track.get("channel", "") - duration = track.get("duration", "") - subtitle = f"{self.language_manager.get_text('result_video')} • {duration}" - if channel: - subtitle = f"{subtitle} • {channel}" - item = QListWidgetItem(f"{title}\n{subtitle}") - item.setData(Qt.ItemDataRole.UserRole, track) - self.search_results.addItem(item) - thumbnail_url = track.get("thumbnail_url", "") - if thumbnail_url: - self._request_thumbnail(thumbnail_url, item) - - for track in playlist_tracks: - title = track.get("title", "Untitled playlist") - channel = track.get("channel", "") - item_count = track.get("item_count", "") - subtitle = f"{self.language_manager.get_text('result_playlist')}" - if item_count: - subtitle = f"{subtitle} • {item_count} tracks" - if channel: - subtitle = f"{subtitle} • {channel}" - item = QListWidgetItem(f"{title}\n{subtitle}") - item.setData(Qt.ItemDataRole.UserRole, track) - self.search_results.addItem(item) - thumbnail_url = track.get("thumbnail_url", "") - if thumbnail_url: - self._request_thumbnail(thumbnail_url, item) - - total_results = len(video_tracks) + len(playlist_tracks) - if not total_results: - self.alert_manager.show_error(self.language_manager.get_text("no_search_results"), self) - - self.search_button.setEnabled(True) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - - def add_selected_result(self) -> None: - row = self.search_results.currentRow() - if row < 0: - self.alert_manager.show_error("no_search_result_selected", self) - return - - item_data = self.search_results.item(row).data(Qt.ItemDataRole.UserRole) - if not isinstance(item_data, dict): - return - - if self.youtube_manager.add_search_result(item_data): - self.alert_manager.show_success("track_added", self) - - def on_result_selected(self, current_item: Optional[QListWidgetItem]) -> None: - if current_item is None: - self.current_video = None - self.add_selected_button.setEnabled(False) - return - - item_data = current_item.data(Qt.ItemDataRole.UserRole) - if not isinstance(item_data, dict): - self.current_video = None - self.add_selected_button.setEnabled(False) - return - - self.current_video = item_data - self.add_selected_button.setEnabled(True) - self._load_video_in_player(item_data) - - def go_back_to_results(self) -> None: - self.video_player.stop() - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - def _load_video_in_player(self, track: dict) -> None: - self.youtube_manager.start_preview(track) - - def _create_player_widget(self) -> QWidget: - video_widget = QVideoWidget(self) - video_widget.setMinimumHeight(320) - return video_widget - - def on_preview_ready(self, stream_url: str, title: str) -> None: - self.video_player.setSource(QUrl(stream_url)) - self.video_player.play() - self.content_stack.setCurrentIndex(self.PLAYER_VIEW_INDEX) - - def on_preview_failed(self, error_key: str) -> None: - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - self.alert_manager.show_error(error_key, self) - - def _request_thumbnail(self, url: str, item: QListWidgetItem) -> None: - request = QNetworkRequest(QUrl(url)) - reply = self.thumbnail_manager.get(request) - self.thumbnail_replies[reply] = item - reply.finished.connect(lambda r=reply: self._on_thumbnail_finished(r)) - - def _on_thumbnail_finished(self, reply: QNetworkReply) -> None: - item = self.thumbnail_replies.pop(reply, None) - if item is None: - reply.deleteLater() - return - - if reply.error() == QNetworkReply.NetworkError.NoError: - image_data = reply.readAll().data() - if image_data: - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - icon = QIcon(pixmap.scaled(120, 68, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - item.setIcon(icon) - - reply.deleteLater() - - def _clear_thumbnail_replies(self) -> None: - for reply in list(self.thumbnail_replies.keys()): - reply.abort() - reply.deleteLater() - self.thumbnail_replies.clear() - - - def on_manager_error(self, error_key: str) -> None: - handled_errors = { - "invalid_youtube_url", - "metadata_fetch_failed", - "invalid_search_query", - "search_in_progress", - "search_failed", - "playlist_expand_failed", - } - if error_key not in handled_errors: - return - - self.search_button.setEnabled(True) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - self.alert_manager.show_error(error_key, self) - - def update_language(self) -> None: - self.search_input.setPlaceholderText(self.language_manager.get_text("search_placeholder")) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - self.back_to_results_button.setText(self.language_manager.get_text("back_to_results")) - self.add_selected_button.setText(self.language_manager.get_text("add_selected_result")) - - def closeEvent(self, event) -> None: - self.video_player.stop() - self._clear_thumbnail_replies() - super().closeEvent(event) \ No newline at end of file diff --git a/app/ui/windows/download_list_window.py b/app/ui/windows/download_list_window.py deleted file mode 100644 index 663a768..0000000 --- a/app/ui/windows/download_list_window.py +++ /dev/null @@ -1,563 +0,0 @@ -from pathlib import Path -from typing import Optional - -from PyQt6.QtCore import QSize, Qt, pyqtSignal -from PyQt6.QtGui import QDrag, QIcon -from PyQt6.QtWidgets import ( - QAbstractItemView, - QComboBox, - QDialog, - QDialogButtonBox, - QFileDialog, - QFormLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QSizePolicy, - QStyle, - QVBoxLayout, - QWidget, -) -from app.ui.widgets.loading_bar import LoadingBar - -from app.core.main_manager import MainManager, NotificationType - - -class AlbumDialog(QDialog): - def __init__(self, language_manager, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.language_manager = language_manager - self.setWindowTitle(self.language_manager.get_text("create_album")) - - layout = QVBoxLayout(self) - form = QFormLayout() - - self.name_input = QLineEdit(self) - form.addRow(self.language_manager.get_text("album_name_label"), self.name_input) - - self.artist_input = QLineEdit(self) - form.addRow(self.language_manager.get_text("artist_label"), self.artist_input) - - cover_row = QHBoxLayout() - self.cover_input = QLineEdit(self) - self.cover_input.setReadOnly(True) - cover_row.addWidget(self.cover_input, 1) - - self.browse_button = QPushButton(self.language_manager.get_text("choose_cover"), self) - self.browse_button.clicked.connect(self._choose_cover) - cover_row.addWidget(self.browse_button) - form.addRow(self.language_manager.get_text("cover_label"), cover_row) - - layout.addLayout(form) - - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - def _choose_cover(self) -> None: - cover_file, _ = QFileDialog.getOpenFileName( - self, - self.language_manager.get_text("choose_cover"), - str(Path.home()), - "Images (*.png *.jpg *.jpeg *.webp)", - ) - if cover_file: - self.cover_input.setText(cover_file) - - -class TrackEditorDialog(QDialog): - def __init__(self, language_manager, track: dict, album_names: list[str], parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.language_manager = language_manager - self.setWindowTitle(self.language_manager.get_text("edit_track")) - - layout = QVBoxLayout(self) - form = QFormLayout() - - self.title_input = QLineEdit(str(track.get("mp3_title") or track.get("title", "")), self) - form.addRow(self.language_manager.get_text("mp3_title_label"), self.title_input) - - self.artist_input = QLineEdit(str(track.get("artist", "")), self) - form.addRow(self.language_manager.get_text("artist_label"), self.artist_input) - - self.album_combo = QComboBox(self) - self.album_combo.addItem(self.language_manager.get_text("without_album"), "") - for album_name in album_names: - self.album_combo.addItem(album_name, album_name) - - current_album = str(track.get("album", "")) - album_idx = self.album_combo.findData(current_album) - self.album_combo.setCurrentIndex(album_idx if album_idx >= 0 else 0) - form.addRow(self.language_manager.get_text("assign_album_label"), self.album_combo) - - layout.addLayout(form) - - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - -class ExplorerGridWidget(QListWidget): - trackMoved = pyqtSignal(int, str) - - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self._dragged_track_index: int = -1 - self.setViewMode(QListWidget.ViewMode.IconMode) - self.setFlow(QListWidget.Flow.LeftToRight) - self.setWrapping(True) - self.setResizeMode(QListWidget.ResizeMode.Adjust) - self.setMovement(QListWidget.Movement.Static) - self.setUniformItemSizes(False) - self.setIconSize(QSize(128, 128)) - self.setGridSize(QSize(180, 160)) - self.setAcceptDrops(True) - self.viewport().setAcceptDrops(True) - - def _item_type(self, item: Optional[QListWidgetItem]) -> str: - if item is None: - return "" - value = item.data(Qt.ItemDataRole.UserRole + 1) - return value if isinstance(value, str) else "" - - def startDrag(self, supportedActions) -> None: - item = self.currentItem() - if self._item_type(item) != "track": - self._dragged_track_index = -1 - return - - track_index = item.data(Qt.ItemDataRole.UserRole + 2) if item else None - self._dragged_track_index = track_index if isinstance(track_index, int) else -1 - if self._dragged_track_index < 0: - return - - mime_data = self.mimeData([item]) - if mime_data is None: - return - mime_data.setText(str(self._dragged_track_index)) - - drag = QDrag(self) - drag.setMimeData(mime_data) - if not item.icon().isNull(): - drag.setPixmap(item.icon().pixmap(self.iconSize())) - drag.exec(Qt.DropAction.MoveAction) - - def _drag_track_index_from_event(self, event) -> int: - mime = event.mimeData() - track_index_text = mime.text().strip() if mime and mime.hasText() else "" - return int(track_index_text) if track_index_text.isdigit() else -1 - - def dragEnterEvent(self, event) -> None: - if self._drag_track_index_from_event(event) >= 0: - event.acceptProposedAction() - return - event.ignore() - - def dragMoveEvent(self, event) -> None: - if self._drag_track_index_from_event(event) < 0: - event.ignore() - return - - target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() - target_item = self.itemAt(target_pos) - if target_item is None or self._item_type(target_item) != "album": - event.ignore() - return - - event.acceptProposedAction() - - def dropEvent(self, event) -> None: - track_index = self._drag_track_index_from_event(event) - - if track_index < 0: - event.ignore() - return - - target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() - target_item = self.itemAt(target_pos) - if target_item is None: - event.ignore() - return - - if self._item_type(target_item) != "album": - event.ignore() - return - - album_name_data = target_item.data(Qt.ItemDataRole.UserRole + 3) - album_name = album_name_data if isinstance(album_name_data, str) else "" - - self.trackMoved.emit(track_index, album_name) - event.acceptProposedAction() - - -class DownloadListWindow(QWidget): - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.main_manager = MainManager.get_instance() - self.language_manager = self.main_manager.get_language_manager() - self.alert_manager = self.main_manager.get_alert_manager() - self.youtube_manager = self.main_manager.get_youtube_manager() - self.metadata_manager = self.main_manager.get_metadata_manager() - - self.observer_manager = self.main_manager.get_observer_manager() - self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - - self.youtube_manager.list_changed.connect(self.refresh_list) - self.youtube_manager.started.connect(self.on_download_started) - self.youtube_manager.progress_text.connect(self.on_progress_text) - self.youtube_manager.progress_value.connect(self.on_progress_value) - self.youtube_manager.completed.connect(self.on_download_completed) - self.youtube_manager.failed.connect(self.on_download_failed) - self.youtube_manager.queue_finished.connect(self.on_queue_finished) - self.youtube_manager.downloading_state_changed.connect(self.set_downloading_state) - - self.track_data: list[dict] = [] - self.album_profiles: dict[str, dict[str, str]] = {} - self.current_album: str = "" - - self.setup_ui() - self.refresh_list(self.youtube_manager.get_tracks()) - - def setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - layout.setSpacing(12) - layout.setContentsMargins(20, 20, 20, 20) - - self.title_label = QLabel(self.language_manager.get_text("download_list_title"), self) - self.title_label.setWordWrap(True) - self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - layout.addWidget(self.title_label) - - tools_row = QHBoxLayout() - self.create_album_button = QPushButton(self.language_manager.get_text("create_album"), self) - self.create_album_button.clicked.connect(self.create_album) - tools_row.addWidget(self.create_album_button) - - self.edit_track_button = QPushButton(self.language_manager.get_text("edit_track"), self) - self.edit_track_button.clicked.connect(self.edit_selected_track) - tools_row.addWidget(self.edit_track_button) - - tools_row.addStretch(1) - layout.addLayout(tools_row) - - nav_row = QHBoxLayout() - self.back_to_root_button = QPushButton(self.language_manager.get_text("back_to_root"), self) - self.back_to_root_button.clicked.connect(self.go_to_root) - nav_row.addWidget(self.back_to_root_button) - - self.path_label = QLabel("", self) - self.path_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - nav_row.addWidget(self.path_label, 1) - layout.addLayout(nav_row) - - self.explorer_grid = ExplorerGridWidget(self) - self.explorer_grid.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - self.explorer_grid.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) - self.explorer_grid.setDefaultDropAction(Qt.DropAction.MoveAction) - self.explorer_grid.setDragEnabled(True) - self.explorer_grid.setAcceptDrops(True) - self.explorer_grid.setDropIndicatorShown(True) - self.explorer_grid.setIconSize(QSize(64, 64)) - self.explorer_grid.setGridSize(QSize(110, 100)) - self.explorer_grid.itemDoubleClicked.connect(self.on_grid_item_double_clicked) - self.explorer_grid.trackMoved.connect(self.on_track_dropped) - layout.addWidget(self.explorer_grid, 1) - - destination_layout = QHBoxLayout() - self.folder_input = QLineEdit(self) - self.folder_input.setReadOnly(True) - self.folder_input.setText(str(Path.home() / "Downloads")) - destination_layout.addWidget(self.folder_input, 1) - - self.browse_button = QPushButton(self.language_manager.get_text("choose_folder"), self) - self.browse_button.clicked.connect(self.select_folder) - destination_layout.addWidget(self.browse_button) - layout.addLayout(destination_layout) - - actions_layout = QHBoxLayout() - self.remove_button = QPushButton(self.language_manager.get_text("remove_selected"), self) - self.remove_button.clicked.connect(self.remove_selected) - actions_layout.addWidget(self.remove_button) - - self.clear_button = QPushButton(self.language_manager.get_text("clear_list"), self) - self.clear_button.clicked.connect(self.clear_list) - actions_layout.addWidget(self.clear_button) - layout.addLayout(actions_layout) - - download_layout = QHBoxLayout() - self.download_selected_button = QPushButton(self.language_manager.get_text("download_selected"), self) - self.download_selected_button.clicked.connect(self.download_selected) - download_layout.addWidget(self.download_selected_button) - - self.download_all_button = QPushButton(self.language_manager.get_text("download_all"), self) - self.download_all_button.clicked.connect(self.download_all) - download_layout.addWidget(self.download_all_button) - layout.addLayout(download_layout) - - self.loading_bar = None - - self.status_label = QLabel("", self) - self.status_label.setWordWrap(True) - layout.addWidget(self.status_label) - - def _show_loading_bar(self, label: str): - if self.loading_bar is not None: - self.loading_bar.deleteLater() - self.loading_bar = LoadingBar(label, self) - self.layout().addWidget(self.loading_bar) - self.loading_bar.set_label(label) - self.loading_bar.set_progress(0) - - def refresh_list(self, tracks: list) -> None: - self.track_data = tracks - self._sync_albums_from_tracks() - self._render_grid() - - def _sync_albums_from_tracks(self) -> None: - for track in self.track_data: - album_name = str(track.get("album", "")).strip() - if not album_name: - continue - if album_name not in self.album_profiles: - self.album_profiles[album_name] = { - "artist": str(track.get("artist", "")).strip(), - "cover_path": str(track.get("cover_path", "")).strip(), - } - - def _add_album_item(self, album_name: str) -> None: - profile = self.album_profiles.get(album_name, {}) - cover_path = str(profile.get("cover_path", "")).strip() - folder_icon: QIcon - if cover_path and Path(cover_path).exists(): - folder_icon = QIcon(cover_path) - else: - folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon) - - if folder_icon.isNull(): - folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon) - - item = QListWidgetItem(folder_icon, album_name) - item.setData(Qt.ItemDataRole.UserRole + 1, "album") - item.setData(Qt.ItemDataRole.UserRole + 3, album_name) - item.setFlags((item.flags() | Qt.ItemFlag.ItemIsDropEnabled) & ~Qt.ItemFlag.ItemIsDragEnabled) - self.explorer_grid.addItem(item) - - def _add_track_item(self, index: int, track: dict) -> None: - cover_path = str(track.get("cover_path", "")).strip() - if cover_path and Path(cover_path).exists(): - file_icon = QIcon(cover_path) - else: - file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) - title = str(track.get("mp3_title") or track.get("title") or track.get("url", "")) - item = QListWidgetItem(file_icon, title) - item.setData(Qt.ItemDataRole.UserRole + 1, "track") - item.setData(Qt.ItemDataRole.UserRole + 2, index) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) - self.explorer_grid.addItem(item) - - def _render_grid(self) -> None: - selected_track_index = self._selected_track_index() - self.explorer_grid.blockSignals(True) - self.explorer_grid.clear() - - if not self.current_album: - for album_name in sorted(self.album_profiles.keys()): - self._add_album_item(album_name) - - for index, track in enumerate(self.track_data): - album_name = str(track.get("album", "")).strip() - if album_name: - continue - self._add_track_item(index, track) - - self.path_label.setText(self.language_manager.get_text("root_path")) - self.back_to_root_button.setVisible(False) - else: - for index, track in enumerate(self.track_data): - album_name = str(track.get("album", "")).strip() - if album_name != self.current_album: - continue - self._add_track_item(index, track) - - self.path_label.setText(f"{self.language_manager.get_text('albums_title')} / {self.current_album}") - self.back_to_root_button.setVisible(True) - - if selected_track_index >= 0: - for row in range(self.explorer_grid.count()): - item = self.explorer_grid.item(row) - track_index = item.data(Qt.ItemDataRole.UserRole + 2) - if isinstance(track_index, int) and track_index == selected_track_index: - self.explorer_grid.setCurrentItem(item) - break - - self.explorer_grid.blockSignals(False) - - def _selected_track_index(self) -> int: - item = self.explorer_grid.currentItem() - if item is None: - return -1 - track_index = item.data(Qt.ItemDataRole.UserRole + 2) - return track_index if isinstance(track_index, int) else -1 - - def on_grid_item_double_clicked(self, item: QListWidgetItem) -> None: - item_type = item.data(Qt.ItemDataRole.UserRole + 1) - if item_type == "album": - album_name = item.data(Qt.ItemDataRole.UserRole + 3) - if isinstance(album_name, str): - self.current_album = album_name - self._render_grid() - return - - if item_type == "track": - self.edit_selected_track() - - def go_to_root(self) -> None: - self.current_album = "" - self._render_grid() - - def create_album(self) -> None: - dialog = AlbumDialog(self.language_manager, self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - - album_name = dialog.name_input.text().strip() - if not album_name: - return - - cover_path = dialog.cover_input.text().strip() - self.album_profiles[album_name] = { - "artist": dialog.artist_input.text().strip(), - "cover_path": cover_path, - } - # UI: demande au manager d'appliquer la cover à tous les morceaux de l'album - self.metadata_manager.apply_album_cover(album_name, cover_path) - self.current_album = "" - self._render_grid() - self.status_label.setText(self.language_manager.get_text("album_saved")) - - def edit_selected_track(self) -> None: - track_index = self._selected_track_index() - if track_index < 0 or track_index >= len(self.track_data): - self.alert_manager.show_error("no_track_selected", self) - return - - track = self.track_data[track_index] - dialog = TrackEditorDialog(self.language_manager, track, sorted(self.album_profiles.keys()), self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - - album_name = str(dialog.album_combo.currentData() or "") - profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""}) - final_artist = dialog.artist_input.text().strip() or profile.get("artist", "") - final_cover = profile.get("cover_path", "") if album_name else str(track.get("cover_path", "")) - - if self.metadata_manager.update_track_metadata( - track_index, - dialog.title_input.text(), - final_artist, - album_name, - final_cover, - ): - self.status_label.setText(self.language_manager.get_text("metadata_saved")) - - def on_track_dropped(self, track_index: int, album_name: str) -> None: - if track_index < 0 or track_index >= len(self.track_data): - return - - profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""}) - if self.metadata_manager.update_track_metadata( - track_index, - str(self.track_data[track_index].get("mp3_title") or self.track_data[track_index].get("title", "")), - profile.get("artist", ""), - album_name, - profile.get("cover_path", ""), - ): - self.status_label.setText(self.language_manager.get_text("metadata_saved")) - - def select_folder(self) -> None: - selected_folder = QFileDialog.getExistingDirectory( - self, - self.language_manager.get_text("choose_output_folder"), - self.folder_input.text(), - ) - if selected_folder: - self.folder_input.setText(selected_folder) - - def remove_selected(self) -> None: - track_index = self._selected_track_index() - self.youtube_manager.remove_track(track_index) - - def clear_list(self) -> None: - self.youtube_manager.clear_tracks() - self.status_label.setText(self.language_manager.get_text("list_cleared")) - - def download_selected(self) -> None: - track_index = self._selected_track_index() - self.youtube_manager.download_track_at(track_index, self.folder_input.text().strip()) - - def download_all(self) -> None: - self.youtube_manager.download_all_tracks(self.folder_input.text().strip()) - - def on_download_started(self) -> None: - self._show_loading_bar(self.language_manager.get_text("downloading_audio")) - self.status_label.setText(self.language_manager.get_text("downloading_audio")) - - def on_progress_text(self, text: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(text) - self.status_label.setText(text) - - def on_progress_value(self, value: int) -> None: - if self.loading_bar is not None: - self.loading_bar.set_progress(value) - - def on_download_completed(self, _title: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_progress(100) - - def on_download_failed(self, error_key: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text(error_key)) - self.loading_bar.set_progress(0) - self.loading_bar.deleteLater() - self.loading_bar = None - self.status_label.setText(self.language_manager.get_text(error_key)) - self.alert_manager.show_error(error_key, self) - - def on_queue_finished(self) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text("playlist_download_complete")) - self.loading_bar.set_progress(100) - self.loading_bar.deleteLater() - self.loading_bar = None - self.status_label.setText(self.language_manager.get_text("playlist_download_complete")) - self.alert_manager.show_success("playlist_download_complete", self) - - def set_downloading_state(self, downloading: bool) -> None: - self.create_album_button.setEnabled(not downloading) - self.edit_track_button.setEnabled(not downloading) - self.browse_button.setEnabled(not downloading) - self.remove_button.setEnabled(not downloading) - self.clear_button.setEnabled(not downloading) - self.download_selected_button.setEnabled(not downloading) - self.download_all_button.setEnabled(not downloading) - self.explorer_grid.setEnabled(not downloading) - self.back_to_root_button.setEnabled(not downloading) - - def update_language(self) -> None: - self.title_label.setText(self.language_manager.get_text("download_list_title")) - self.create_album_button.setText(self.language_manager.get_text("create_album")) - self.edit_track_button.setText(self.language_manager.get_text("edit_track")) - self.browse_button.setText(self.language_manager.get_text("choose_folder")) - self.remove_button.setText(self.language_manager.get_text("remove_selected")) - self.clear_button.setText(self.language_manager.get_text("clear_list")) - self.download_selected_button.setText(self.language_manager.get_text("download_selected")) - self.download_all_button.setText(self.language_manager.get_text("download_all")) - self.back_to_root_button.setText(self.language_manager.get_text("back_to_root")) - self._render_grid() diff --git a/app/ui/windows/editing_window.py b/app/ui/windows/editing_window.py new file mode 100644 index 0000000..873518f --- /dev/null +++ b/app/ui/windows/editing_window.py @@ -0,0 +1,31 @@ +from PyQt6.QtWidgets import QScrollArea, QWidget, QHBoxLayout +from PyQt6.QtCore import Qt +from app.ui.widgets.grid_widget import GridWidget +from app.core.main_manager import MainManager, NotificationType +from typing import Optional + +class EditingWindow(QWidget): + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.main_manager: MainManager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.theme_manager = self.main_manager.get_theme_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.setup_ui() + + def setup_ui(self) -> None: + layout = QHBoxLayout(self) + scroll_area = QScrollArea(self) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setWidgetResizable(True) + grid_widget = GridWidget(parent=self) + scroll_area.setWidget(grid_widget) + layout.addWidget(scroll_area) + + + def update_language(self) -> None: + return \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py index ad9afcd..217297d 100644 --- a/app/ui/windows/settings_window.py +++ b/app/ui/windows/settings_window.py @@ -14,20 +14,6 @@ class SettingsWindow(QWidget): self.observer_manager = self.main_manager.get_observer_manager() self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - # Type hints for UI elements - self.language_layout: QHBoxLayout - self.languageLabel: QLabel - self.languageCombo: QComboBox - self.theme_layout: QHBoxLayout - self.themeLabel: QLabel - self.themeCombo: QComboBox - self.audio_format_layout: QHBoxLayout - self.audioFormatLabel: QLabel - self.audioFormatCombo: QComboBox - self.audio_quality_layout: QHBoxLayout - self.audioQualityLabel: QLabel - self.audioQualityCombo: QComboBox - self.setup_ui() def setup_ui(self) -> None: @@ -66,36 +52,8 @@ class SettingsWindow(QWidget): self.theme_layout.addWidget(self.themeCombo) layout.addLayout(self.theme_layout) - - layout.addStretch(1) - - self.audio_format_layout = QHBoxLayout() - self.audioFormatLabel = QLabel(self.language_manager.get_text("audio_format_setting"), self) - self.audioFormatLabel.setMinimumWidth(100) - self.audioFormatLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - self.audio_format_layout.addWidget(self.audioFormatLabel) - - self.audioFormatCombo = self.createAudioFormatSelector() - self.audioFormatCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.audio_format_layout.addWidget(self.audioFormatCombo) - layout.addLayout(self.audio_format_layout) - - layout.addStretch(1) - - self.audio_quality_layout = QHBoxLayout() - self.audioQualityLabel = QLabel(self.language_manager.get_text("audio_quality_setting"), self) - self.audioQualityLabel.setMinimumWidth(100) - self.audioQualityLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - self.audio_quality_layout.addWidget(self.audioQualityLabel) - - self.audioQualityCombo = self.createAudioQualitySelector() - self.audioQualityCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.audio_quality_layout.addWidget(self.audioQualityCombo) - layout.addLayout(self.audio_quality_layout) layout.addStretch(1) - - self._update_audio_quality_enabled_state() def createLanguageSelector(self) -> QComboBox: combo: QComboBox = QComboBox() @@ -130,52 +88,10 @@ class SettingsWindow(QWidget): theme: str = self.themeCombo.itemData(index) self.settings_manager.set_theme(theme) - def createAudioFormatSelector(self) -> QComboBox: - combo: QComboBox = QComboBox() - combo.addItem(self.language_manager.get_text("audio_format_mp3"), "mp3") - combo.addItem(self.language_manager.get_text("audio_format_best"), "best") - - current_index = combo.findData(self.settings_manager.get_audio_format()) - combo.setCurrentIndex(current_index if current_index >= 0 else 0) - combo.currentIndexChanged.connect(self.change_audio_format) - return combo - - def createAudioQualitySelector(self) -> QComboBox: - combo: QComboBox = QComboBox() - combo.addItem("320 kbps", "320") - combo.addItem("192 kbps", "192") - combo.addItem("128 kbps", "128") - - current_index = combo.findData(self.settings_manager.get_audio_quality()) - combo.setCurrentIndex(current_index if current_index >= 0 else 0) - combo.currentIndexChanged.connect(self.change_audio_quality) - return combo - - def change_audio_format(self, index: int) -> None: - audio_format = self.audioFormatCombo.itemData(index) - self.settings_manager.set_audio_format(audio_format) - self._update_audio_quality_enabled_state() - - def change_audio_quality(self, index: int) -> None: - quality = self.audioQualityCombo.itemData(index) - self.settings_manager.set_audio_quality(quality) - - def _update_audio_quality_enabled_state(self) -> None: - self.audioQualityCombo.setEnabled(self.settings_manager.get_audio_format() == "mp3") - def update_language(self) -> None: self.languageLabel.setText(self.language_manager.get_text("language")) self.themeLabel.setText(self.language_manager.get_text("theme")) - self.audioFormatLabel.setText(self.language_manager.get_text("audio_format_setting")) - self.audioQualityLabel.setText(self.language_manager.get_text("audio_quality_setting")) # Mettre à jour les textes dans la combo de thème for i in range(self.themeCombo.count()): - self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) - - current_audio_format = self.audioFormatCombo.currentData() - self.audioFormatCombo.setItemText(0, self.language_manager.get_text("audio_format_mp3")) - self.audioFormatCombo.setItemText(1, self.language_manager.get_text("audio_format_best")) - format_index = self.audioFormatCombo.findData(current_audio_format) - if format_index >= 0: - self.audioFormatCombo.setCurrentIndex(format_index) \ No newline at end of file + self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) \ No newline at end of file diff --git a/data/assets/edit.svg b/data/assets/edit.svg new file mode 100644 index 0000000..c2f870b --- /dev/null +++ b/data/assets/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/assets/icon.ico b/data/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2e253057c9af042119c0deade82d37d281d87b92 GIT binary patch literal 8591 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 int: global preloaded_window - try: - QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) - except Exception: - return 0 + + main_manager: MainManager = MainManager.get_instance() + theme_manager = main_manager.get_theme_manager() + settings_manager = main_manager.get_settings_manager() - try: - main_manager: MainManager = MainManager.get_instance() - - theme_manager = main_manager.get_theme_manager() - settings_manager = main_manager.get_settings_manager() - except Exception as e: - print(f"Error occurred while initializing managers: {e}") - return 0 - - try: - app: QApplication = QApplication(sys.argv) - except Exception: - return 0 - - try: - app.setStyleSheet(theme_manager.get_sheet()) - app.setApplicationName(settings_manager.get_config("app_name")) - app.setWindowIcon(QIcon(paths.get_asset_path("icon"))) - except Exception: - return 0 + app: QApplication = QApplication(sys.argv) + app.setStyleSheet(theme_manager.get_sheet()) + app.setApplicationName(settings_manager.get_config("app_name")) + app.setWindowIcon(QIcon(paths.get_asset_path("icon"))) splash_image_path = paths.get_asset_path(settings_manager.get_config("splash_image")) use_splash = splash_image_path and paths.Path(splash_image_path).exists() - + if use_splash: - try: - splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash)) - splash.show_splash() - except Exception as e: - return 0 - + splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash)) + splash.show_splash() + def on_splash_finished(success): global preloaded_window if not success: app.quit() return - try: - if preloaded_window: - preloaded_window.show() - else: - window = MainWindow() - window.show() - except Exception: - app.quit() + + if preloaded_window: + preloaded_window.show() + else: + window = MainWindow() + window.show() splash.finished.connect(on_splash_finished) - return app.exec() else: + def dummy_progress(text): pass + success = preload_application(dummy_progress) if not success: return 0 - try: - window = preloaded_window if preloaded_window else MainWindow() - window.show() - except Exception: - return 0 - return app.exec() + + window = preloaded_window if preloaded_window else MainWindow() + window.show() + + return app.exec() if __name__ == "__main__": sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 60c1aed..390d828 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,4 @@ PyQt6 -PyQt6-WebEngine pyinstaller python-dotenv -requests -yt-dlp -mutagen -pillow \ No newline at end of file +requests \ No newline at end of file diff --git a/tools/build.command b/tools/build.command new file mode 100644 index 0000000..7d73185 --- /dev/null +++ b/tools/build.command @@ -0,0 +1,88 @@ +#!/bin/bash + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PARENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +CONFIG_FILE="$PARENT_DIR/config.json" +ICON_FILE="$PARENT_DIR/data/assets/icon.png" +ENV_FILE="$PARENT_DIR/.env" + +# === Check .env === +if [ ! -f "$ENV_FILE" ]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# === Extract values from config.json === +ICON_PATH=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['icon_path'])") +APP_NAME=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['app_name'])") +ARCHITECTURE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['architecture'])") + +# === Extract PYTHON_PATH from .env === +SYSTEM_PYTHON=$(grep '^PYTHON_PATH=' "$ENV_FILE" | cut -d '=' -f2-) + +VENV_PATH="$PARENT_DIR/MACenv_$ARCHITECTURE" +APP_FILE="$APP_NAME.app" +PYTHON_IN_VENV="$VENV_PATH/bin/python" +BUILD_DIR="$PARENT_DIR/build" +ZIP_FILE="$BUILD_DIR/$APP_NAME.zip" + +# === Verify Python existence === +if [ ! -x "$SYSTEM_PYTHON" ]; then + echo "[ERROR] Python not found at: $SYSTEM_PYTHON" + exit 1 +fi + +# === Check virtual environment === +if [ ! -f "$VENV_PATH/bin/activate" ]; then + echo "[INFO] Virtual environment not found. Creating..." + "$SYSTEM_PYTHON" -m venv "$VENV_PATH" + "$PYTHON_IN_VENV" -m pip install --upgrade pip + "$PYTHON_IN_VENV" -m pip install -r "$PARENT_DIR/requirements.txt" +else + echo "[INFO] Virtual environment found." +fi + +# === Run PyInstaller === +"$PYTHON_IN_VENV" -m PyInstaller \ + --distpath "$BUILD_DIR" \ + --workpath "$BUILD_DIR/dist" \ + --clean \ + "$PARENT_DIR/BUILD.spec" + +# === Clean build cache === +rm -rf "$BUILD_DIR/dist" + +# === Create ZIP === +echo "[INFO] Creating ZIP archive..." + +TEMP_ZIP_DIR="$BUILD_DIR/temp_zip" + +# Remove old temp dir +rm -rf "$TEMP_ZIP_DIR" +mkdir -p "$TEMP_ZIP_DIR" + +# Move compiled app +mv "$BUILD_DIR/$APP_FILE" "$TEMP_ZIP_DIR/" + +# Copy config.json +cp "$CONFIG_FILE" "$TEMP_ZIP_DIR/" + +# Copy icon.png +cp "$ICON_FILE" "$TEMP_ZIP_DIR/" + +# Copy data/lang +cp -R "$PARENT_DIR/data/lang" "$TEMP_ZIP_DIR/lang" + +# Remove old ZIP +rm -f "$ZIP_FILE" + +# Create ZIP +cd "$BUILD_DIR" +zip -r "$ZIP_FILE" "temp_zip" >/dev/null + +# Remove temp folder +rm -rf "$TEMP_ZIP_DIR" + +echo "[INFO] ZIP created at: $ZIP_FILE" \ No newline at end of file diff --git a/tools/open.command b/tools/open.command new file mode 100644 index 0000000..e69de29