#!/usr/bin/env python3 """跨平台媒体播放器(Windows / Linux / macOS)。 基于 tkinter;播放内核按优先级为:VLC(python-vlc)、纯 Python 解码 (PyAV + sounddevice,无需安装任何外部软件)、pygame 音频回退。 用法: python media_player.py [媒体文件或文件夹...] 常用快捷键: Space 播放 / 暂停 ← / → 后退 / 前进 10 秒 Shift+← / → 后退 / 前进 60 秒 ↑ / ↓ 音量 +5 / -5 M 静音 / 恢复 Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) N / P 下一首 / 上一首 [ / ] 减速 / 加速(0.5x - 2.0x) L 切换循环模式(关 / 单曲 / 列表) F 视频全屏切换 Esc 退出全屏 Ctrl+O 打开文件(macOS 也支持 Command+O) Delete 从播放列表移除所选曲目 Enter 播放列表中选中的曲目 """ import collections import json import os import queue import sys import threading import time from pathlib import Path import tkinter as tk from tkinter import filedialog, messagebox, ttk try: from PIL import Image as PILImage, ImageTk except ImportError: PILImage = ImageTk = None try: import vlc except Exception: vlc = None try: import pygame except ImportError: pygame = None IS_MAC = sys.platform == "darwin" IS_WIN = sys.platform == "win32" IS_LINUX = sys.platform.startswith("linux") AUDIO_EXTS = { ".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac", ".opus", ".wma", ".aiff", ".ape", } VIDEO_EXTS = { ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".ts", ".mpg", ".mpeg", ".3gp", } SUPPORTED_EXTS = AUDIO_EXTS | VIDEO_EXTS LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"} SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] SEEK_STEP = 10 SEEK_STEP_BIG = 60 def fmt_time(ms): """把毫秒格式化为 mm:ss 或 h:mm:ss。""" if ms is None or ms < 0: return "--:--" total = int(ms // 1000) hours, remainder = divmod(total, 3600) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}:{minutes:02d}:{seconds:02d}" return f"{minutes:02d}:{seconds:02d}" class _BaseEngine: """播放内核统一接口。""" EMBEDDABLE = False supports_rate = True def __init__(self): self._events = queue.Queue() def play(self, path): raise NotImplementedError def toggle_pause(self): pass def stop(self): pass def seek(self, ms): return False def get_time(self): return -1 def get_length(self): return -1 def is_active(self): return False def is_playing(self): return False def set_volume(self, volume): pass def set_rate(self, rate): pass def set_fullscreen(self, on): pass def embed(self, window_id): return False def get_frame(self): """返回当前应显示的帧(PIL 图像),没有新帧时返回 None。""" return None def poll_events(self): events = [] while True: try: events.append(self._events.get_nowait()) except queue.Empty: return events def release(self): pass class VlcEngine(_BaseEngine): """基于 VLC 的内核:支持音视频、定位、变速与全屏。""" EMBEDDABLE = not IS_MAC def __init__(self): super().__init__() args = ["--no-video-title-show", "--quiet"] if IS_MAC: args.append("--vout=macosx") self.instance = vlc.Instance(args) self.player = self.instance.media_player_new() self._media = None self._rate = 1.0 self._volume = 80 manager = self.player.event_manager() manager.event_attach(vlc.EventType.MediaPlayerEndReached, self._on_event_end) manager.event_attach(vlc.EventType.MediaPlayerEncounteredError, self._on_event_error) def _on_event_end(self, _event): self._events.put("end") def _on_event_error(self, _event): self._events.put("error") def play(self, path): media = self.instance.media_new(str(path)) self.player.set_media(media) self._media = media self.player.play() self.player.set_rate(self._rate) self.player.audio_set_volume(self._volume) return True def toggle_pause(self): if not self.is_active(): return if self.is_playing(): self.player.set_pause(1) else: self.player.set_pause(0) def stop(self): self.player.stop() self._media = None def seek(self, ms): if not self.is_active(): return False self.player.set_time(max(int(ms), 0)) return True def get_time(self): return self.player.get_time() if self.is_active() else -1 def get_length(self): if not self.is_active(): return -1 length = self.player.get_length() return length if length > 0 else -1 def is_active(self): return self._media is not None def is_playing(self): return bool(self.player.is_playing()) def set_volume(self, volume): self._volume = max(0, min(100, int(volume))) self.player.audio_set_volume(self._volume) def set_rate(self, rate): self._rate = float(rate) self.player.set_rate(self._rate) def set_fullscreen(self, on): self.player.set_fullscreen(bool(on)) def embed(self, window_id): if IS_WIN: self.player.set_hwnd(int(window_id)) elif IS_LINUX: self.player.set_xwindow(int(window_id)) return True def release(self): try: self.player.stop() self.player.release() except Exception: pass class PygameEngine(_BaseEngine): """基于 pygame 的回退内核:无 VLC 时播放音频文件。""" EMBEDDABLE = False supports_rate = False def __init__(self): super().__init__() pygame.mixer.init() self._volume = 80 self._paused = False self._started = False self._stopped = True self._pos = 0 self._end_reported = False def play(self, path): self.stop() pygame.mixer.music.load(str(path)) pygame.mixer.music.play() pygame.mixer.music.set_volume(self._volume / 100.0) self._started = True self._stopped = False self._paused = False self._pos = 0 self._end_reported = False return True def toggle_pause(self): if self._stopped: return if self._paused: pygame.mixer.music.unpause() self._paused = False else: pygame.mixer.music.pause() self._paused = True def stop(self): pygame.mixer.music.stop() self._started = False self._stopped = True self._paused = False self._pos = 0 def seek(self, ms): try: pygame.mixer.music.set_pos(max(ms, 0) / 1000.0) self._pos = max(ms, 0) return True except pygame.error: return False def get_time(self): if self._stopped: return -1 if not self._paused and pygame.mixer.music.get_busy(): pos = pygame.mixer.music.get_pos() if pos > 0: self._pos = pos return int(self._pos) def get_length(self): return -1 def is_active(self): return self._started def is_playing(self): return self._started and not self._paused and pygame.mixer.music.get_busy() def set_volume(self, volume): self._volume = max(0, min(100, int(volume))) pygame.mixer.music.set_volume(self._volume / 100.0) def set_rate(self, rate): pass def set_fullscreen(self, on): pass def poll_events(self): events = super().poll_events() if ( self._started and not self._paused and not self._stopped and not pygame.mixer.music.get_busy() and not self._end_reported ): self._end_reported = True events.append("end") return events def release(self): try: pygame.mixer.music.stop() pygame.mixer.quit() except Exception: pass class AvEngine(_BaseEngine): """纯 Python 解码内核:基于 PyAV 与 sounddevice,无需安装 VLC。 跨平台(Windows / macOS / Linux)可用:PyAV 自带 FFmpeg 解码能力, sounddevice 自带 PortAudio 音频输出,视频画面由主窗口负责绘制。 """ EMBEDDABLE = True supports_rate = False AUDIO_RATE = 44100 def __init__(self): super().__init__() try: import av import numpy # noqa: F401 to_ndarray 依赖 import sounddevice as sd from PIL import Image # noqa: F401 except ImportError as exc: raise ImportError("缺少纯 Python 播放依赖(av / sounddevice / numpy / Pillow):%s" % exc) self._av = av self._sd = sd self._lock = threading.Lock() self._reset_state() def _reset_state(self): self._container = None self._video_stream = None self._audio_stream = None self._video_deque = collections.deque() self._video_deque_max = 6 self._audio_queue = queue.Queue(maxsize=64) self._audio_leftover = None self._audio_stream_obj = None self._audio_ok = False self._audio_broken = False self._audio_written = 0 self._resampler = None self._thread = None self._stop_flag = threading.Event() self._seek_request = None self._gen = 0 self._seek_after = 0.0 self._eof = False self._ended = False self._paused = False self._volume = 80 self._clock_base = 0.0 self._clock_started = time.monotonic() self._clock_running = False def play(self, path): self.stop() container = self._av.open(str(path)) self._container = container videos = [s for s in container.streams if s.type == "video"] if videos: from av.stream import Disposition self._video_stream = max( videos, key=lambda s: (0 if s.disposition & Disposition.attached_pic else 1, s.frames or 0), ) else: self._video_stream = None self._audio_stream = next((s for s in container.streams if s.type == "audio"), None) self._gen += 1 self._eof = False self._ended = False self._paused = False self._seek_after = 0.0 self._resampler = None self._audio_ok = False self._audio_broken = False if self._audio_stream is not None: try: self._audio_stream_obj = self._sd.OutputStream( samplerate=self.AUDIO_RATE, channels=2, dtype="float32", callback=self._audio_callback, ) self._audio_stream_obj.start() self._audio_ok = True except Exception: self._audio_stream_obj = None self._audio_written = 0 self._clock_base = 0.0 self._clock_started = time.monotonic() self._clock_running = self._audio_stream_obj is None self._stop_flag = threading.Event() self._thread = threading.Thread(target=self._decode_loop, daemon=True) self._thread.start() return True def toggle_pause(self): if not self.is_active() or self._ended: return self._paused = not self._paused if self._paused: if self._clock_running: self._clock_base = self._noaudio_pos() self._clock_running = False else: self._clock_started = time.monotonic() if self._audio_stream_obj is None: self._clock_running = True def stop(self): self._stop_flag.set() if self._thread is not None: self._thread.join(timeout=1.0) if self._audio_stream_obj is not None: try: self._audio_stream_obj.stop() self._audio_stream_obj.close() except Exception: pass if self._container is not None: try: self._container.close() except Exception: pass self._reset_state() def seek(self, ms): if not self.is_active(): return False target = max(0.0, int(ms) / 1000.0) with self._lock: self._seek_request = target self._seek_after = target self._gen += 1 self._video_deque.clear() self._drain_queue(self._audio_queue) self._audio_leftover = None self._audio_written = int(target * self.AUDIO_RATE) self._clock_base = target self._clock_started = time.monotonic() self._ended = False if self._audio_stream_obj is not None: try: self._audio_stream_obj.stop() self._audio_stream_obj.start() except Exception: pass if self._thread is None or not self._thread.is_alive(): self._eof = False self._resampler = None self._stop_flag = threading.Event() self._thread = threading.Thread(target=self._decode_loop, daemon=True) self._thread.start() return True def get_time(self): if not self.is_active(): return -1 if self._audio_stream_obj is not None: with self._lock: return int(self._audio_written * 1000.0 / self.AUDIO_RATE) return int(self._noaudio_pos() * 1000.0) def _disable_audio(self, reason=""): """音频不可用时降级为无声播放,视频播放不受影响。""" stream = self._audio_stream_obj self._audio_stream_obj = None self._audio_ok = False self._audio_broken = False self._drain_queue(self._audio_queue) self._audio_leftover = None if stream is not None: try: stream.stop() stream.close() except Exception: pass with self._lock: self._clock_base = self._audio_written / self.AUDIO_RATE self._clock_started = time.monotonic() self._clock_running = not self._paused if reason: print("音频不可用,已切换为无声播放:", reason) def _noaudio_pos(self): if self._clock_running: return self._clock_base + (time.monotonic() - self._clock_started) return self._clock_base def get_length(self): if not self.is_active(): return -1 duration = self._container.duration if duration and duration > 0: return int(duration / 1000) return -1 def is_active(self): return self._container is not None def is_playing(self): return self.is_active() and not self._paused and not self._ended def set_volume(self, volume): self._volume = max(0, min(100, int(volume))) def set_rate(self, rate): pass def set_fullscreen(self, on): pass def embed(self, window_id): return False def get_frame(self): if not self.is_active(): return None pos = self.get_time() / 1000.0 latest = None with self._lock: while self._video_deque: gen, secs, img = self._video_deque[0] if gen != self._gen: self._video_deque.popleft() continue if secs > pos + 0.05: break latest = img self._video_deque.popleft() return latest def poll_events(self): events = super().poll_events() if self._audio_broken and self._audio_stream_obj is not None: self._disable_audio("音频回调异常") if not self._ended and self.is_active() and not self._paused and self._eof: with self._lock: video_done = not self._video_deque audio_done = ( self._audio_stream_obj is None or (self._audio_queue.empty() and self._audio_leftover is None) ) if video_done and audio_done: self._ended = True events.append("end") return events def release(self): self.stop() def _decode_loop(self): try: container = self._container video = self._video_stream audio = self._audio_stream streams = [s for s in (video, audio) if s is not None] demux = container.demux(*streams) while not self._stop_flag.is_set(): if self._seek_request is not None: with self._lock: target = self._seek_request self._seek_request = None seek_stream = video if video is not None else audio if seek_stream is not None: offset = int(round(target / float(seek_stream.time_base))) try: container.seek(offset, stream=seek_stream) except Exception: pass demux = container.demux(*streams) self._resampler = None continue gen = self._gen try: packet = next(demux) except StopIteration: self._eof = True break except Exception: self._eof = True break if self._stop_flag.is_set(): break if packet.stream.type == "video" and video is not None and packet.size: try: frames = packet.decode() except Exception: continue for frame in frames: if self._stop_flag.is_set(): break if frame.pts is None: continue secs = float(frame.pts * video.time_base) if secs + 0.05 < self._seek_after: continue try: img = frame.to_image() except Exception: continue while not self._stop_flag.is_set(): with self._lock: if len(self._video_deque) < self._video_deque_max: self._video_deque.append((gen, secs, img)) break time.sleep(0.005) elif packet.stream.type == "audio" and audio is not None and packet.size: if not self._audio_ok: continue try: self._decode_audio_packet(packet, gen) except Exception as exc: self._disable_audio("音频解码出错(%s)" % exc) if self._resampler is not None and not self._stop_flag.is_set(): self._flush_resampler() except Exception as exc: self._eof = True print("解码线程异常退出:", exc) def _decode_audio_packet(self, packet, gen): try: frames = packet.decode() except Exception: return atb = self._audio_stream.time_base for frame in frames: if self._stop_flag.is_set(): return if frame.pts is not None: secs = float(frame.pts * atb) if secs + 0.05 < self._seek_after: continue if self._resampler is None: self._resampler = self._av.AudioResampler( format="fltp", layout="stereo", rate=self.AUDIO_RATE ) for out in self._resampler.resample(frame): try: chunk = out.to_ndarray().T except Exception: continue self._put_audio((gen, chunk)) def _put_audio(self, item): while not self._stop_flag.is_set(): try: self._audio_queue.put(item, timeout=0.1) return except queue.Full: if item[0] != self._gen: return def _flush_resampler(self): try: for out in self._resampler.resample(None): self._put_audio((self._gen, out.to_ndarray().T)) except Exception: pass def _audio_callback(self, outdata, frames, time_info, status): try: self._audio_callback_inner(outdata, frames) except Exception: self._audio_broken = True outdata[:] = 0 def _audio_callback_inner(self, outdata, frames): if self._paused: outdata[:] = 0 return outdata[:] = 0 n = 0 leftover = self._audio_leftover if leftover is not None and len(leftover): take = min(len(leftover), frames) outdata[:take] = leftover[:take] n = take self._audio_leftover = leftover[take:] if take < len(leftover) else None while n < frames: try: item = self._audio_queue.get_nowait() except queue.Empty: break gen, chunk = item if gen != self._gen: continue take = min(len(chunk), frames - n) outdata[n:n + take] = chunk[:take] n += take if take < len(chunk): self._audio_leftover = chunk[take:] outdata *= self._volume / 100.0 with self._lock: self._audio_written += frames @staticmethod def _drain_queue(q): while True: try: q.get_nowait() except queue.Empty: return class SeekBar(tk.Canvas): """自绘进度条:支持点击与拖动定位,跨平台表现一致。""" TRACK_COLOR = "#4a4a4a" FILL_COLOR = "#3f8efc" def __init__(self, master, height=24, command=None): super().__init__( master, height=height, bg="#2b2b2b", highlightthickness=0, cursor="hand2", ) self._command = command self._frac = 0.0 self._dragging = False self.bind("", lambda _e: self._redraw()) self.bind("", self._on_press) self.bind("", self._on_drag) self.bind("", self._on_release) self._redraw() def _width(self): return max(self.winfo_width(), 1) def _x_of_frac(self, frac): return 4 + frac * (self._width() - 8) def _frac_of_event(self, event): return min(max((event.x - 4) / max(self._width() - 8, 1), 0.0), 1.0) def set_frac(self, frac): if not self._dragging: self._frac = min(max(frac, 0.0), 1.0) self._redraw() def _redraw(self): self.delete("all") width, height = self._width(), max(self.winfo_height(), 1) y = height // 2 self.create_rectangle(2, y - 3, width - 2, y + 3, fill=self.TRACK_COLOR, outline="") x = self._x_of_frac(self._frac) if x > 4: self.create_rectangle(2, y - 3, x, y + 3, fill=self.FILL_COLOR, outline="") radius = 6 self.create_oval(x - radius, y - radius, x + radius, y + radius, fill=self.FILL_COLOR, outline="") def _on_press(self, event): self._dragging = True self._frac = self._frac_of_event(event) self._redraw() if self._command: self._command(self._frac, "start") def _on_drag(self, event): if not self._dragging: return self._frac = self._frac_of_event(event) self._redraw() if self._command: self._command(self._frac, "drag") def _on_release(self, event): if not self._dragging: return self._dragging = False self._frac = self._frac_of_event(event) self._redraw() if self._command: self._command(self._frac, "end") class MediaPlayerApp(tk.Tk): """播放器主窗口。""" def __init__(self): super().__init__() self.title("媒体播放器") self.geometry("960x600") self.minsize(720, 460) self.playlist = [] self.current_index = -1 self.volume = 80 self.rate = 1.0 self.loop_mode = "list" self._seeking = False self._switching_until = 0.0 self._fullscreen = False self._muted_volume = None self._photo = None self.engine = self._create_engine() if self.engine is None: messagebox.showerror( "无法启动", "未找到可用的播放内核。\n" "请先安装 python-vlc(并安装 VLC 播放器),或安装 pygame。", ) self.destroy() return self._build_ui() self._bind_shortcuts() if isinstance(self.engine, PygameEngine): self.status_var.set("pygame 音频模式:未检测到 VLC,视频功能不可用") elif isinstance(self.engine, AvEngine): self.status_var.set("纯 Python 解码内核:无需安装 VLC,可直接播放音视频") self._load_playlist() self.protocol("WM_DELETE_WINDOW", self._on_close) self.after(100, self._maybe_embed) self.after(200, self._tick) def _create_engine(self): if vlc is not None: try: return VlcEngine() except Exception as exc: print("VLC 内核初始化失败,回退到纯 Python 内核:", exc) try: return AvEngine() except Exception as exc: print("纯 Python 内核初始化失败,回退到 pygame:", exc) if pygame is not None: try: return PygameEngine() except Exception as exc: print("pygame 内核初始化失败:", exc) return None def _build_ui(self): style = ttk.Style(self) if "clam" in style.theme_names(): style.theme_use("clam") toolbar = ttk.Frame(self, padding=(8, 8)) toolbar.pack(fill="x") ttk.Button(toolbar, text="打开文件…", command=self.open_files, takefocus=0).pack(side="left", padx=(0, 4)) ttk.Button(toolbar, text="打开文件夹…", command=self.open_folder, takefocus=0).pack(side="left", padx=(0, 8)) self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0) self.btn_prev.pack(side="left", padx=2) self.btn_play = ttk.Button(toolbar, text="播放", command=self.toggle_play, width=8, takefocus=0) self.btn_play.pack(side="left", padx=2) self.btn_stop = ttk.Button(toolbar, text="停止", command=self.stop_playback, takefocus=0) self.btn_stop.pack(side="left", padx=2) self.btn_next = ttk.Button(toolbar, text="下一首", command=self.next_track, takefocus=0) self.btn_next.pack(side="left", padx=(2, 10)) self.btn_loop = ttk.Button(toolbar, text=LOOP_LABELS[self.loop_mode], command=self.cycle_loop, width=9, takefocus=0) self.btn_loop.pack(side="left", padx=2) self.btn_speed = ttk.Button(toolbar, text="速度 1x", command=self.cycle_speed, width=9, takefocus=0) self.btn_speed.pack(side="left", padx=2) ttk.Label(toolbar, text="音量").pack(side="right", padx=(10, 4)) self.volume_slider = ttk.Scale( toolbar, from_=0, to=100, length=110, value=self.volume, command=self._on_volume_slider, takefocus=0, ) self.volume_slider.pack(side="right", padx=(0, 4)) main = ttk.Frame(self, padding=(8, 0)) main.pack(fill="both", expand=True) main.columnconfigure(0, weight=1) main.rowconfigure(0, weight=1) self.video_frame = tk.Frame(main, bg="#101010") self.video_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) if isinstance(self.engine, AvEngine): self.video_canvas = tk.Canvas(self.video_frame, bg="#101010", highlightthickness=0) self.video_canvas.pack(fill="both", expand=True) hint_text = "打开文件开始播放\n(视频将显示在此窗口)" if not self.engine.EMBEDDABLE: hint_text = "打开文件开始播放\n(视频将在独立窗口中显示)" self.video_hint = tk.Label(self.video_frame, text=hint_text, bg="#101010", fg="#8a8a8a") self.video_hint.place(relx=0.5, rely=0.5, anchor="center") side = ttk.Frame(main, width=260) side.grid(row=0, column=1, sticky="ns") side.columnconfigure(0, weight=1) side.rowconfigure(0, weight=1) self.listbox = tk.Listbox( side, activestyle="none", selectmode="extended", selectbackground="#3f8efc", selectforeground="#ffffff", exportselection=False, highlightthickness=0, relief="flat", ) self.listbox.grid(row=0, column=0, sticky="nsew") scrollbar = ttk.Scrollbar(side, orient="vertical", command=self.listbox.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.listbox.configure(yscrollcommand=scrollbar.set) self._playlist_menu = tk.Menu(self, tearoff=0) self._playlist_menu.add_command(label="播放选中曲目", command=self.play_selected) self._playlist_menu.add_separator() self._playlist_menu.add_command(label="删除选中曲目", command=self.remove_selected) self._playlist_menu.add_command(label="清空播放列表", command=self.clear_playlist) if IS_MAC: self.listbox.bind("", self._show_playlist_menu) self.listbox.bind("", self._show_playlist_menu) self.listbox.bind("", self._show_playlist_menu) else: self.listbox.bind("", self._show_playlist_menu) bottom = ttk.Frame(self, padding=(8, 6)) bottom.pack(fill="x") self.seekbar = SeekBar(bottom, command=self._on_seek_command) self.seekbar.pack(fill="x") self.time_var = tk.StringVar(value="00:00 / --:--") self.status_var = tk.StringVar(value="就绪 — 打开文件或按 Ctrl+O") ttk.Label(bottom, textvariable=self.time_var).pack(side="left", anchor="w") ttk.Label(bottom, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True) def _bind_shortcuts(self): shortcuts = [ ("", self._on_space), ("", self._on_left), ("", self._on_right), ("", lambda _e: self.seek_relative(-SEEK_STEP_BIG)), ("", lambda _e: self.seek_relative(SEEK_STEP_BIG)), ("", lambda _e: self.prev_track()), ("", lambda _e: self.next_track()), ("", lambda _e: self.prev_track()), ("", lambda _e: self.next_track()), ("", self._on_up), ("", self._on_down), ("", lambda _e: self.toggle_mute()), ("", lambda _e: self.toggle_mute()), ("", lambda _e: self.open_files()), ("", lambda _e: self.open_files()), ("", lambda _e: self.open_folder()), ("", lambda _e: self.open_folder()), ("", lambda _e: self.stop_playback()), ("", lambda _e: self.stop_playback()), ("", lambda _e: self.stop_playback()), ("", lambda _e: self.stop_playback()), ("", lambda _e: self.next_track()), ("", lambda _e: self.next_track()), ("

