optimized PC player

This commit is contained in:
2026-09-11 12:15:51 +08:00
parent 07261d092b
commit 4b71c6c615
5 changed files with 317 additions and 84 deletions

View File

@@ -35,6 +35,7 @@ import json
import logging
from logging.handlers import RotatingFileHandler
import os
import re
import queue
import sys
import tempfile
@@ -86,6 +87,61 @@ DEBUG_LOG_DIR = Path.home() / ".mediaplayer_logs"
DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log"
_debug_logger = logging.getLogger("mediaplayer")
_SPEAKER_TAG_RE = re.compile(r"\[(S\d+)\]")
def _split_speaker_tagged_sentences(document: Dict[str, Any]) -> Dict[str, Any]:
"""Split sentences that still contain inline ``[S01]`` speaker tags.
The server should already strip these tags and split at speaker boundaries
(see ``split_sentences_at_punctuation``), but locally cached boundaries
from an older server version may still have the tags embedded. This
post-processing ensures the player always uses clean, per-phrase sentences
for playback navigation, AI explanation, and every other operation.
"""
sentences = document.get("sentences")
if not isinstance(sentences, list) or not sentences:
return document
needs_split = any(_SPEAKER_TAG_RE.search(str(s.get("text") or "")) for s in sentences)
if not needs_split:
return document
cleaned: list = []
for sentence in sentences:
text = str(sentence.get("text") or "")
matches = list(_SPEAKER_TAG_RE.finditer(text))
if not matches:
cleaned.append(sentence)
continue
start_ms = int(sentence["start_ms"])
end_ms = int(sentence["end_ms"])
duration_ms = max(1, end_ms - start_ms)
total = len(matches)
for index, match in enumerate(matches):
piece_start = match.end()
if index + 1 < total:
piece_end = matches[index + 1].start()
else:
piece_end = len(text)
piece = text[piece_start:piece_end].strip()
if not piece:
continue
sub_start = start_ms + int(duration_ms * index / total)
sub_end = start_ms + int(duration_ms * (index + 1) / total)
if sub_end <= sub_start:
sub_end = sub_start + 1
cleaned.append({
**sentence,
"start_ms": sub_start,
"end_ms": sub_end,
"text": piece,
})
for index, sentence in enumerate(cleaned):
sentence["index"] = index
return {**document, "sentences": cleaned}
def setup_debug_logging() -> Path:
"""Write concise diagnostics to a small local rotating log."""
@@ -1103,8 +1159,12 @@ class MediaPlayerApp(tk.Tk):
text="按句跳转",
variable=self.sentence_mode,
command=self._on_sentence_mode,
takefocus=0,
)
self.sentence_mode_btn.pack(side="left", padx=(16, 0))
# 点击 Checkbutton 后 tk 仍会把焦点给它;把空格键重定向到播放/暂停,
# 避免误改复选按钮状态。
self.sentence_mode_btn.bind("<space>", self._on_space)
self.btn_generate_boundaries = ttk.Button(
controls,
text="生成句边界",
@@ -1172,6 +1232,8 @@ class MediaPlayerApp(tk.Tk):
("<period>", lambda _e: self.seek_sentence(1)),
("<e>", lambda _e: self.show_ai_explanation()),
("<E>", lambda _e: self.show_ai_explanation()),
("<a>", lambda _e: self.show_ai_explanation()),
("<A>", lambda _e: self.show_ai_explanation()),
("<f>", lambda _e: self.toggle_fullscreen()),
("<F>", lambda _e: self.toggle_fullscreen()),
("<Escape>", lambda _e: self.leave_fullscreen()),
@@ -1622,7 +1684,7 @@ class MediaPlayerApp(tk.Tk):
document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"):
self._media_hashes[path] = media_hash
self._boundaries[path] = document
self._boundaries[path] = _split_speaker_tagged_sentences(document)
count = len(document["sentences"])
message = f"已加载本地句边界缓存:共 {count} 句"
debug_log(
@@ -1714,6 +1776,9 @@ class MediaPlayerApp(tk.Tk):
if (self._boundaries.get(path) or {}).get("sentences"):
if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"):
return
force = True
else:
force = False
self.sentence_mode.set(False)
self._save_settings()
self._analysis_running.add(path)
@@ -1723,11 +1788,11 @@ class MediaPlayerApp(tk.Tk):
self._update_sentence_controls()
threading.Thread(
target=self._analyze_sentences,
args=(path, token),
args=(path, token, force),
daemon=True,
).start()
def _analyze_sentences(self, path, token):
def _analyze_sentences(self, path, token, force=False):
error = ""
document = None
source = "generated"
@@ -1744,48 +1809,56 @@ class MediaPlayerApp(tk.Tk):
)
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)
if force:
self._report_progress(18, "正在重新生成句边界…")
else:
self._report_progress(18, "正在检查线上是否已有句边界…")
remote_document = self._fetch_remote_boundaries(media_hash)
if remote_document and remote_document.get("sentences"):
document = remote_document
document = _split_speaker_tagged_sentences(document)
source = "server"
self._save_cached_boundaries(media_hash, document)
if document is None and not force:
document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"):
source = "local"
else:
if document is None:
if not force:
self._report_progress(25, "线上没有句边界,正在准备音频…")
debug_log("audio_extraction_started", media_hash=media_hash)
audio_path = self._make_audio_upload(
path,
progress_callback=lambda ratio: self._report_progress(
25 + ratio * 20,
"正在提取音频轨…",
),
)
debug_log(
"audio_extraction_ready",
media_hash=media_hash,
audio_bytes=Path(audio_path).stat().st_size,
)
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"
else:
self._report_progress(25, "正在重新提取音频…")
debug_log("audio_extraction_started", media_hash=media_hash, force=force)
audio_path = self._make_audio_upload(
path,
progress_callback=lambda ratio: self._report_progress(
25 + ratio * 20,
"正在提取音频轨…",
),
)
debug_log(
"audio_extraction_ready",
media_hash=media_hash,
audio_bytes=Path(audio_path).stat().st_size,
)
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}%",
)
),
)
document = _split_speaker_tagged_sentences(document)
self._save_cached_boundaries(media_hash, document)
source = "generated"
except Exception as exc:
error = str(exc) or exc.__class__.__name__
_debug_logger.exception(
@@ -1800,6 +1873,7 @@ class MediaPlayerApp(tk.Tk):
self._analysis_running.discard(path)
if document is not None and document.get("sentences"):
document = _split_speaker_tagged_sentences(document)
self._boundaries[path] = document
count = len(document["sentences"])
if source == "server":
@@ -2367,54 +2441,138 @@ class MediaPlayerApp(tk.Tk):
pass
window = tk.Toplevel(self)
window.title("AI 句子讲解")
window.geometry("480x380")
window.geometry("1000x800")
window.minsize(1000, 800)
window.configure(background="#1e1e2e")
window.transient(self)
frame = ttk.Frame(window, padding=14)
# Dark theme palette.
bg = "#1e1e2e"
card_bg = "#2a2a3d"
fg_primary = "#cdd6f4"
fg_secondary = "#9399b2"
accent = "#89b4fa"
accent_hover = "#74c7ec"
frame = tk.Frame(window, bg=bg, padx=16, pady=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",
foreground="#111111",
font=("Helvetica", 12, "bold"),
).grid(
row=0, column=0, sticky="ew", pady=(0, 10)
frame.rowconfigure(2, weight=1)
font_size = [20]
# ── Top toolbar panel ─────────────────────────────────────────────
# Fixed-height, always visible: title | A− 20pt A+ | ✕
toolbar = tk.Frame(frame, bg=card_bg, padx=12, pady=8)
toolbar.grid(row=0, column=0, sticky="ew", pady=(0, 10))
toolbar.columnconfigure(1, weight=1)
tk.Label(
toolbar,
text="AI 句子讲解",
bg=card_bg,
fg=fg_primary,
font=("Helvetica", 13, "bold"),
).grid(row=0, column=0, sticky="w")
font_controls = tk.Frame(toolbar, bg=card_bg)
font_controls.grid(row=0, column=2, sticky="e")
def _apply_font_size():
size_label.configure(text=f"{font_size[0]}pt")
sentence_label.configure(font=("Helvetica", font_size[0], "bold"))
text.configure(font=("Helvetica", font_size[0]))
def _set_font_size(delta):
font_size[0] = max(12, min(36, font_size[0] + delta))
_apply_font_size()
tk.Button(
font_controls, text="A\u2212", command=lambda: _set_font_size(-2),
bg=accent, fg=bg, activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=10, pady=4, font=("Helvetica", 12, "bold"),
takefocus=0, cursor="hand2",
).pack(side="left", padx=(0, 4))
size_label = tk.Label(
font_controls, text=f"{font_size[0]}pt",
bg=card_bg, fg=fg_primary,
font=("Helvetica", 11), width=4, anchor="center",
)
size_label.pack(side="left")
tk.Button(
font_controls, text="A+", command=lambda: _set_font_size(2),
bg=accent, fg=bg, activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=10, pady=4, font=("Helvetica", 12, "bold"),
takefocus=0, cursor="hand2",
).pack(side="left", padx=(4, 0))
tk.Button(
toolbar, text="\u2715",
command=lambda: self._close_explanation(generation),
bg=card_bg, fg=fg_secondary,
activebackground=accent_hover, activeforeground=bg,
relief="flat", padx=8, pady=4, font=("Helvetica", 13),
takefocus=0, cursor="hand2",
).grid(row=0, column=3, sticky="e", padx=(12, 0))
# ── Sentence text (source) ────────────────────────────────────────
sentence_card = tk.Frame(frame, bg=card_bg, padx=12, pady=10)
sentence_card.grid(row=1, column=0, sticky="ew", pady=(0, 10))
sentence_card.columnconfigure(0, weight=1)
tk.Label(
sentence_card,
text="当前句子",
bg=card_bg,
fg=fg_secondary,
font=("Helvetica", 10),
).grid(row=0, column=0, sticky="w")
sentence_label = tk.Label(
sentence_card,
text=sentence_text,
wraplength=940,
justify="left",
bg=card_bg,
fg=fg_primary,
font=("Helvetica", font_size[0], "bold"),
)
sentence_label.grid(row=1, column=0, sticky="ew", pady=(4, 0))
# ── Explanation text area ──────────────────────────────────────────
text = tk.Text(
frame,
wrap="word",
relief="flat",
background="#f6f6f6",
foreground="#111111",
insertbackground="#111111",
selectbackground="#3f8efc",
selectforeground="#ffffff",
padx=10,
pady=10,
bg=card_bg,
fg=fg_primary,
insertbackground=fg_primary,
selectbackground=accent,
selectforeground=bg,
padx=14,
pady=12,
font=("Helvetica", font_size[0]),
borderwidth=0,
highlightthickness=0,
)
text.grid(row=1, column=0, sticky="nsew")
text.grid(row=2, column=0, sticky="nsew")
text.insert("1.0", "AI 正在讲解这句话…")
text.configure(state="disabled")
self._explanation_text = text
self._explanation_debug_var = tk.StringVar(
value=f"请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见“调试日志”。"
value=f'请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见\u201c调试日志\u201d。'
)
ttk.Label(
frame,
textvariable=self._explanation_debug_var,
wraplength=440,
wraplength=940,
justify="left",
foreground="#666666",
).grid(row=2, column=0, sticky="ew")
ttk.Button(
frame,
text="关闭",
command=lambda: self._close_explanation(generation),
takefocus=0,
).grid(row=3, column=0, sticky="e", pady=(10, 0))
foreground=fg_secondary,
background=bg,
).grid(row=3, column=0, sticky="ew", pady=(8, 0))
window.bind("<Escape>", lambda _e: self._close_explanation(generation))
self._explanation_window = window
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
@@ -2532,7 +2690,6 @@ class MediaPlayerApp(tk.Tk):
try:
text.configure(state="normal")
text.delete("1.0", "end")
text.configure(foreground="#111111")
text.insert("1.0", explanation)
text.configure(state="disabled")
if error: