import os import sys import shutil import threading VERSION = "1.1" # 每次修改 +0.1;大改版 +1 需經小威同意 import tkinter as tk from tkinter import ttk, messagebox, filedialog try: import yt_dlp except ImportError: messagebox.showerror("缺少依賴", "請先安裝 yt-dlp 套件!") sys.exit(1) class VideoDownloaderApp: def __init__(self, root: tk.Tk): self.root = root self.root.title(f"網頁影片通用下載器 v{VERSION}") # 視窗大小依內容決定(見 _center_window),不寫死,避免高解析度螢幕縮放時底部按鈕被切掉。 self.root.configure(bg="#0f172a") # 預設儲存目錄(使用者下載資料夾) self.download_dir = os.path.join(os.path.expanduser("~"), "Downloads") if not os.path.exists(self.download_dir): self.download_dir = os.getcwd() self.is_downloading = False self.download_thread = None self._setup_styles() self._build_ui() self._center_window() self._check_ffmpeg() def _setup_styles(self): style = ttk.Style() try: style.theme_use("clam") except Exception: pass def _center_window(self): self.root.update_idletasks() w = max(self.root.winfo_reqwidth(), 540) h = self.root.winfo_reqheight() self.root.minsize(w, h) ws = self.root.winfo_screenwidth() hs = self.root.winfo_screenheight() x = max(0, (ws // 2) - (w // 2)) y = max(0, (hs // 2) - (h // 2)) self.root.geometry(f"{w}x{h}+{x}+{y}") def _check_ffmpeg(self): # 檢測系統是否已有 ffmpeg ffmpeg_path = shutil.which("ffmpeg") if not ffmpeg_path: # 嘗試檢查常見 WinGet 或本機路徑 local_appdata = os.environ.get("LOCALAPPDATA", "") winget_path = os.path.join(local_appdata, r"Microsoft\WinGet\Links\ffmpeg.exe") if os.path.exists(winget_path): os.environ["PATH"] = os.path.dirname(winget_path) + os.pathsep + os.environ.get("PATH", "") def _build_ui(self): # 主容器 container = tk.Frame(self.root, bg="#0f172a", padx=24, pady=20) container.pack(fill=tk.BOTH, expand=True) # 1. 標題與簡介 header_frame = tk.Frame(container, bg="#0f172a") header_frame.pack(fill=tk.X, pady=(0, 16)) title_label = tk.Label( header_frame, text="🎬 Universal Video Downloader", font=("Segoe UI", 16, "bold"), bg="#0f172a", fg="#f8fafc" ) title_label.pack(anchor="w") subtitle_label = tk.Label( header_frame, text="支援 YouTube、Bilibili、FB、IG、X/Twitter 等主流串流影片下載", font=("Microsoft JhengHei", 9), bg="#0f172a", fg="#94a3b8" ) subtitle_label.pack(anchor="w", pady=(2, 0)) # 2. 網址輸入區 url_label = tk.Label( container, text="影片網址 (Video URL):", font=("Microsoft JhengHei", 10, "bold"), bg="#0f172a", fg="#cbd5e1" ) url_label.pack(anchor="w", pady=(0, 6)) url_input_frame = tk.Frame(container, bg="#0f172a") url_input_frame.pack(fill=tk.X, pady=(0, 14)) self.url_var = tk.StringVar() self.url_entry = tk.Entry( url_input_frame, textvariable=self.url_var, font=("Segoe UI", 10), bg="#1e293b", fg="#ffffff", insertbackground="#ffffff", relief="flat", bd=6 ) self.url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) paste_btn = tk.Button( url_input_frame, text="📋 貼上", font=("Microsoft JhengHei", 9, "bold"), bg="#334155", fg="#f8fafc", activebackground="#475569", activeforeground="#ffffff", relief="flat", padx=12, pady=4, cursor="hand2", command=self._paste_clipboard ) paste_btn.pack(side=tk.RIGHT) # 3. 下載規格與畫質選擇 format_frame = tk.LabelFrame( container, text=" 畫質與格式設定 ", font=("Microsoft JhengHei", 9, "bold"), bg="#1e293b", fg="#94a3b8", padx=14, pady=10, relief="solid", bd=1 ) format_frame.pack(fill=tk.X, pady=(0, 14)) self.format_var = tk.StringVar(value="best") formats = [ ("🌟 最佳畫質 (自動合併最高 4K / 1080p MP4)", "best"), ("🎬 1080p 高畫質 (MP4)", "1080p"), ("📱 720p 標清 (MP4)", "720p"), ("🎵 僅擷取音訊 (MP3 音樂檔)", "audio_only") ] for text, val in formats: rb = tk.Radiobutton( format_frame, text=text, value=val, variable=self.format_var, font=("Microsoft JhengHei", 9), bg="#1e293b", fg="#f1f5f9", selectcolor="#0f172a", activebackground="#1e293b", activeforeground="#60a5fa", cursor="hand2", anchor="w" ) rb.pack(fill=tk.X, pady=2) # 4. 儲存路徑區 path_frame = tk.Frame(container, bg="#0f172a") path_frame.pack(fill=tk.X, pady=(0, 16)) path_label = tk.Label( path_frame, text="儲存資料夾:", font=("Microsoft JhengHei", 9, "bold"), bg="#0f172a", fg="#94a3b8" ) path_label.pack(side=tk.LEFT) self.path_display_var = tk.StringVar(value=self.download_dir) self.path_entry = tk.Entry( path_frame, textvariable=self.path_display_var, font=("Segoe UI", 9), bg="#1e293b", fg="#94a3b8", relief="flat", state="readonly", bd=4 ) self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6) browse_btn = tk.Button( path_frame, text="瀏覽...", font=("Microsoft JhengHei", 9), bg="#334155", fg="#f8fafc", activebackground="#475569", activeforeground="#ffffff", relief="flat", padx=10, pady=2, cursor="hand2", command=self._browse_dir ) browse_btn.pack(side=tk.RIGHT) # 5. 進度顯示區(Canvas 自繪平滑進度條) progress_box = tk.Frame(container, bg="#1e293b", padx=12, pady=10, bd=1, relief="solid") progress_box.pack(fill=tk.X, pady=(0, 16)) self.status_title_var = tk.StringVar(value="影片:尚未開始") self.status_title_label = tk.Label( progress_box, textvariable=self.status_title_var, font=("Microsoft JhengHei", 9, "bold"), bg="#1e293b", fg="#e2e8f0", anchor="w" ) self.status_title_label.pack(fill=tk.X) # 進度條畫布 self.progress_canvas = tk.Canvas(progress_box, height=14, bg="#0f172a", highlightthickness=0) self.progress_canvas.pack(fill=tk.X, pady=6) self.progress_bar_rect = self.progress_canvas.create_rectangle(0, 0, 0, 14, fill="#3b82f6", width=0) # 詳細狀態 (百分比, 速度, 剩餘時間) self.detail_var = tk.StringVar(value="狀態:待命中,請貼上網址後點擊下載") self.detail_label = tk.Label( progress_box, textvariable=self.detail_var, font=("Segoe UI", 9), bg="#1e293b", fg="#94a3b8", anchor="w" ) self.detail_label.pack(fill=tk.X) # 6. 下載按鈕與開啟目錄 btn_box = tk.Frame(container, bg="#0f172a") btn_box.pack(fill=tk.X) self.download_btn = tk.Button( btn_box, text="⬇️ 開始下載影片", font=("Microsoft JhengHei", 11, "bold"), bg="#2563eb", fg="#ffffff", activebackground="#1d4ed8", activeforeground="#ffffff", relief="flat", pady=8, cursor="hand2", command=self.start_download ) self.download_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8)) self.open_folder_btn = tk.Button( btn_box, text="📂 開啟下載資料夾", font=("Microsoft JhengHei", 10, "bold"), bg="#059669", fg="#ffffff", activebackground="#047857", activeforeground="#ffffff", relief="flat", padx=14, pady=8, cursor="hand2", command=self._open_folder ) self.open_folder_btn.pack(side=tk.RIGHT) # 7. 免責聲明 disclaimer = tk.Label( container, text="免責聲明:請只下載你擁有權利或已獲授權的影片。下載、轉存他人作品須遵守著作權法與各平台使用條款,使用者須自行承擔責任。", font=("Microsoft JhengHei", 8), bg="#0f172a", fg="#94a3b8", justify="left", wraplength=480, ) disclaimer.pack(fill=tk.X, pady=(14, 0)) def _paste_clipboard(self): try: text = self.root.clipboard_get().strip() if text: self.url_var.set(text) except Exception: pass def _browse_dir(self): selected = filedialog.askdirectory(initialdir=self.download_dir) if selected: self.download_dir = selected self.path_display_var.set(selected) def _open_folder(self): if os.path.exists(self.download_dir): os.startfile(self.download_dir) else: messagebox.showinfo("提示", "下載資料夾尚未建立或不存在!") def _update_progress(self, percent: float, status_text: str, title_text: str = None): def _apply(): if title_text: self.status_title_var.set(f"影片:{title_text}") self.detail_var.set(status_text) # 更新 Canvas 進度條寬度 canvas_width = self.progress_canvas.winfo_width() bar_w = int(canvas_width * (percent / 100.0)) self.progress_canvas.coords(self.progress_bar_rect, 0, 0, bar_w, 14) self.root.after(0, _apply) def start_download(self): if self.is_downloading: return url = self.url_var.get().strip() if not url: messagebox.showwarning("請輸入網址", "請先貼上或輸入您要下載的影片網址!") return self.is_downloading = True self.download_btn.config( text="⏳ 正在下載與轉碼中...", bg="#475569", state="disabled", cursor="watch" ) self.url_entry.config(state="disabled") self._update_progress(0, "正在解析影片串流資訊與提取格式,請稍候...") # 背景執行下載 self.download_thread = threading.Thread(target=self._download_worker, args=(url,), daemon=True) self.download_thread.start() def _download_worker(self, url: str): choice = self.format_var.get() outtmpl = os.path.join(self.download_dir, "%(title)s.%(ext)s") ydl_opts = { "outtmpl": outtmpl, "quiet": True, "no_warnings": True, "nocheckcertificate": True, "progress_hooks": [self._ytdl_hook], "postprocessor_hooks": [self._postprocessor_hook], } # 設定畫質參數 if choice == "audio_only": ydl_opts["format"] = "bestaudio/best" ydl_opts["postprocessors"] = [{ "key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192", }] elif choice == "1080p": ydl_opts["format"] = "bestvideo[height<=1080]+bestaudio/best[height<=1080]/best" ydl_opts["merge_output_format"] = "mp4" elif choice == "720p": ydl_opts["format"] = "bestvideo[height<=720]+bestaudio/best[height<=720]/best" ydl_opts["merge_output_format"] = "mp4" else: # 最佳畫質 ydl_opts["format"] = "bestvideo+bestaudio/best" ydl_opts["merge_output_format"] = "mp4" try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) video_title = info.get("title", "未知影片標題") self._update_progress(0, "解析成功,開始串流下載...", title_text=video_title) ydl.download([url]) self._update_progress(100, "🎉 下載完成!檔案已妥善儲存。") self.root.after(0, lambda: messagebox.showinfo("下載成功", f"影片已成功下載至:\n{self.download_dir}")) except Exception as e: err_msg = str(e) if "HTTP Error 403" in err_msg: err_text = "下載失敗:該平台限制存取或需要登入驗證 (403 Forbidden)。" elif "Unsupported URL" in err_msg: err_text = "下載失敗:不支援的網址格式或不是有效的影片連結。" else: err_text = f"下載發生錯誤:{err_msg[:120]}" self._update_progress(0, f"❌ {err_text}") self.root.after(0, lambda: messagebox.showerror("下載失敗", err_text)) finally: self.is_downloading = False self.root.after(0, self._reset_ui) def _ytdl_hook(self, d): status = d.get("status") if status == "downloading": total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 downloaded = d.get("downloaded_bytes", 0) speed = d.get("speed") or 0 eta = d.get("eta") or 0 percent = 0.0 if total > 0: percent = min(100.0, (downloaded / total) * 100.0) # 速度格式化 if speed > 1024 * 1024: speed_str = f"{speed / (1024 * 1024):.1f} MB/s" elif speed > 1024: speed_str = f"{speed / 1024:.0f} KB/s" else: speed_str = "-- KB/s" # 剩餘時間格式化 if eta: eta_str = f"{int(eta // 60):02d}:{int(eta % 60):02d}" else: eta_str = "--:--" info_text = f"正在下載中: {percent:.1f}% | 速度: {speed_str} | 剩餘時間: {eta_str}" self._update_progress(percent, info_text) elif status == "finished": self._update_progress(100, "下載完成,正在處理影音轉碼合成 (FFmpeg)...") def _postprocessor_hook(self, d): status = d.get("status") if status == "started": self._update_progress(100, "正在進行音訊視訊無損合成與封裝...") elif status == "finished": self._update_progress(100, "轉碼合成完成!") def _reset_ui(self): self.download_btn.config( text="⬇️ 開始下載影片", bg="#2563eb", state="normal", cursor="hand2" ) self.url_entry.config(state="normal") def main(): # 讓 Windows 知道程式會自己處理高解析度縮放,文字才不會模糊。 if sys.platform == "win32": try: import ctypes ctypes.windll.shcore.SetProcessDpiAwareness(1) except Exception: pass root = tk.Tk() app = VideoDownloaderApp(root) root.mainloop() if __name__ == "__main__": main()