#!/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 Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓) , / . 上一句 / 下一句 勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句 E 讲解当前句子(AI) M 静音 / 恢复 Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →) N / P 下一首 / 上一首 [ / ] 减速 / 加速(0.5x - 2.0x) L 切换循环模式(关 / 单曲 / 列表) F 视频全屏切换 Esc 退出全屏 Ctrl+O 打开文件(macOS 也支持 Command+O) Delete 从播放列表移除所选曲目 Enter 播放列表中选中的曲目 """ import collections import hashlib import json import os import queue import sys import tempfile import threading import time import requests from typing import Any, Dict, Optional 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 DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn" CLIENT_NAME = "MediaPlayer/1.0 (Desktop)" def sha256_file(path: Path, progress_callback=None) -> str: digest = hashlib.sha256() total_size = max(1, path.stat().st_size) processed = 0 with path.open("rb") as media_file: for chunk in iter(lambda: media_file.read(1024 * 1024), b""): digest.update(chunk) processed += len(chunk) if progress_callback: progress_callback(min(1.0, processed / total_size)) return digest.hexdigest() def extract_audio_to_wav(path: Path, output_path: Path, progress_callback=None) -> None: """Extract one mono 16 kHz audio track before uploading it to the server.""" import av with av.open(str(path)) as source: source_stream = next((s for s in source.streams if s.type == "audio"), None) if source_stream is None: raise RuntimeError("文件中没有音频轨") total_seconds = source.duration / 1_000_000 if source.duration and source.duration > 0 else 0.0 output_seconds = 0.0 resampler = av.AudioResampler(format="s16", layout="mono", rate=16_000) with av.open(str(output_path), "w", format="wav") as target: target_stream = target.add_stream("pcm_s16le", rate=16_000, layout="mono") for packet in source.demux(source_stream): for frame in packet.decode(): for resampled in resampler.resample(frame): output_seconds += len(resampled.samples) / resampled.sample_rate if progress_callback and total_seconds > 0: progress_callback(min(1.0, output_seconds / total_seconds)) for encoded in target_stream.encode(resampled): target.mux(encoded) for resampled in resampler.resample(None): for encoded in target_stream.encode(resampled): target.mux(encoded) for encoded in target_stream.encode(None): target.mux(encoded) 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 settings = self._load_settings() self.seek_step = settings["seek_step"] # Sentence mode is always re-enabled manually after boundaries exist. self.sentence_mode = tk.BooleanVar(value=False) self.server_url = settings["server_url"].rstrip("/") self.auth_token = settings["auth_token"] self.loop_mode = "list" self._seeking = False self._switching_until = 0.0 self._fullscreen = False self._muted_volume = None self._photo = None self._boundaries = {} self._media_hashes = {} self._analysis_running = set() self._analysis_done = queue.Queue() self._ui_events = queue.Queue() self._controls_media_path = None self._generation_token = 0 self._explanation_window = None self._explanation_generation = 0 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)) ttk.Button(toolbar, text="服务…", command=self.configure_server, takefocus=0).pack(side="left") 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") controls = ttk.Frame(bottom) controls.pack(fill="x", pady=(4, 0)) self.time_var = tk.StringVar(value="00:00 / --:--") self.status_var = tk.StringVar(value="就绪 — 打开文件或按 Ctrl+O") self.seek_step_var = tk.StringVar(value=str(self.seek_step)) ttk.Label(controls, textvariable=self.time_var).pack(side="left", anchor="w") ttk.Label(controls, text="快进步长:").pack(side="left", padx=(16, 4)) self.seek_step_spin = ttk.Spinbox( controls, from_=1, to=600, increment=1, width=5, textvariable=self.seek_step_var, command=self._commit_seek_step, ) self.seek_step_spin.pack(side="left") ttk.Label(controls, text="秒").pack(side="left", padx=(4, 0)) self.sentence_mode_btn = ttk.Checkbutton( controls, text="按句跳转", variable=self.sentence_mode, command=self._on_sentence_mode, ) self.sentence_mode_btn.pack(side="left", padx=(16, 0)) self.btn_generate_boundaries = ttk.Button( controls, text="生成句边界", command=self.generate_sentence_boundaries, takefocus=0, ) self.btn_generate_boundaries.pack(side="left", padx=(12, 0)) self.btn_ai_explain = ttk.Button( controls, text="AI讲解 (E)", command=self.show_ai_explanation, takefocus=0, ) self.btn_ai_explain.pack(side="left", padx=(12, 0)) self.sentence_var = tk.StringVar(value="") self.sentence_label = ttk.Label( controls, textvariable=self.sentence_var, anchor="w", width=32, ) self.sentence_label.pack(side="left", padx=(10, 0)) self.boundary_progress = ttk.Progressbar(bottom, mode="determinate", maximum=100) self.boundary_progress.pack(fill="x", pady=(5, 0)) self.seek_step_spin.bind("", self._on_seek_step_return) self.seek_step_spin.bind("", lambda _e: self._commit_seek_step()) ttk.Label(controls, 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.adjust_seek_step(1)), ("", lambda _e: self.adjust_seek_step(-1)), ("", lambda _e: self.adjust_seek_step(1)), ("", lambda _e: self.adjust_seek_step(-1)), ("", 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.seek_sentence(-1)), ("", lambda _e: self.seek_sentence(1)), ("", lambda _e: self.show_ai_explanation()), ("", lambda _e: self.show_ai_explanation()), ("", 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: wrapped = self._break_handler(handler) self.bind(sequence, wrapped) self.listbox.bind(sequence, wrapped) self.listbox.bind("", lambda _e: self.play_selected()) @staticmethod def _break_handler(handler): """包装快捷键回调:执行后返回 "break",阻止事件继续冒泡造成重复触发。""" def run(event): handler(event) return "break" return run def _on_space(self, _event): self.toggle_play() return "break" def _on_left(self, _event): if self.sentence_mode.get() and self._seek_sentence(-1): return "break" self.seek_relative(-self.seek_step) return "break" def _on_right(self, _event): if self.sentence_mode.get() and self._seek_sentence(1): return "break" self.seek_relative(self.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: current_path = self._current_path() if current_path != self._controls_media_path: self._update_sentence_controls() while True: try: done_path, message = self._analysis_done.get_nowait() except queue.Empty: break boundaries = self._boundaries.get(done_path) self.status_var.set(message or ( f"句边界已就绪:共 {len((boundaries or {}).get('sentences', []))} 句" )) if done_path == self._current_path(): self._update_sentence_controls() while True: try: callback = self._ui_events.get_nowait() except queue.Empty: break callback() 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) sentence = self._current_sentence() text = str(sentence.get("text") or "").strip() if sentence else "" self.sentence_var.set(text) 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 _settings_path(self): return Path.home() / ".mediaplayer_settings.json" def _load_settings(self): try: data = json.loads(self._settings_path().read_text(encoding="utf-8")) except Exception: data = {} try: step = int(data.get("seek_step", SEEK_STEP)) except (TypeError, ValueError): step = SEEK_STEP if not 1 <= step <= 600: step = SEEK_STEP server_url = str(data.get("server_url") or DEFAULT_SERVER_URL).strip() if server_url.lower().startswith("http://") or server_url.lower().startswith("https://"): server_url = server_url.rstrip("/") else: server_url = DEFAULT_SERVER_URL return { "seek_step": step, "sentence_mode": bool(data.get("sentence_mode", False)), "server_url": server_url, "auth_token": str(data.get("auth_token") or "").strip(), } def _save_settings(self): try: self._settings_path().write_text( json.dumps( { "seek_step": self.seek_step, "sentence_mode": bool(self.sentence_mode.get()), "server_url": self.server_url, "auth_token": self.auth_token, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) except Exception as exc: print("保存设置失败:", exc) 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() if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"): self.sentence_mode.set(False) self.status_var.set("请先生成句子边界") self._update_sentence_controls() 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.sentence_var.set("") 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_sentence_controls() 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 _commit_seek_step(self): raw = self.seek_step_var.get().strip() try: value = int(float(raw)) except ValueError: value = 0 if not 1 <= value <= 600: self.seek_step_var.set(str(self.seek_step)) self.status_var.set("快进步长需为 1-600 之间的整数") return if value != self.seek_step: self.seek_step = value self._save_settings() self.status_var.set(f"快进步长已设为 {value} 秒(已保存)") def adjust_seek_step(self, delta): value = max(1, min(600, self.seek_step + delta)) if value == self.seek_step: self.status_var.set(f"快进步长已到{'上限' if delta > 0 else '下限'}({value} 秒)") return self.seek_step = value self.seek_step_var.set(str(value)) self._save_settings() self.status_var.set(f"快进步长已设为 {value} 秒(已保存)") def _on_seek_step_return(self, _event): self._commit_seek_step() self.focus_set() return "break" def _current_path(self): if 0 <= self.current_index < len(self.playlist): return self.playlist[self.current_index] return None def _update_sentence_controls(self): path = self._current_path() self._controls_media_path = path has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences")) generating = bool(self._analysis_running) if not path: self.btn_generate_boundaries.config(state="disabled", text="生成句边界") elif generating: self.btn_generate_boundaries.config(state="disabled", text="生成中…") else: self.btn_generate_boundaries.config( state="normal", text="重新生成" if has_boundaries else "生成句边界", ) def _set_progress(self, value=None, message=None, indeterminate=False): if not hasattr(self, "boundary_progress"): return if indeterminate: self.boundary_progress.configure(mode="indeterminate") self.boundary_progress.start(14) else: self.boundary_progress.stop() self.boundary_progress.configure(mode="determinate") if value is not None: self.boundary_progress.configure(value=max(0, min(100, float(value)))) if message is not None: self.status_var.set(message) def _report_progress(self, value, message=None, indeterminate=False): self._ui_events.put( lambda: self._set_progress(value, message, indeterminate) ) def _on_sentence_mode(self): path = self._current_path() has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences")) if self.sentence_mode.get() and not has_boundaries: self.sentence_mode.set(False) self.status_var.set("请先生成句子边界") self._save_settings() return self._save_settings() if not self.sentence_mode.get(): self.status_var.set("按句跳转已关闭") return count = len(self._boundaries[path]["sentences"]) self.status_var.set(f"按句跳转已开启(识别到 {count} 句)") def generate_sentence_boundaries(self): path = self._current_path() if path is None: messagebox.showinfo("生成句边界", "请先打开并选择一个媒体文件。") return if self._analysis_running: messagebox.showinfo("生成句边界", "当前正在生成句边界,请稍候。") return if not self.server_url: messagebox.showwarning("生成句边界", "请先在“服务…”中配置服务器地址。") return if (self._boundaries.get(path) or {}).get("sentences"): if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"): return self.sentence_mode.set(False) self._save_settings() self._analysis_running.add(path) self._generation_token += 1 token = self._generation_token self._set_progress(0, "正在准备生成句边界…") self._update_sentence_controls() threading.Thread( target=self._analyze_sentences, args=(path, token), daemon=True, ).start() def _analyze_sentences(self, path, token): error = "" document = None source = "generated" audio_path = None media_hash = None try: self._report_progress(4, "正在计算文件哈希…") media_hash = sha256_file( Path(path), progress_callback=lambda ratio: self._report_progress( 4 + ratio * 12, "正在计算文件哈希…", ), ) self._media_hashes[path] = media_hash self._report_progress(18, "正在检查线上是否已有句边界…") remote_document = self._fetch_remote_boundaries(media_hash) if remote_document and remote_document.get("sentences"): document = remote_document source = "server" self._save_cached_boundaries(media_hash, document) else: document = self._load_cached_boundaries(media_hash) if document and document.get("sentences"): source = "local" else: self._report_progress(25, "线上没有句边界,正在准备音频…") audio_path = self._make_audio_upload( path, progress_callback=lambda ratio: self._report_progress( 25 + ratio * 20, "正在提取音频轨…", ), ) document = self._generate_remote_boundaries( audio_path, progress_callback=lambda ratio: ( self._report_progress( 85, "服务器正在生成句边界…", indeterminate=True, ) if ratio < 0 else self._report_progress( 45 + ratio * 35, f"正在上传音频… {ratio * 100:.0f}%", ) ), ) self._save_cached_boundaries(media_hash, document) source = "generated" except Exception as exc: error = str(exc) or exc.__class__.__name__ finally: if audio_path is not None: try: Path(audio_path).unlink(missing_ok=True) except OSError: pass self._analysis_running.discard(path) if document is not None and document.get("sentences"): self._boundaries[path] = document count = len(document["sentences"]) if source == "server": message = f"已取回线上句边界:共 {count} 句,并保存到本地" elif source == "local": message = f"已加载本地句边界缓存:共 {count} 句" else: message = f"句边界已生成:共 {count} 句,已保存到本地" self._report_progress(100, message) else: self._boundaries[path] = {"sentences": []} message = error or "服务器未能识别句子边界" self._report_progress(0, message) self._report_progress(None, None, indeterminate=False) self._ui_events.put(lambda: self._update_sentence_controls()) self._analysis_done.put((path, message)) def _boundary_cache_path(self, media_hash: str) -> Path: return Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.json" def _load_cached_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]: path = self._boundary_cache_path(media_hash) try: data = json.loads(path.read_text(encoding="utf-8")) sentences = data["sentences"] if not isinstance(sentences, list): return None cleaned = [] previous_end = 0 for index, item in enumerate(sentences): start = max(0, int(item["start_ms"])) end = int(item["end_ms"]) if end <= start or start < previous_end: raise ValueError("invalid cached boundary") cleaned.append({ "index": index, "start_ms": start, "end_ms": end, "text": (str(item["text"]) if item.get("text") is not None else None), "language": item.get("language"), }) previous_end = end if not cleaned: return None return {"sentences": cleaned, "duration_ms": int(data.get("duration_ms") or previous_end)} except Exception: return None def _save_cached_boundaries(self, media_hash: str, document: Dict[str, Any]) -> None: path = self._boundary_cache_path(media_hash) path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(".tmp") payload = { "version": 1, "media_sha256": media_hash, "duration_ms": int(document.get("duration_ms") or 0), "sentences": document.get("sentences", []), } temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") temporary.replace(path) def _make_audio_upload(self, path, progress_callback=None): source = Path(path) if source.suffix.lower() in AUDIO_EXTS: if progress_callback: progress_callback(1.0) return source handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False) output = Path(handle.name) handle.close() extract_audio_to_wav(source, output, progress_callback) return output def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]: headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"} if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" response = requests.get( self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries", headers=headers, timeout=(15, 30), ) if response.status_code == 404: return None if response.status_code == 401: raise RuntimeError("请先在“服务…”中登录") if response.status_code not in range(200, 300): raise RuntimeError( f"线上句边界查询返回 {response.status_code}:{self._server_error_detail(response)}" ) data = response.json() if not data.get("sentences"): return None return data def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]: filename = "audio" + Path(audio_path).suffix.lower() content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream" total_size = max(1, Path(audio_path).stat().st_size) boundary = f"mediaplayer-{os.urandom(12).hex()}" headers = { "User-Agent": CLIENT_NAME, "Content-Type": f"multipart/form-data; boundary={boundary}", } if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" def multipart_chunks(): prefix = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' f"Content-Type: {content_type}\r\n\r\n" ).encode("utf-8") yield prefix processed = 0 with Path(audio_path).open("rb") as audio_file: while True: chunk = audio_file.read(256 * 1024) if not chunk: break yield chunk processed += len(chunk) if progress_callback: progress_callback(min(1.0, processed / total_size)) if progress_callback: progress_callback(-1.0) yield f"\r\n--{boundary}--\r\n".encode("utf-8") with Path(audio_path).open("rb") as audio_file: response = requests.post( self.server_url + "/api/v1/audio/sentence-boundaries", headers=headers, data=multipart_chunks(), timeout=(15, 3600), ) if response.status_code == 401: raise RuntimeError("请先在“服务…”中登录") if response.status_code not in range(200, 300): detail = self._server_error_detail(response) raise RuntimeError(f"句边界服务返回 {response.status_code}:{detail}") data = response.json() if not data.get("sentences"): raise RuntimeError("服务器未能识别句子边界") return data @staticmethod def _server_error_detail(response) -> str: try: data = response.json() detail = data.get("detail", response.text) except Exception: detail = response.text detail = str(detail) return detail if len(detail) <= 300 else detail[:297] + "..." def _current_sentence(self) -> Optional[Dict[str, Any]]: path = self._current_path() if path is None: return None sentences = (self._boundaries.get(path) or {}).get("sentences") or [] pos = self.engine.get_time() if pos < 0 or not sentences: return None pos_ms = max(0, int(pos)) current = None for sentence in sentences: if pos_ms + 150 < int(sentence["start_ms"]): break current = sentence return current def seek_sentence(self, direction): return self._seek_sentence(direction) def _seek_sentence(self, direction): path = self._current_path() if path is None: return False sentences = (self._boundaries.get(path) or {}).get("sentences") or [] if not sentences: return False pos = self.engine.get_time() if pos < 0: return False pos_sec = pos / 1000.0 if direction > 0: for sentence in sentences: boundary = int(sentence["start_ms"]) / 1000.0 if boundary > pos_sec + 0.15: self.seek_to(int(sentence["start_ms"])) return True return False target = 0.0 target_ms = 0 for sentence in reversed(sentences): boundary = int(sentence["start_ms"]) / 1000.0 if boundary < pos_sec - 0.25: target = boundary target_ms = int(sentence["start_ms"]) break if target > 0.0 or pos_sec > 0.25: self.seek_to(target_ms) return True return False 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 configure_server(self): window = tk.Toplevel(self) window.title("服务配置") window.geometry("420x230") window.transient(self) window.resizable(False, False) frame = ttk.Frame(window, padding=16) frame.pack(fill="both", expand=True) frame.columnconfigure(1, weight=1) ttk.Label(frame, text="服务器地址").grid(row=0, column=0, sticky="w", pady=5) server_var = tk.StringVar(value=self.server_url) ttk.Entry(frame, textvariable=server_var, width=40).grid(row=0, column=1, sticky="ew") ttk.Label(frame, text="账号").grid(row=1, column=0, sticky="w", pady=5) username_var = tk.StringVar() ttk.Entry(frame, textvariable=username_var).grid(row=1, column=1, sticky="ew") ttk.Label(frame, text="密码").grid(row=2, column=0, sticky="w", pady=5) password_var = tk.StringVar() ttk.Entry(frame, textvariable=password_var, show="*").grid(row=2, column=1, sticky="ew") ttk.Label(frame, text="登录令牌").grid(row=3, column=0, sticky="w", pady=5) token_var = tk.StringVar(value=self.auth_token) ttk.Entry(frame, textvariable=token_var, show="*").grid(row=3, column=1, sticky="ew") message = tk.StringVar(value="已有令牌可直接保存;没有令牌时输入账号密码登录。") ttk.Label(frame, textvariable=message, wraplength=360, foreground="#666666").grid( row=4, column=0, columnspan=2, sticky="ew", pady=(12, 8) ) buttons = ttk.Frame(frame) buttons.grid(row=5, column=0, columnspan=2, sticky="e") def save(): url = server_var.get().strip().rstrip("/") if not url.lower().startswith(("http://", "https://")): messagebox.showerror("服务配置", "服务器地址必须以 http:// 或 https:// 开头。", parent=window) return self.server_url = url self.auth_token = token_var.get().strip() self._save_settings() window.destroy() self.status_var.set("服务配置已保存") def login_and_save(): url = server_var.get().strip().rstrip("/") try: response = requests.post( url + "/api/v1/auth/login", json={"username": username_var.get().strip(), "password": password_var.get()}, headers={"User-Agent": CLIENT_NAME}, timeout=(10, 20), ) if response.status_code not in range(200, 300): raise RuntimeError(self._server_error_detail(response)) token_var.set(response.json()["token"]) message.set("登录成功,令牌已保存。") self.server_url = url self.auth_token = token_var.get().strip() self._save_settings() window.after(500, window.destroy) except Exception as exc: messagebox.showerror("登录失败", str(exc), parent=window) ttk.Button(buttons, text="登录并保存", command=login_and_save, takefocus=0).pack(side="left", padx=(0, 8)) ttk.Button(buttons, text="保存", command=save, takefocus=0).pack(side="left") def show_ai_explanation(self): if not self.server_url: self.configure_server() return sentence = self._current_sentence() if not sentence: self.status_var.set("当前没有可用于讲解的句子边界") return sentence_text = str(sentence.get("text") or "").strip() if not sentence_text: self.status_var.set("当前句子没有文字内容,AI 讲解不可用") return self._explanation_generation += 1 generation = self._explanation_generation if self._explanation_window is not None: try: self._explanation_window.destroy() except tk.TclError: pass window = tk.Toplevel(self) window.title("AI 句子讲解") window.geometry("480x380") window.transient(self) frame = ttk.Frame(window, padding=14) frame.pack(fill="both", expand=True) frame.columnconfigure(0, weight=1) frame.rowconfigure(1, weight=1) ttk.Label(frame, text=sentence_text, wraplength=440, justify="left", font=("Helvetica", 12, "bold")).grid( row=0, column=0, sticky="ew", pady=(0, 10) ) text = tk.Text(frame, wrap="word", state="disabled", relief="flat", background="#f6f6f6") text.grid(row=1, column=0, sticky="nsew") text.insert("1.0", "AI 正在讲解这句话…") ttk.Button(frame, text="关闭", command=window.destroy, takefocus=0).grid(row=2, column=0, sticky="e", pady=(10, 0)) self._explanation_window = window path = self._current_path() or "" course_title = Path(path).stem threading.Thread( target=self._explain_sentence_worker, args=(sentence, course_title, generation), daemon=True, ).start() def _explain_sentence_worker(self, sentence, course_title, generation): try: headers = {"Content-Type": "application/json", "User-Agent": CLIENT_NAME} if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" payload = { "sentence_text": sentence.get("text", ""), "course_title": course_title, "sentence_index": sentence.get("index"), "language": sentence.get("language"), } response = requests.post( self.server_url + "/api/v1/sentences/explain", headers=headers, json=payload, timeout=(15, 90), ) if response.status_code == 401: raise RuntimeError("登录已过期,请在“服务…”中重新登录") if response.status_code not in range(200, 300): raise RuntimeError(f"AI 讲解服务返回 {response.status_code}:{self._server_error_detail(response)}") explanation = response.json()["explanation"] self._ui_events.put(lambda: self._show_explanation_result(explanation, generation)) except Exception as exc: message = str(exc) or exc.__class__.__name__ self._ui_events.put(lambda: self._show_explanation_result("讲解失败:" + message, generation, error=True)) def _show_explanation_result(self, explanation, generation, error=False): if generation != self._explanation_generation: return window = self._explanation_window if window is None: return try: text = window.nametowidget(window.grid_slaves(row=1, column=0)[0]) text.configure(state="normal") text.delete("1.0", "end") text.insert("1.0", explanation) text.configure(state="disabled") if error: window.title("AI 讲解失败") except (tk.TclError, IndexError): pass 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()