", lambda _e: self.prev_track()), ("

", lambda _e: self.prev_track()), ("", lambda _e: self.cycle_loop()), ("", lambda _e: self.cycle_loop()), ("", lambda _e: self.step_speed(-1)), ("", lambda _e: self.step_speed(1)), ("", lambda _e: self.toggle_fullscreen()), ("", lambda _e: self.toggle_fullscreen()), ("", lambda _e: self.leave_fullscreen()), ("", lambda _e: self.remove_selected()), ("", lambda _e: self.play_selected()), ] for sequence, handler in shortcuts: self.bind(sequence, handler) self.listbox.bind(sequence, handler) self.listbox.bind("", lambda _e: self.play_selected()) def _on_space(self, _event): self.toggle_play() return "break" def _on_left(self, _event): self.seek_relative(-SEEK_STEP) return "break" def _on_right(self, _event): self.seek_relative(SEEK_STEP) return "break" def _on_up(self, _event): self.adjust_volume(5) return "break" def _on_down(self, _event): self.adjust_volume(-5) return "break" def _maybe_embed(self): if self.engine.EMBEDDABLE: try: self.engine.embed(self.video_frame.winfo_id()) except Exception: pass def _tick(self): if self.engine is not None: for event in self.engine.poll_events(): if event == "end": self._on_media_end() elif event == "error": self.status_var.set("无法播放该文件,请检查文件格式或 VLC 是否已安装") img = self.engine.get_frame() if img is not None: self._show_frame(img) pos = self.engine.get_time() length = self.engine.get_length() if pos >= 0 and not self._seeking: self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}") if length > 0: self.seekbar.set_frac(pos / length) playing = self.engine.is_playing() self.btn_play.config(text="暂停" if playing else "播放") self.after(33, self._tick) def _show_frame(self, img): if ImageTk is None: return canvas = getattr(self, "video_canvas", None) if canvas is None: return width = canvas.winfo_width() height = canvas.winfo_height() if width < 2 or height < 2: return iw, ih = img.size if iw < 1 or ih < 1: return scale = min(width / iw, height / ih) size = (max(1, int(iw * scale)), max(1, int(ih * scale))) if size != img.size: img = img.resize(size, PILImage.Resampling.BILINEAR) self._photo = ImageTk.PhotoImage(img) canvas.delete("all") canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center") def open_files(self): filetypes = [ ("媒体文件", " ".join("*" + ext for ext in sorted(SUPPORTED_EXTS))), ("所有文件", "*"), ] paths = filedialog.askopenfilenames(title="选择媒体文件", filetypes=filetypes) if paths: self.add_paths(list(paths)) def open_folder(self): folder = filedialog.askdirectory(title="选择包含媒体文件的文件夹") if folder: self.add_paths([folder]) def add_paths(self, paths): start = len(self.playlist) added = [] for item in paths: path = Path(item) if path.is_dir(): for child in sorted(path.rglob("*")): if child.is_file() and child.suffix.lower() in SUPPORTED_EXTS: added.append(str(child)) elif path.is_file() and path.suffix.lower() in SUPPORTED_EXTS: added.append(str(path)) if not added: self.status_var.set("所选路径中没有支持的媒体文件") return self.playlist.extend(added) for name in added: self.listbox.insert(tk.END, Path(name).name) if not self.engine.is_active(): self.play_index(start) else: self._update_status() self._save_playlist() def _playlist_path(self): return Path.home() / ".mediaplayer_playlist.json" def _save_playlist(self): try: data = {"playlist": list(self.playlist), "current_index": self.current_index} self._playlist_path().write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) except Exception as exc: print("保存播放列表失败:", exc) def _load_playlist(self): path = self._playlist_path() if not path.exists(): return try: data = json.loads(path.read_text(encoding="utf-8")) items = data.get("playlist", []) except Exception as exc: print("读取播放列表失败:", exc) return added = [] for item in items: p = Path(item) if p.is_file() and p.suffix.lower() in SUPPORTED_EXTS: added.append(str(p)) if not added: return self.playlist.extend(added) for name in added: self.listbox.insert(tk.END, Path(name).name) saved_index = data.get("current_index", -1) if 0 <= saved_index < len(self.playlist): self.current_index = saved_index self.listbox.selection_set(saved_index) self.listbox.see(saved_index) msg = f"已恢复上次的播放列表({len(added)} 个文件)" dropped = len(items) - len(added) if dropped: msg += f",已忽略 {dropped} 个不存在的文件" self.status_var.set(msg) def play_index(self, index): if not (0 <= index < len(self.playlist)): return self.current_index = index path = self.playlist[index] self._switching_until = time.monotonic() + 0.8 if self.engine.EMBEDDABLE: try: self.engine.embed(self.video_frame.winfo_id()) except Exception: pass try: ok = self.engine.play(path) except Exception: ok = False if not ok: self.status_var.set(f"无法播放:{Path(path).name}") return self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(index) self.listbox.see(index) if Path(path).suffix.lower() in VIDEO_EXTS and self.engine.EMBEDDABLE: self.video_hint.place_forget() else: self.video_hint.place(relx=0.5, rely=0.5, anchor="center") self.title(f"{Path(path).name} — 媒体播放器") self._update_status() self._save_playlist() def toggle_play(self): if not self.playlist: self.open_files() return if not self.engine.is_active(): self.play_index(self.current_index if self.current_index >= 0 else 0) return self.engine.toggle_pause() self._update_status() def stop_playback(self): self.engine.stop() self._seeking = False self.time_var.set("00:00 / --:--") self.seekbar.set_frac(0.0) self.btn_play.config(text="播放") canvas = getattr(self, "video_canvas", None) if canvas is not None: canvas.delete("all") self._photo = None self.video_hint.place(relx=0.5, rely=0.5, anchor="center") self._update_status() def next_track(self): if not self.playlist: self.open_files() return self.play_index((self.current_index + 1) % len(self.playlist)) def prev_track(self): if not self.playlist: self.open_files() return self.play_index((self.current_index - 1) % len(self.playlist)) def seek_relative(self, seconds): pos = self.engine.get_time() if pos < 0: return self.seek_to(pos + seconds * 1000) def seek_to(self, ms): ms = max(0, int(ms)) if not self.engine.seek(ms): self.status_var.set("当前文件格式不支持定位") def _on_seek_command(self, frac, phase): length = self.engine.get_length() if length <= 0: return target = int(frac * length) if phase in ("start", "drag"): self._seeking = True self.time_var.set(f"{fmt_time(target)} / {fmt_time(length)}") else: self._seeking = False self.seek_to(target) def adjust_volume(self, delta): self.volume = max(0, min(100, self.volume + delta)) self.volume_slider.set(self.volume) self.engine.set_volume(self.volume) if self.volume > 0: self._muted_volume = None self._update_status() def _on_volume_slider(self, value): self.volume = max(0, min(100, int(float(value)))) self.engine.set_volume(self.volume) if self.volume > 0: self._muted_volume = None self._update_status() def toggle_mute(self): if self.volume > 0: self._muted_volume = self.volume self.adjust_volume(-self.volume) else: self.adjust_volume(self._muted_volume or 80) def cycle_loop(self): order = ["off", "one", "list"] self.loop_mode = order[(order.index(self.loop_mode) + 1) % len(order)] self.btn_loop.config(text=LOOP_LABELS[self.loop_mode]) self._update_status() def cycle_speed(self): self.step_speed(1) def step_speed(self, direction): if not getattr(self.engine, "supports_rate", True): self.status_var.set("当前播放内核不支持变速") return index = SPEED_PRESETS.index(self.rate) if self.rate in SPEED_PRESETS else SPEED_PRESETS.index(1.0) index = (index + direction) % len(SPEED_PRESETS) self.rate = SPEED_PRESETS[index] self.engine.set_rate(self.rate) self.btn_speed.config(text=f"速度 {self.rate:g}x") self._update_status() def toggle_fullscreen(self): self._fullscreen = not self._fullscreen if isinstance(self.engine, AvEngine): self.attributes("-fullscreen", self._fullscreen) else: self.engine.set_fullscreen(self._fullscreen) def leave_fullscreen(self): self._fullscreen = False if isinstance(self.engine, AvEngine): self.attributes("-fullscreen", False) else: self.engine.set_fullscreen(False) def play_selected(self): selection = self.listbox.curselection() if selection: self.play_index(selection[0]) def remove_selected(self): selection = list(self.listbox.curselection()) if not selection: return if self.current_index in selection: self.engine.stop() self.current_index = -1 for index in reversed(selection): self.playlist.pop(index) self.listbox.delete(index) self._update_status() self._save_playlist() def _show_playlist_menu(self, event): has_selection = bool(self.listbox.curselection()) self._playlist_menu.entryconfig("播放选中曲目", state="normal" if has_selection else "disabled") self._playlist_menu.entryconfig("删除选中曲目", state="normal" if has_selection else "disabled") try: self._playlist_menu.tk_popup(event.x_root, event.y_root) finally: self._playlist_menu.grab_release() def clear_playlist(self): if not self.playlist: return if not messagebox.askyesno("清空播放列表", "确定要清空整个播放列表吗?"): return self.stop_playback() self.playlist.clear() self.listbox.delete(0, tk.END) self.current_index = -1 self._save_playlist() self.status_var.set("播放列表已清空") def _update_status(self): parts = [f"循环:{LOOP_LABELS[self.loop_mode].split(':')[1].strip()}"] parts.append(f"速度 {self.rate:g}x") parts.append(f"音量 {self.volume}") if isinstance(self.engine, PygameEngine): parts.append("pygame 音频模式(未检测到 VLC,视频不可用)") elif isinstance(self.engine, AvEngine): parts.append("纯 Python 解码内核(无需 VLC)") if 0 <= self.current_index < len(self.playlist): name = Path(self.playlist[self.current_index]).name total = len(self.playlist) parts.insert(0, f"正在播放:{name}(第 {self.current_index + 1}/{total} 首)") self.status_var.set("|".join(parts)) def _on_media_end(self): if time.monotonic() < self._switching_until: return if not self.playlist or not self.engine.is_active(): return if self.loop_mode == "one": self.play_index(self.current_index) return next_index = self.current_index + 1 if next_index < len(self.playlist): self.play_index(next_index) elif self.loop_mode == "list": self.play_index(0) else: self.stop_playback() self.status_var.set("播放结束") def _on_close(self): self._save_playlist() self.engine.release() self.destroy() def main(): app = MediaPlayerApp() if app.engine is None: return if sys.argv[1:]: app.add_paths(sys.argv[1:]) app.mainloop() if __name__ == "__main__": main()