add sentences borders enable in pc player
This commit is contained in:
419
media_player.py
419
media_player.py
@@ -13,7 +13,9 @@
|
||||
Shift+← / → 后退 / 前进 60 秒
|
||||
↑ / ↓ 音量 +5 / -5
|
||||
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
|
||||
勾选“按句跳转”后,←/→ 自动播放上一句/下一句(基于音频静音识别句边界)
|
||||
, / . 上一句 / 下一句
|
||||
勾选“按句跳转”后,←/→ 也会自动播放上一句/下一句
|
||||
E 讲解当前句子(AI)
|
||||
M 静音 / 恢复
|
||||
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
|
||||
N / P 下一首 / 上一首
|
||||
@@ -27,19 +29,21 @@
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
from sentence_analysis import detect_sentence_boundaries
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage, ImageTk
|
||||
except ImportError:
|
||||
@@ -73,6 +77,41 @@ 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) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as media_file:
|
||||
for chunk in iter(lambda: media_file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def extract_audio_to_wav(path: Path, output_path: Path) -> 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("文件中没有音频轨")
|
||||
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):
|
||||
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:
|
||||
@@ -835,6 +874,8 @@ class MediaPlayerApp(tk.Tk):
|
||||
settings = self._load_settings()
|
||||
self.seek_step = settings["seek_step"]
|
||||
self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"])
|
||||
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
|
||||
@@ -842,8 +883,12 @@ class MediaPlayerApp(tk.Tk):
|
||||
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._explanation_window = None
|
||||
self._explanation_generation = 0
|
||||
|
||||
self.engine = self._create_engine()
|
||||
if self.engine is None:
|
||||
@@ -893,6 +938,7 @@ class MediaPlayerApp(tk.Tk):
|
||||
|
||||
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)
|
||||
@@ -996,6 +1042,21 @@ class MediaPlayerApp(tk.Tk):
|
||||
command=self._on_sentence_mode,
|
||||
)
|
||||
self.sentence_mode_btn.pack(side="left", padx=(16, 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.seek_step_spin.bind("<Return>", self._on_seek_step_return)
|
||||
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
|
||||
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True)
|
||||
@@ -1035,6 +1096,10 @@ class MediaPlayerApp(tk.Tk):
|
||||
("<L>", lambda _e: self.cycle_loop()),
|
||||
("<bracketleft>", lambda _e: self.step_speed(-1)),
|
||||
("<bracketright>", lambda _e: self.step_speed(1)),
|
||||
("<comma>", lambda _e: self.seek_sentence(-1)),
|
||||
("<period>", lambda _e: self.seek_sentence(1)),
|
||||
("<e>", lambda _e: self.show_ai_explanation()),
|
||||
("<E>", lambda _e: self.show_ai_explanation()),
|
||||
("<f>", lambda _e: self.toggle_fullscreen()),
|
||||
("<F>", lambda _e: self.toggle_fullscreen()),
|
||||
("<Escape>", lambda _e: self.leave_fullscreen()),
|
||||
@@ -1090,17 +1155,21 @@ class MediaPlayerApp(tk.Tk):
|
||||
if self.engine is not None:
|
||||
while True:
|
||||
try:
|
||||
done_path = self._analysis_done.get_nowait()
|
||||
done_path, message = self._analysis_done.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if done_path == self._current_path() and self.sentence_mode.get():
|
||||
boundaries = self._boundaries.get(done_path)
|
||||
if boundaries:
|
||||
self.status_var.set(
|
||||
f"句边界分析完成:识别到 {len(boundaries)} 句,←/→ 将按句跳转"
|
||||
)
|
||||
self.status_var.set(message or f"句边界已就绪:共 {len(boundaries['sentences'])} 句")
|
||||
else:
|
||||
self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
|
||||
self.status_var.set(message or "该文件无法识别句子边界,按句跳转不可用")
|
||||
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()
|
||||
@@ -1115,6 +1184,9 @@ class MediaPlayerApp(tk.Tk):
|
||||
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)
|
||||
@@ -1194,7 +1266,17 @@ class MediaPlayerApp(tk.Tk):
|
||||
step = SEEK_STEP
|
||||
if not 1 <= step <= 600:
|
||||
step = SEEK_STEP
|
||||
return {"seek_step": step, "sentence_mode": bool(data.get("sentence_mode", False))}
|
||||
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:
|
||||
@@ -1203,6 +1285,8 @@ class MediaPlayerApp(tk.Tk):
|
||||
{
|
||||
"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,
|
||||
@@ -1297,6 +1381,7 @@ class MediaPlayerApp(tk.Tk):
|
||||
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)
|
||||
@@ -1372,46 +1457,186 @@ class MediaPlayerApp(tk.Tk):
|
||||
self._ensure_sentence_boundaries(path)
|
||||
self.status_var.set("正在分析句边界…")
|
||||
elif self._boundaries[path]:
|
||||
self.status_var.set(f"按句跳转已开启(识别到 {len(self._boundaries[path])} 句)")
|
||||
count = len(self._boundaries[path]["sentences"])
|
||||
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
|
||||
else:
|
||||
self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
|
||||
|
||||
def _ensure_sentence_boundaries(self, path):
|
||||
if path in self._boundaries or path in self._analysis_running:
|
||||
return
|
||||
if not self.server_url:
|
||||
self._boundaries[path] = {"sentences": []}
|
||||
self._analysis_done.put((path, "请先在“服务…”中配置服务器地址"))
|
||||
return
|
||||
self._analysis_running.add(path)
|
||||
self.status_var.set("正在从服务器生成句边界…")
|
||||
threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start()
|
||||
|
||||
def _analyze_sentences(self, path):
|
||||
boundaries = detect_sentence_boundaries(path)
|
||||
self._boundaries[path] = boundaries
|
||||
error = ""
|
||||
document = None
|
||||
audio_path = None
|
||||
try:
|
||||
media_hash = sha256_file(Path(path))
|
||||
self._media_hashes[path] = media_hash
|
||||
document = self._load_cached_boundaries(media_hash)
|
||||
if document is None:
|
||||
audio_path = self._make_audio_upload(path)
|
||||
document = self._generate_remote_boundaries(audio_path)
|
||||
self._save_cached_boundaries(media_hash, document)
|
||||
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)
|
||||
self._analysis_done.put(path)
|
||||
if document is not None and document.get("sentences"):
|
||||
self._boundaries[path] = document
|
||||
message = error or f"句边界已生成:共 {len(document['sentences'])} 句,已保存到本地"
|
||||
self._analysis_done.put((path, message))
|
||||
else:
|
||||
self._boundaries[path] = {"sentences": []}
|
||||
self._analysis_done.put((path, error or "服务器未能识别句子边界"))
|
||||
|
||||
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):
|
||||
source = Path(path)
|
||||
if source.suffix.lower() in AUDIO_EXTS:
|
||||
return source
|
||||
handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False)
|
||||
output = Path(handle.name)
|
||||
handle.close()
|
||||
extract_audio_to_wav(source, output)
|
||||
return output
|
||||
|
||||
def _generate_remote_boundaries(self, audio_path) -> Dict[str, Any]:
|
||||
filename = Path(audio_path).name
|
||||
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
|
||||
headers = {"User-Agent": CLIENT_NAME}
|
||||
if self.auth_token:
|
||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||
with Path(audio_path).open("rb") as audio_file:
|
||||
response = requests.post(
|
||||
self.server_url + "/api/v1/audio/sentence-boundaries",
|
||||
headers=headers,
|
||||
files={"file": (filename, audio_file, content_type)},
|
||||
data={"language": ""},
|
||||
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
|
||||
boundaries = self._boundaries.get(path)
|
||||
if not boundaries:
|
||||
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 boundary in boundaries:
|
||||
for sentence in sentences:
|
||||
boundary = int(sentence["start_ms"]) / 1000.0
|
||||
if boundary > pos_sec + 0.15:
|
||||
self.seek_to(int(boundary * 1000))
|
||||
self.seek_to(int(sentence["start_ms"]))
|
||||
return True
|
||||
return False
|
||||
target = 0.0
|
||||
for boundary in reversed(boundaries):
|
||||
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(int(target * 1000))
|
||||
self.seek_to(target_ms)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1527,6 +1752,162 @@ class MediaPlayerApp(tk.Tk):
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user