fixed again
This commit is contained in:
@@ -16,6 +16,8 @@ pip 安装依赖后即可直接播放视频。
|
|||||||
缓存结果;生成过程有进度条。
|
缓存结果;生成过程有进度条。
|
||||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||||
|
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
|
||||||
|
`~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。
|
||||||
- 音量调节、静音,0.5x - 2.0x 变速播放
|
- 音量调节、静音,0.5x - 2.0x 变速播放
|
||||||
- 三种循环模式:关 / 单曲循环 / 列表循环
|
- 三种循环模式:关 / 单曲循环 / 列表循环
|
||||||
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)
|
||||||
|
|||||||
466
media_player.py
466
media_player.py
@@ -31,6 +31,8 @@
|
|||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
@@ -79,6 +81,37 @@ SEEK_STEP = 10
|
|||||||
SEEK_STEP_BIG = 60
|
SEEK_STEP_BIG = 60
|
||||||
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
|
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
|
||||||
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
|
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
|
||||||
|
DEBUG_LOG_DIR = Path.home() / ".mediaplayer_logs"
|
||||||
|
DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log"
|
||||||
|
_debug_logger = logging.getLogger("mediaplayer")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_debug_logging() -> Path:
|
||||||
|
"""Write concise diagnostics to a small local rotating log."""
|
||||||
|
try:
|
||||||
|
DEBUG_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
DEBUG_LOG_PATH,
|
||||||
|
maxBytes=1024 * 1024,
|
||||||
|
backupCount=2,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
_debug_logger.addHandler(handler)
|
||||||
|
_debug_logger.setLevel(logging.INFO)
|
||||||
|
_debug_logger.propagate = False
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return DEBUG_LOG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def debug_log(event: str, **fields: Any) -> None:
|
||||||
|
"""Record a diagnostic event without any authentication material."""
|
||||||
|
try:
|
||||||
|
_debug_logger.info("%s %s", event, json.dumps(fields, ensure_ascii=False, sort_keys=True))
|
||||||
|
except Exception:
|
||||||
|
# Logging must never interfere with playback or request handling.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path, progress_callback=None) -> str:
|
def sha256_file(path: Path, progress_callback=None) -> str:
|
||||||
@@ -110,7 +143,8 @@ def extract_audio_to_wav(path: Path, output_path: Path, progress_callback=None)
|
|||||||
for packet in source.demux(source_stream):
|
for packet in source.demux(source_stream):
|
||||||
for frame in packet.decode():
|
for frame in packet.decode():
|
||||||
for resampled in resampler.resample(frame):
|
for resampled in resampler.resample(frame):
|
||||||
output_seconds += len(resampled.samples) / resampled.sample_rate
|
# PyAV exposes Frame.samples as the frame's sample count.
|
||||||
|
output_seconds += resampled.samples / resampled.sample_rate
|
||||||
if progress_callback and total_seconds > 0:
|
if progress_callback and total_seconds > 0:
|
||||||
progress_callback(min(1.0, output_seconds / total_seconds))
|
progress_callback(min(1.0, output_seconds / total_seconds))
|
||||||
for encoded in target_stream.encode(resampled):
|
for encoded in target_stream.encode(resampled):
|
||||||
@@ -876,6 +910,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.title("媒体播放器")
|
self.title("媒体播放器")
|
||||||
self.geometry("960x600")
|
self.geometry("960x600")
|
||||||
self.minsize(720, 460)
|
self.minsize(720, 460)
|
||||||
|
self.debug_log_path = setup_debug_logging()
|
||||||
|
|
||||||
self.playlist = []
|
self.playlist = []
|
||||||
self.current_index = -1
|
self.current_index = -1
|
||||||
@@ -887,6 +922,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.sentence_mode = tk.BooleanVar(value=False)
|
self.sentence_mode = tk.BooleanVar(value=False)
|
||||||
self.server_url = settings["server_url"].rstrip("/")
|
self.server_url = settings["server_url"].rstrip("/")
|
||||||
self.auth_token = settings["auth_token"]
|
self.auth_token = settings["auth_token"]
|
||||||
|
self.auth_username = settings["auth_username"]
|
||||||
|
self.auth_nickname = settings["auth_nickname"]
|
||||||
self.loop_mode = "list"
|
self.loop_mode = "list"
|
||||||
self._seeking = False
|
self._seeking = False
|
||||||
self._switching_until = 0.0
|
self._switching_until = 0.0
|
||||||
@@ -896,12 +933,14 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._boundaries = {}
|
self._boundaries = {}
|
||||||
self._media_hashes = {}
|
self._media_hashes = {}
|
||||||
self._analysis_running = set()
|
self._analysis_running = set()
|
||||||
|
self._local_cache_loading = set()
|
||||||
self._analysis_done = queue.Queue()
|
self._analysis_done = queue.Queue()
|
||||||
self._ui_events = queue.Queue()
|
self._ui_events = queue.Queue()
|
||||||
self._controls_media_path = None
|
self._controls_media_path = None
|
||||||
self._generation_token = 0
|
self._generation_token = 0
|
||||||
self._explanation_window = None
|
self._explanation_window = None
|
||||||
self._explanation_text = None
|
self._explanation_text = None
|
||||||
|
self._explanation_debug_var = None
|
||||||
self._explanation_generation = 0
|
self._explanation_generation = 0
|
||||||
|
|
||||||
self.engine = self._create_engine()
|
self.engine = self._create_engine()
|
||||||
@@ -916,11 +955,14 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._bind_shortcuts()
|
self._bind_shortcuts()
|
||||||
|
debug_log("app_started", server_url=self.server_url, token_present=bool(self.auth_token))
|
||||||
if isinstance(self.engine, PygameEngine):
|
if isinstance(self.engine, PygameEngine):
|
||||||
self.status_var.set("pygame 音频模式:未检测到 VLC,视频功能不可用")
|
self.status_var.set("pygame 音频模式:未检测到 VLC,视频功能不可用")
|
||||||
elif isinstance(self.engine, AvEngine):
|
elif isinstance(self.engine, AvEngine):
|
||||||
self.status_var.set("纯 Python 解码内核:无需安装 VLC,可直接播放音视频")
|
self.status_var.set("纯 Python 解码内核:无需安装 VLC,可直接播放音视频")
|
||||||
self._load_playlist()
|
self._load_playlist()
|
||||||
|
self._load_local_boundaries_async(self._current_path())
|
||||||
|
self._validate_saved_session()
|
||||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
self.after(100, self._maybe_embed)
|
self.after(100, self._maybe_embed)
|
||||||
self.after(200, self._tick)
|
self.after(200, self._tick)
|
||||||
@@ -953,6 +995,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_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.open_folder, takefocus=0).pack(side="left", padx=(0, 8))
|
||||||
ttk.Button(toolbar, text="服务…", command=self.configure_server, takefocus=0).pack(side="left")
|
ttk.Button(toolbar, text="服务…", command=self.configure_server, takefocus=0).pack(side="left")
|
||||||
|
ttk.Button(toolbar, text="调试日志", command=self.show_debug_log, takefocus=0).pack(side="left", padx=(8, 0))
|
||||||
|
|
||||||
self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0)
|
self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0)
|
||||||
self.btn_prev.pack(side="left", padx=2)
|
self.btn_prev.pack(side="left", padx=2)
|
||||||
@@ -1302,6 +1345,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
"sentence_mode": bool(data.get("sentence_mode", False)),
|
"sentence_mode": bool(data.get("sentence_mode", False)),
|
||||||
"server_url": server_url,
|
"server_url": server_url,
|
||||||
"auth_token": str(data.get("auth_token") or "").strip(),
|
"auth_token": str(data.get("auth_token") or "").strip(),
|
||||||
|
"auth_username": str(data.get("auth_username") or "").strip(),
|
||||||
|
"auth_nickname": str(data.get("auth_nickname") or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
@@ -1313,6 +1358,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
"sentence_mode": bool(self.sentence_mode.get()),
|
"sentence_mode": bool(self.sentence_mode.get()),
|
||||||
"server_url": self.server_url,
|
"server_url": self.server_url,
|
||||||
"auth_token": self.auth_token,
|
"auth_token": self.auth_token,
|
||||||
|
"auth_username": self.auth_username,
|
||||||
|
"auth_nickname": self.auth_nickname,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -1393,6 +1440,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"):
|
if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"):
|
||||||
self.sentence_mode.set(False)
|
self.sentence_mode.set(False)
|
||||||
self.status_var.set("请先生成句子边界")
|
self.status_var.set("请先生成句子边界")
|
||||||
|
self._load_local_boundaries_async(path)
|
||||||
self._update_sentence_controls()
|
self._update_sentence_controls()
|
||||||
|
|
||||||
def toggle_play(self):
|
def toggle_play(self):
|
||||||
@@ -1473,6 +1521,130 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return self.playlist[self.current_index]
|
return self.playlist[self.current_index]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _auth_display(self):
|
||||||
|
return self.auth_nickname or self.auth_username or ""
|
||||||
|
|
||||||
|
def _validate_saved_session(self):
|
||||||
|
if not self.auth_token:
|
||||||
|
self._ui_events.put(lambda: self._set_saved_session_result(False, "未登录;请在“服务…”中登录"))
|
||||||
|
return
|
||||||
|
threading.Thread(target=self._validate_saved_session_worker, daemon=True).start()
|
||||||
|
|
||||||
|
def _validate_saved_session_worker(self):
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
self.server_url + "/api/v1/auth/me",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.auth_token}",
|
||||||
|
"User-Agent": CLIENT_NAME,
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=(10, 15),
|
||||||
|
)
|
||||||
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
debug_log(
|
||||||
|
"saved_session_validation",
|
||||||
|
status_code=response.status_code,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
)
|
||||||
|
if response.status_code not in range(200, 300):
|
||||||
|
self._ui_events.put(
|
||||||
|
lambda: self._set_saved_session_result(
|
||||||
|
False,
|
||||||
|
"保存的登录已过期,请在“服务…”中重新登录",
|
||||||
|
clear_token=response.status_code == 401,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
user = response.json()
|
||||||
|
display = user.get("nickname") or user.get("username") or ""
|
||||||
|
self._ui_events.put(
|
||||||
|
lambda: self._set_saved_session_result(
|
||||||
|
True,
|
||||||
|
f"已自动登录:{display}",
|
||||||
|
username=user.get("username"),
|
||||||
|
nickname=user.get("nickname"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(
|
||||||
|
"saved_session_validation_failed",
|
||||||
|
error_class=exc.__class__.__name__,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
self._ui_events.put(
|
||||||
|
lambda: self._set_saved_session_result(False, "自动登录校验失败;已保留本地令牌")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_saved_session_result(
|
||||||
|
self,
|
||||||
|
ok,
|
||||||
|
message,
|
||||||
|
clear_token=False,
|
||||||
|
username=None,
|
||||||
|
nickname=None,
|
||||||
|
):
|
||||||
|
if clear_token:
|
||||||
|
self.auth_token = ""
|
||||||
|
self.auth_username = ""
|
||||||
|
self.auth_nickname = ""
|
||||||
|
self._save_settings()
|
||||||
|
if ok:
|
||||||
|
if username is not None:
|
||||||
|
self.auth_username = str(username or "")
|
||||||
|
if nickname is not None:
|
||||||
|
self.auth_nickname = str(nickname or "")
|
||||||
|
self._save_settings()
|
||||||
|
self._update_status()
|
||||||
|
self.status_var.set(message)
|
||||||
|
|
||||||
|
def _load_local_boundaries_async(self, path):
|
||||||
|
if not path or path in self._boundaries or path in self._local_cache_loading:
|
||||||
|
return
|
||||||
|
self._local_cache_loading.add(path)
|
||||||
|
self.status_var.set("正在检查本地句边界缓存…")
|
||||||
|
threading.Thread(
|
||||||
|
target=self._load_local_boundaries_worker,
|
||||||
|
args=(path,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _load_local_boundaries_worker(self, path):
|
||||||
|
try:
|
||||||
|
media_hash = sha256_file(Path(path))
|
||||||
|
document = self._load_cached_boundaries(media_hash)
|
||||||
|
if document and document.get("sentences"):
|
||||||
|
self._media_hashes[path] = media_hash
|
||||||
|
self._boundaries[path] = document
|
||||||
|
count = len(document["sentences"])
|
||||||
|
message = f"已加载本地句边界缓存:共 {count} 句"
|
||||||
|
debug_log(
|
||||||
|
"sentence_cache_loaded",
|
||||||
|
media_hash=media_hash,
|
||||||
|
sentence_count=count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = "本地没有句边界缓存;可点击“生成句边界”"
|
||||||
|
debug_log("sentence_cache_miss", media_hash=media_hash)
|
||||||
|
self._ui_events.put(lambda: self._finish_local_boundary_load(path, message))
|
||||||
|
except Exception as exc:
|
||||||
|
message = str(exc) or exc.__class__.__name__
|
||||||
|
debug_log(
|
||||||
|
"sentence_cache_load_failed",
|
||||||
|
error_class=exc.__class__.__name__,
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
self._ui_events.put(lambda: self._finish_local_boundary_load(path, message))
|
||||||
|
|
||||||
|
def _finish_local_boundary_load(self, path, message):
|
||||||
|
self._local_cache_loading.discard(path)
|
||||||
|
if path in self._analysis_running or path in self._boundaries:
|
||||||
|
return
|
||||||
|
if path == self._current_path():
|
||||||
|
self.status_var.set(message)
|
||||||
|
self._update_sentence_controls()
|
||||||
|
|
||||||
def _update_sentence_controls(self):
|
def _update_sentence_controls(self):
|
||||||
path = self._current_path()
|
path = self._current_path()
|
||||||
self._controls_media_path = path
|
self._controls_media_path = path
|
||||||
@@ -1578,6 +1750,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
source = "local"
|
source = "local"
|
||||||
else:
|
else:
|
||||||
self._report_progress(25, "线上没有句边界,正在准备音频…")
|
self._report_progress(25, "线上没有句边界,正在准备音频…")
|
||||||
|
debug_log("audio_extraction_started", media_hash=media_hash)
|
||||||
audio_path = self._make_audio_upload(
|
audio_path = self._make_audio_upload(
|
||||||
path,
|
path,
|
||||||
progress_callback=lambda ratio: self._report_progress(
|
progress_callback=lambda ratio: self._report_progress(
|
||||||
@@ -1585,6 +1758,11 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
"正在提取音频轨…",
|
"正在提取音频轨…",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
debug_log(
|
||||||
|
"audio_extraction_ready",
|
||||||
|
media_hash=media_hash,
|
||||||
|
audio_bytes=Path(audio_path).stat().st_size,
|
||||||
|
)
|
||||||
document = self._generate_remote_boundaries(
|
document = self._generate_remote_boundaries(
|
||||||
audio_path,
|
audio_path,
|
||||||
progress_callback=lambda ratio: (
|
progress_callback=lambda ratio: (
|
||||||
@@ -1604,6 +1782,9 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
source = "generated"
|
source = "generated"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc) or exc.__class__.__name__
|
error = str(exc) or exc.__class__.__name__
|
||||||
|
_debug_logger.exception(
|
||||||
|
"sentence_boundaries_exception media_hash=%s", media_hash
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if audio_path is not None:
|
if audio_path is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1622,10 +1803,22 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
else:
|
else:
|
||||||
message = f"句边界已生成:共 {count} 句,已保存到本地"
|
message = f"句边界已生成:共 {count} 句,已保存到本地"
|
||||||
self._report_progress(100, message)
|
self._report_progress(100, message)
|
||||||
|
debug_log(
|
||||||
|
"sentence_boundaries_ready",
|
||||||
|
source=source,
|
||||||
|
sentence_count=count,
|
||||||
|
media_hash=media_hash,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._boundaries[path] = {"sentences": []}
|
self._boundaries[path] = {"sentences": []}
|
||||||
message = error or "服务器未能识别句子边界"
|
message = error or "服务器未能识别句子边界"
|
||||||
self._report_progress(0, message)
|
self._report_progress(0, message)
|
||||||
|
debug_log(
|
||||||
|
"sentence_boundaries_failed",
|
||||||
|
source=source,
|
||||||
|
media_hash=media_hash,
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
self._report_progress(None, None, indeterminate=False)
|
self._report_progress(None, None, indeterminate=False)
|
||||||
self._ui_events.put(lambda: self._update_sentence_controls())
|
self._ui_events.put(lambda: self._update_sentence_controls())
|
||||||
self._analysis_done.put((path, message))
|
self._analysis_done.put((path, message))
|
||||||
@@ -1673,6 +1866,12 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
}
|
}
|
||||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
|
debug_log(
|
||||||
|
"sentence_cache_saved",
|
||||||
|
media_hash=media_hash,
|
||||||
|
sentence_count=len(payload["sentences"]),
|
||||||
|
cache_file=str(path),
|
||||||
|
)
|
||||||
|
|
||||||
def _make_audio_upload(self, path, progress_callback=None):
|
def _make_audio_upload(self, path, progress_callback=None):
|
||||||
source = Path(path)
|
source = Path(path)
|
||||||
@@ -1687,14 +1886,22 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]:
|
def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]:
|
||||||
|
started = time.monotonic()
|
||||||
|
request_url = self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries"
|
||||||
headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"}
|
headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"}
|
||||||
if self.auth_token:
|
if self.auth_token:
|
||||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries",
|
request_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=(15, 30),
|
timeout=(15, 30),
|
||||||
)
|
)
|
||||||
|
debug_log(
|
||||||
|
"remote_boundaries_lookup",
|
||||||
|
status_code=response.status_code,
|
||||||
|
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
media_hash=media_hash,
|
||||||
|
)
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
return None
|
return None
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
@@ -1709,6 +1916,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]:
|
def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]:
|
||||||
|
started = time.monotonic()
|
||||||
|
request_url = self.server_url + "/api/v1/audio/sentence-boundaries"
|
||||||
filename = "audio" + Path(audio_path).suffix.lower()
|
filename = "audio" + Path(audio_path).suffix.lower()
|
||||||
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
|
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
|
||||||
total_size = max(1, Path(audio_path).stat().st_size)
|
total_size = max(1, Path(audio_path).stat().st_size)
|
||||||
@@ -1743,29 +1952,66 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
|
|
||||||
with Path(audio_path).open("rb") as audio_file:
|
with Path(audio_path).open("rb") as audio_file:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.server_url + "/api/v1/audio/sentence-boundaries",
|
request_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=multipart_chunks(),
|
data=multipart_chunks(),
|
||||||
timeout=(15, 3600),
|
timeout=(15, 3600),
|
||||||
)
|
)
|
||||||
|
debug_log(
|
||||||
|
"audio_boundaries_response",
|
||||||
|
status_code=response.status_code,
|
||||||
|
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
upload_bytes=total_size,
|
||||||
|
)
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
raise RuntimeError("请先在“服务…”中登录")
|
raise RuntimeError("请先在“服务…”中登录")
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
|
response_body = response.text or ""
|
||||||
|
debug_log(
|
||||||
|
"audio_boundaries_failed",
|
||||||
|
status_code=response.status_code,
|
||||||
|
body_snippet=response_body[:1000].replace("\n", "\\n"),
|
||||||
|
)
|
||||||
detail = self._server_error_detail(response)
|
detail = self._server_error_detail(response)
|
||||||
raise RuntimeError(f"句边界服务返回 {response.status_code}:{detail}")
|
raise RuntimeError(f"句边界服务返回 {response.status_code}:{detail}")
|
||||||
data = response.json()
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(
|
||||||
|
"audio_boundaries_bad_json",
|
||||||
|
status_code=response.status_code,
|
||||||
|
body_snippet=(response.text or "")[:1000].replace("\n", "\\n"),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise RuntimeError("句边界服务返回了无法解析的 JSON 数据") from exc
|
||||||
if not data.get("sentences"):
|
if not data.get("sentences"):
|
||||||
|
debug_log(
|
||||||
|
"audio_boundaries_empty",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=(response.text or "")[:1000],
|
||||||
|
)
|
||||||
raise RuntimeError("服务器未能识别句子边界")
|
raise RuntimeError("服务器未能识别句子边界")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _server_error_detail(response) -> str:
|
def _server_error_detail(response) -> str:
|
||||||
|
"""Return a short, human-readable API error for any FastAPI shape."""
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
detail = data.get("detail", response.text)
|
if isinstance(data, dict):
|
||||||
|
detail = data.get("detail", data)
|
||||||
|
else:
|
||||||
|
detail = data
|
||||||
except Exception:
|
except Exception:
|
||||||
detail = response.text
|
detail = response.text or response.reason or "未知错误"
|
||||||
detail = str(detail)
|
|
||||||
|
if isinstance(detail, (dict, list)):
|
||||||
|
try:
|
||||||
|
detail = json.dumps(detail, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
detail = str(detail)
|
||||||
|
else:
|
||||||
|
detail = str(detail)
|
||||||
return detail if len(detail) <= 300 else detail[:297] + "..."
|
return detail if len(detail) <= 300 else detail[:297] + "..."
|
||||||
|
|
||||||
def _current_sentence(self) -> Optional[Dict[str, Any]]:
|
def _current_sentence(self) -> Optional[Dict[str, Any]]:
|
||||||
@@ -1930,6 +2176,62 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._save_playlist()
|
self._save_playlist()
|
||||||
self.status_var.set("播放列表已清空")
|
self.status_var.set("播放列表已清空")
|
||||||
|
|
||||||
|
def show_debug_log(self):
|
||||||
|
window = tk.Toplevel(self)
|
||||||
|
window.title("调试日志")
|
||||||
|
window.geometry("860x540")
|
||||||
|
window.transient(self)
|
||||||
|
frame = ttk.Frame(window, padding=10)
|
||||||
|
frame.pack(fill="both", expand=True)
|
||||||
|
frame.columnconfigure(0, weight=1)
|
||||||
|
frame.rowconfigure(1, weight=1)
|
||||||
|
|
||||||
|
info = ttk.Frame(frame)
|
||||||
|
info.grid(row=0, column=0, sticky="ew", pady=(0, 8))
|
||||||
|
ttk.Label(info, text=f"日志文件:{self.debug_log_path}", wraplength=700, justify="left").pack(anchor="w")
|
||||||
|
ttk.Label(
|
||||||
|
info,
|
||||||
|
text="日志不包含登录令牌;发送问题反馈时可点击“复制日志”。",
|
||||||
|
wraplength=700,
|
||||||
|
justify="left",
|
||||||
|
foreground="#666666",
|
||||||
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
text = tk.Text(frame, wrap="none", state="disabled", relief="flat", background="#111111", foreground="#eeeeee")
|
||||||
|
text.grid(row=1, column=0, sticky="nsew")
|
||||||
|
scrollbar = ttk.Scrollbar(frame, orient="vertical", command=text.yview)
|
||||||
|
scrollbar.grid(row=1, column=1, sticky="ns")
|
||||||
|
text.configure(yscrollcommand=scrollbar.set)
|
||||||
|
|
||||||
|
buttons = ttk.Frame(frame)
|
||||||
|
buttons.grid(row=2, column=0, columnspan=2, sticky="e", pady=(8, 0))
|
||||||
|
|
||||||
|
def refresh():
|
||||||
|
try:
|
||||||
|
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")[-256 * 1024:]
|
||||||
|
except FileNotFoundError:
|
||||||
|
data = "日志文件尚未创建。"
|
||||||
|
except OSError as exc:
|
||||||
|
data = f"读取日志失败:{exc}"
|
||||||
|
text.configure(state="normal")
|
||||||
|
text.delete("1.0", "end")
|
||||||
|
text.insert("1.0", data)
|
||||||
|
text.see("end")
|
||||||
|
text.configure(state="disabled")
|
||||||
|
|
||||||
|
def copy_log():
|
||||||
|
try:
|
||||||
|
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")[-256 * 1024:]
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(data)
|
||||||
|
self.status_var.set("调试日志已复制")
|
||||||
|
except OSError as exc:
|
||||||
|
messagebox.showerror("复制日志", str(exc), parent=window)
|
||||||
|
|
||||||
|
ttk.Button(buttons, text="刷新", command=refresh, takefocus=0).pack(side="left", padx=(0, 8))
|
||||||
|
ttk.Button(buttons, text="复制日志", command=copy_log, takefocus=0).pack(side="left")
|
||||||
|
refresh()
|
||||||
|
|
||||||
def configure_server(self):
|
def configure_server(self):
|
||||||
window = tk.Toplevel(self)
|
window = tk.Toplevel(self)
|
||||||
window.title("服务配置")
|
window.title("服务配置")
|
||||||
@@ -1945,7 +2247,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
ttk.Entry(frame, textvariable=server_var, width=40).grid(row=0, column=1, sticky="ew")
|
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)
|
ttk.Label(frame, text="账号").grid(row=1, column=0, sticky="w", pady=5)
|
||||||
username_var = tk.StringVar()
|
username_var = tk.StringVar(value=self.auth_username)
|
||||||
ttk.Entry(frame, textvariable=username_var).grid(row=1, column=1, sticky="ew")
|
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)
|
ttk.Label(frame, text="密码").grid(row=2, column=0, sticky="w", pady=5)
|
||||||
@@ -1956,7 +2258,14 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
token_var = tk.StringVar(value=self.auth_token)
|
token_var = tk.StringVar(value=self.auth_token)
|
||||||
ttk.Entry(frame, textvariable=token_var, show="*").grid(row=3, column=1, sticky="ew")
|
ttk.Entry(frame, textvariable=token_var, show="*").grid(row=3, column=1, sticky="ew")
|
||||||
|
|
||||||
message = tk.StringVar(value="已有令牌可直接保存;没有令牌时输入账号密码登录。")
|
display = self._auth_display()
|
||||||
|
message = tk.StringVar(
|
||||||
|
value=(
|
||||||
|
f"已保存登录状态:{display}。下次启动会自动使用令牌;过期时重新登录即可。"
|
||||||
|
if self.auth_token
|
||||||
|
else "输入账号密码登录,或粘贴服务端返回的令牌。"
|
||||||
|
)
|
||||||
|
)
|
||||||
ttk.Label(frame, textvariable=message, wraplength=360, foreground="#666666").grid(
|
ttk.Label(frame, textvariable=message, wraplength=360, foreground="#666666").grid(
|
||||||
row=4, column=0, columnspan=2, sticky="ew", pady=(12, 8)
|
row=4, column=0, columnspan=2, sticky="ew", pady=(12, 8)
|
||||||
)
|
)
|
||||||
@@ -1970,8 +2279,12 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return
|
return
|
||||||
self.server_url = url
|
self.server_url = url
|
||||||
self.auth_token = token_var.get().strip()
|
self.auth_token = token_var.get().strip()
|
||||||
|
if not self.auth_token:
|
||||||
|
self.auth_username = ""
|
||||||
|
self.auth_nickname = ""
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
window.destroy()
|
window.destroy()
|
||||||
|
self._update_status()
|
||||||
self.status_var.set("服务配置已保存")
|
self.status_var.set("服务配置已保存")
|
||||||
|
|
||||||
def login_and_save():
|
def login_and_save():
|
||||||
@@ -1985,11 +2298,16 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
)
|
)
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
raise RuntimeError(self._server_error_detail(response))
|
raise RuntimeError(self._server_error_detail(response))
|
||||||
token_var.set(response.json()["token"])
|
body = response.json()
|
||||||
message.set("登录成功,令牌已保存。")
|
token_var.set(body["token"])
|
||||||
|
user = body.get("user") or {}
|
||||||
self.server_url = url
|
self.server_url = url
|
||||||
self.auth_token = token_var.get().strip()
|
self.auth_token = token_var.get().strip()
|
||||||
|
self.auth_username = str(user.get("username") or username_var.get().strip())
|
||||||
|
self.auth_nickname = str(user.get("nickname") or "")
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
message.set(f"登录成功,已保存 {self._auth_display()} 的令牌。")
|
||||||
|
self._update_status()
|
||||||
window.after(500, window.destroy)
|
window.after(500, window.destroy)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messagebox.showerror("登录失败", str(exc), parent=window)
|
messagebox.showerror("登录失败", str(exc), parent=window)
|
||||||
@@ -2025,24 +2343,63 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
frame.pack(fill="both", expand=True)
|
frame.pack(fill="both", expand=True)
|
||||||
frame.columnconfigure(0, weight=1)
|
frame.columnconfigure(0, weight=1)
|
||||||
frame.rowconfigure(1, weight=1)
|
frame.rowconfigure(1, weight=1)
|
||||||
ttk.Label(frame, text=sentence_text, wraplength=440, justify="left", font=("Helvetica", 12, "bold")).grid(
|
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)
|
row=0, column=0, sticky="ew", pady=(0, 10)
|
||||||
)
|
)
|
||||||
text = tk.Text(frame, wrap="word", state="disabled", relief="flat", background="#f6f6f6")
|
text = tk.Text(
|
||||||
|
frame,
|
||||||
|
wrap="word",
|
||||||
|
relief="flat",
|
||||||
|
background="#f6f6f6",
|
||||||
|
foreground="#111111",
|
||||||
|
insertbackground="#111111",
|
||||||
|
selectbackground="#3f8efc",
|
||||||
|
selectforeground="#ffffff",
|
||||||
|
padx=10,
|
||||||
|
pady=10,
|
||||||
|
)
|
||||||
text.grid(row=1, column=0, sticky="nsew")
|
text.grid(row=1, column=0, sticky="nsew")
|
||||||
text.insert("1.0", "AI 正在讲解这句话…")
|
text.insert("1.0", "AI 正在讲解这句话…")
|
||||||
|
text.configure(state="disabled")
|
||||||
self._explanation_text = text
|
self._explanation_text = text
|
||||||
|
self._explanation_debug_var = tk.StringVar(
|
||||||
|
value=f"请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见“调试日志”。"
|
||||||
|
)
|
||||||
|
ttk.Label(
|
||||||
|
frame,
|
||||||
|
textvariable=self._explanation_debug_var,
|
||||||
|
wraplength=440,
|
||||||
|
justify="left",
|
||||||
|
foreground="#666666",
|
||||||
|
).grid(row=2, column=0, sticky="ew")
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
frame,
|
frame,
|
||||||
text="关闭",
|
text="关闭",
|
||||||
command=lambda: self._close_explanation(generation),
|
command=lambda: self._close_explanation(generation),
|
||||||
takefocus=0,
|
takefocus=0,
|
||||||
).grid(row=2, column=0, sticky="e", pady=(10, 0))
|
).grid(row=3, column=0, sticky="e", pady=(10, 0))
|
||||||
self._explanation_window = window
|
self._explanation_window = window
|
||||||
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
|
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
|
||||||
|
|
||||||
path = self._current_path() or ""
|
path = self._current_path() or ""
|
||||||
course_title = Path(path).stem
|
course_title = Path(path).stem
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_started",
|
||||||
|
request_id=generation,
|
||||||
|
server_url=self.server_url,
|
||||||
|
sentence_index=sentence.get("index"),
|
||||||
|
sentence_text_length=len(sentence_text),
|
||||||
|
course_title_length=len(course_title),
|
||||||
|
language=sentence.get("language"),
|
||||||
|
token_present=bool(self.auth_token),
|
||||||
|
)
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._explain_sentence_worker,
|
target=self._explain_sentence_worker,
|
||||||
args=(sentence, course_title, generation),
|
args=(sentence, course_title, generation),
|
||||||
@@ -2050,6 +2407,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _explain_sentence_worker(self, sentence, course_title, generation):
|
def _explain_sentence_worker(self, sentence, course_title, generation):
|
||||||
|
started = time.monotonic()
|
||||||
|
request_url = self.server_url + "/api/v1/sentences/explain"
|
||||||
try:
|
try:
|
||||||
headers = {"Content-Type": "application/json", "User-Agent": CLIENT_NAME}
|
headers = {"Content-Type": "application/json", "User-Agent": CLIENT_NAME}
|
||||||
if self.auth_token:
|
if self.auth_token:
|
||||||
@@ -2061,38 +2420,102 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
"language": sentence.get("language"),
|
"language": sentence.get("language"),
|
||||||
}
|
}
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.server_url + "/api/v1/sentences/explain",
|
request_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=(15, 180),
|
timeout=(15, 180),
|
||||||
)
|
)
|
||||||
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
response_body = response.text or ""
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_response",
|
||||||
|
request_id=generation,
|
||||||
|
status_code=response.status_code,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
content_type=response.headers.get("content-type"),
|
||||||
|
response_length=len(response_body.encode("utf-8")),
|
||||||
|
)
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
raise RuntimeError("登录已过期,请在“服务…”中重新登录")
|
raise RuntimeError("登录已过期,请在“服务…”中重新登录")
|
||||||
if response.status_code not in range(200, 300):
|
if response.status_code not in range(200, 300):
|
||||||
raise RuntimeError(f"AI 讲解服务返回 {response.status_code}:{self._server_error_detail(response)}")
|
raise RuntimeError(f"AI 讲解服务返回 {response.status_code}:{self._server_error_detail(response)}")
|
||||||
explanation = response.json()["explanation"]
|
try:
|
||||||
self._ui_events.put(lambda: self._show_explanation_result(explanation, generation))
|
response_json = response.json()
|
||||||
|
explanation = response_json["explanation"]
|
||||||
|
except Exception as exc:
|
||||||
|
snippet = response_body[:500].replace("\n", "\\n")
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_bad_body",
|
||||||
|
request_id=generation,
|
||||||
|
error=str(exc),
|
||||||
|
body_snippet=snippet,
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"AI 服务返回了无法解析的数据:{snippet}") from exc
|
||||||
|
if not isinstance(explanation, str) or not explanation.strip():
|
||||||
|
debug_log("ai_explain_empty_body", request_id=generation, response_body=response_body[:500])
|
||||||
|
raise RuntimeError("AI 服务返回的讲解内容为空")
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_success",
|
||||||
|
request_id=generation,
|
||||||
|
explanation_length=len(explanation),
|
||||||
|
elapsed_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
debug_text = f"请求 #{generation}:HTTP {response.status_code},耗时 {elapsed_ms / 1000:.1f} 秒。"
|
||||||
|
self._ui_events.put(lambda: self._show_explanation_result(explanation, generation, debug_text=debug_text))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
message = str(exc) or exc.__class__.__name__
|
message = str(exc) or exc.__class__.__name__
|
||||||
self._ui_events.put(lambda: self._show_explanation_result("讲解失败:" + message, generation, error=True))
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_failed",
|
||||||
|
request_id=generation,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
error_class=exc.__class__.__name__,
|
||||||
|
error=message,
|
||||||
|
)
|
||||||
|
debug_text = f"请求 #{generation}失败:{exc.__class__.__name__},耗时 {elapsed_ms / 1000:.1f} 秒。"
|
||||||
|
self._ui_events.put(
|
||||||
|
lambda: self._show_explanation_result(
|
||||||
|
"讲解失败:" + message,
|
||||||
|
generation,
|
||||||
|
error=True,
|
||||||
|
debug_text=debug_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _show_explanation_result(self, explanation, generation, error=False):
|
def _show_explanation_result(self, explanation, generation, error=False, debug_text=None):
|
||||||
if generation != self._explanation_generation:
|
if generation != self._explanation_generation:
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_ui_skipped",
|
||||||
|
request_id=generation,
|
||||||
|
current_generation=self._explanation_generation,
|
||||||
|
reason="generation_mismatch",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
window = self._explanation_window
|
window = self._explanation_window
|
||||||
if window is None:
|
if window is None:
|
||||||
|
debug_log("ai_explain_ui_skipped", request_id=generation, reason="window_missing")
|
||||||
return
|
return
|
||||||
text = self._explanation_text
|
text = self._explanation_text
|
||||||
if text is None:
|
if text is None:
|
||||||
|
debug_log("ai_explain_ui_skipped", request_id=generation, reason="text_widget_missing")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
text.configure(state="normal")
|
text.configure(state="normal")
|
||||||
text.delete("1.0", "end")
|
text.delete("1.0", "end")
|
||||||
|
text.configure(foreground="#111111")
|
||||||
text.insert("1.0", explanation)
|
text.insert("1.0", explanation)
|
||||||
text.configure(state="disabled")
|
text.configure(state="disabled")
|
||||||
if error:
|
if error:
|
||||||
window.title("AI 讲解失败")
|
window.title("AI 讲解失败")
|
||||||
self.status_var.set(explanation)
|
self.status_var.set(explanation)
|
||||||
|
if debug_text and self._explanation_debug_var is not None:
|
||||||
|
self._explanation_debug_var.set(debug_text + " 详情见“调试日志”。")
|
||||||
|
debug_log(
|
||||||
|
"ai_explain_ui_applied",
|
||||||
|
request_id=generation,
|
||||||
|
error=error,
|
||||||
|
content_length=len(explanation),
|
||||||
|
)
|
||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2114,6 +2537,11 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
parts.append("pygame 音频模式(未检测到 VLC,视频不可用)")
|
parts.append("pygame 音频模式(未检测到 VLC,视频不可用)")
|
||||||
elif isinstance(self.engine, AvEngine):
|
elif isinstance(self.engine, AvEngine):
|
||||||
parts.append("纯 Python 解码内核(无需 VLC)")
|
parts.append("纯 Python 解码内核(无需 VLC)")
|
||||||
|
if self.auth_token:
|
||||||
|
display = self._auth_display()
|
||||||
|
parts.append(f"已登录:{display}" if display else "已登录")
|
||||||
|
else:
|
||||||
|
parts.append("未登录")
|
||||||
if 0 <= self.current_index < len(self.playlist):
|
if 0 <= self.current_index < len(self.playlist):
|
||||||
name = Path(self.playlist[self.current_index]).name
|
name = Path(self.playlist[self.current_index]).name
|
||||||
total = len(self.playlist)
|
total = len(self.playlist)
|
||||||
|
|||||||
Reference in New Issue
Block a user