optimized PC player
This commit is contained in:
299
media_player.py
299
media_player.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user