feat(gui): drive everything from settings, add tray and in-app download

The GUI no longer needs command line arguments: on first run it opens a
settings dialog that lists the registry, downloads models in-app (mirror
base, HTTP proxy, resume), and picks the active model. The caption window
gains a tray icon (show/hide, settings, quit); its close button hides to
the tray so the window can always be brought back, and the language pill
opens the settings. Appearance (font, opacity, line counts, position) is
persisted. CLI flags remain for automated testing and override settings.

GUI 不再依赖命令行参数:首次启动打开设置对话框,可在应用内下载模型
(下载源/代理/断点续传)、选择当前模型与识别语言;字幕窗增加托盘(显示
隐藏/设置/退出),关闭按钮改为隐藏到托盘以免关掉后无法再打开,语言标签
点击进设置;字号/透明度/行数/位置都会持久化。命令行参数保留作自动化测试。
This commit is contained in:
blumia committed 2026-10-12 16:30:00 +08:00
1 parent b37b9eb779
commit a7a6f3e6b6
15 files changed
+1175 -90

No files matched your search

+219 -67
View File
@@ -1,111 +1,263 @@
// 图形界面版:悬浮字幕窗。
// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。
// 完全通过 GUI 使用;命令行参数保留,仅用于自动化测试(显式给出时覆盖设置)。
#include <cstdio>
#include <memory>
#include <QAction>
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QGuiApplication>
#include <QIcon>
#include <QMenu>
#include <QMessageBox>
#include <QPainter>
#include <QPixmap>
#include <QSystemTrayIcon>
#include <QTimer>
#include "appcli.h"
#include "appsettings.h"
#include "captionwindow.h"
#include "livetranscriber.h"
#include "iasr.h"
#include "livetranscriber.h"
#include "modelstore.h"
#include "qwen3asr.h"
#include "settingsdialog.h"
#include "streamingasr.h"
#include "transcript.h"
namespace {
int runGui(lc::IAsr & asr, const lc::StreamOptions & sop,
const QString & screenshotPath, int screenshotDelaySec, int opacityPercent)
// 托盘图标:直接画一个,免得依赖图标资源文件
QIcon makeTrayIcon()
{
lc::CaptionWindow window;
window.setPanelOpacity(opacityPercent);
lc::LiveTranscriber live(asr);
QPixmap pm(64, 64);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setBrush(QColor(20, 20, 20, 235));
p.setPen(Qt::NoPen);
p.drawRoundedRect(2, 12, 60, 40, 10, 10);
p.setPen(QColor(240, 240, 240));
QFont f = p.font();
f.setPixelSize(26);
f.setBold(true);
p.setFont(f);
p.drawText(pm.rect(), Qt::AlignCenter, QStringLiteral("字"));
return QIcon(pm);
}
QObject::connect(&live, &lc::LiveTranscriber::caption, &window,
[&window](const QString & committed, const QString & tail,
const QString & lang) {
window.setLanguage(
QString::fromStdString(lc::languageDisplayName(lang.toStdString())));
window.setCaption(committed, tail);
});
QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) {
qWarning("采集失败: %s", qUtf8Printable(m));
});
QObject::connect(&window, &lc::CaptionWindow::closed, qApp, &QCoreApplication::quit);
QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [] {
qInfo() << "语言标签被点击(设置界面将在 Phase 4 接入)";
});
// 把设置应用到字幕窗(字号/不透明度/行数/位置)
void applyAppearance(lc::CaptionWindow & window, const lc::AppSettings & cfg)
{
window.setPanelOpacity(cfg.opacityPercent);
window.setTextFontSize(cfg.fontSize);
window.setLineCounts(cfg.collapsedLines, cfg.expandedLines);
if (cfg.hasWindowPos) window.move(cfg.windowPos);
}
QString err;
if (!live.start(sop, &err)) {
qCritical("启动采集失败: %s", qUtf8Printable(err));
return 1;
}
qInfo("正在采集: %s", qUtf8Printable(live.deviceName()));
window.show();
if (!screenshotPath.isEmpty()) {
// 自截图:用于验证渲染(不依赖外部截图工具)
QTimer::singleShot(screenshotDelaySec * 1000, &window, [&window, screenshotPath] {
const bool ok1 = window.grab().save(screenshotPath);
window.setCollapsed(false);
QString expandedPath = screenshotPath;
expandedPath.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png"));
const bool ok2 = window.grab().save(expandedPath);
std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n",
ok1 ? "成功" : "失败", ok2 ? "成功" : "失败");
QCoreApplication::quit();
});
}
return QCoreApplication::exec();
bool hasUsableModel()
{
using namespace lc::models;
const lc::AppSettings & cfg = lc::AppSettings::instance();
if (cfg.modelId.isEmpty()) return false;
const ModelInfo * m = ModelStore::find(cfg.modelId);
return m && ModelStore::isInstalled(*m);
}
} // namespace
int main(int argc, char ** argv)
{
// 说明意图:DPI 取整策略必须在 QGuiApplication 之前定。
// (Deepin 的会话环境已经设了 QT_SCALE_FACTOR_ROUNDING_POLICY,
// 所以本机仍会看到 Qt 的那条提醒,那不是本程序的问题。)
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
QApplication app(argc, argv);
QCoreApplication::setApplicationName(QStringLiteral("live-caption"));
QCoreApplication::setOrganizationName(QStringLiteral("live-caption"));
lc::AppSettings & cfg = lc::AppSettings::instance();
cfg.load();
// ---- 命令行(仅用于自动化测试,显式给出时覆盖设置)----
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)"));
parser.addHelpOption();
lc::app::addModelOptions(parser);
lc::app::addStreamOptions(parser);
lc::app::addBackendOptions(parser);
parser.addOption(lc::app::makeOption("opacity", "字幕背景不透明度,取值 20-100", "n", "84"));
parser.addOption(lc::app::makeOption("screenshot", "启动后自动截图并退出(验证渲染用)", "path"));
parser.addOption(lc::app::makeOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
parser.addOption(QCommandLineOption("opacity", "背景不透明度(20-100)", "n"));
parser.addOption(QCommandLineOption("screenshot", "启动后自动截图并退出", "path"));
parser.addOption(QCommandLineOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
parser.addOption(QCommandLineOption("no-tray", "不创建托盘图标(测试用)"));
parser.addOption(QCommandLineOption("screenshot-settings", "打开设置对话框截图后退出", "path"));
parser.process(app);
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
lc::StreamOptions sop = lc::app::streamOptions(parser);
if (parser.isSet("asr-model")) cfg.modelId = parser.value("asr-model");
if (parser.isSet("language")) cfg.language = parser.value("language");
if (parser.isSet("opacity")) cfg.opacityPercent = parser.value("opacity").toInt();
if (parser.isSet("model-dir")) lc::models::ModelStore::setRootOverride(parser.value("model-dir"));
std::string err;
std::unique_ptr<lc::IAsr> asr = lc::app::makeAsr(parser, asrOpt, &err);
if (!asr) {
std::fprintf(stderr, "%s\n", err.c_str());
// 仅用于验证渲染:打开设置对话框截图后退出
if (parser.isSet("screenshot-settings")) {
const QString path = parser.value("screenshot-settings");
auto * dlg = new lc::SettingsDialog(!hasUsableModel());
dlg->show();
QTimer::singleShot(1200, dlg, [dlg, path] {
dlg->grab().save(path);
std::fprintf(stderr, "设置对话框截图: %s\n", qUtf8Printable(path));
QCoreApplication::quit();
});
return QCoreApplication::exec();
}
// ---- 启动:没有可用模型就先开设置 ----
if (!hasUsableModel()) {
lc::SettingsDialog dlg(true);
if (dlg.exec() != QDialog::Accepted || !hasUsableModel()) return 0;
}
lc::CaptionWindow window;
applyAppearance(window, cfg);
// 语言 pill:流式模型不提供语言识别,就显示设置里选的语言
if (!cfg.language.isEmpty()) {
window.setLanguage(QString::fromStdString(lc::languageDisplayName(cfg.language.toStdString())));
}
// 注意声明顺序:asr 必须在 live 之前声明,
// 这样析构时 live(会 join 工作线程)先销毁、asr 后销毁,避免 use-after-free。
std::unique_ptr<lc::IAsr> asr;
lc::LiveTranscriber live; // 后端在 startTranscriber() 里注入
const auto startTranscriber = [&]() -> bool {
live.stop();
asr.reset();
const lc::models::ModelInfo * m = lc::models::ModelStore::find(cfg.modelId);
if (!m) {
qCritical("找不到模型 %s", qUtf8Printable(cfg.modelId));
return false;
}
std::string err;
asr = lc::app::makeAsrForModel(*m, cfg.language, cfg.threads,
parser.isSet("verbose"), &err);
if (!asr) {
qCritical("加载模型失败: %s", err.c_str());
return false;
}
lc::StreamOptions sop;
if (asr->kind() == lc::AsrKind::NativeStreaming) {
sop.chunkSec = (cfg.chunkSec > 0.0) ? cfg.chunkSec : 0.32;
} else if (cfg.chunkSec > 0.0) {
sop.chunkSec = cfg.chunkSec;
}
QString qerr;
if (!live.start(*asr, sop, &qerr)) {
qCritical("启动采集失败: %s", qUtf8Printable(qerr));
return false;
}
return true;
};
QObject::connect(&live, &lc::LiveTranscriber::caption, &window,
[&window](const QString & committed, const QString & tail,
const QString & lang) {
if (!lang.isEmpty()) {
window.setLanguage(
QString::fromStdString(lc::languageDisplayName(lang.toStdString())));
}
window.setCaption(committed, tail);
});
QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) {
qWarning("采集失败: %s", qUtf8Printable(m));
});
// 语言 pill → 设置
QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [&] {
lc::SettingsDialog dlg(false);
if (dlg.exec() == QDialog::Accepted) {
applyAppearance(window, cfg);
if (!startTranscriber()) {
QMessageBox::warning(&window, QObject::tr("启动失败"),
QObject::tr("模型加载失败,请检查设置。"));
}
}
});
// ---- 托盘 ----
QSystemTrayIcon * tray = nullptr;
QMenu * trayMenu = nullptr;
if (!parser.isSet("no-tray")) {
tray = new QSystemTrayIcon(makeTrayIcon(), &app);
tray->setToolTip(QObject::tr("实时字幕"));
trayMenu = new QMenu;
auto * showAct = trayMenu->addAction(QObject::tr("显示/隐藏字幕"));
auto * setAct = trayMenu->addAction(QObject::tr("设置…"));
trayMenu->addSeparator();
auto * quitAct = trayMenu->addAction(QObject::tr("退出"));
tray->setContextMenu(trayMenu);
QObject::connect(showAct, &QAction::triggered, &window,
[&window] { window.setVisible(!window.isVisible()); });
QObject::connect(setAct, &QAction::triggered, &window, [&] {
lc::SettingsDialog dlg(false);
if (dlg.exec() == QDialog::Accepted) {
applyAppearance(window, cfg);
startTranscriber();
}
});
QObject::connect(quitAct, &QAction::triggered, &app, &QApplication::quit);
QObject::connect(tray, &QSystemTrayIcon::activated, &window,
[&window](QSystemTrayIcon::ActivationReason r) {
if (r == QSystemTrayIcon::Trigger) {
window.setVisible(!window.isVisible());
}
});
tray->show();
}
// 关闭按钮 = 隐藏到托盘(否则关掉就没法再打开了)
QObject::connect(&window, &lc::CaptionWindow::closed, &window, [&] {
if (tray) {
window.hide();
} else {
qApp->quit();
}
});
// 记住拖动后的位置
QObject::connect(&window, &lc::CaptionWindow::moved, &window, [&](const QPoint & p) {
cfg.windowPos = p;
cfg.hasWindowPos = true;
cfg.save();
});
if (!startTranscriber()) {
QMessageBox::critical(nullptr, QObject::tr("启动失败"),
QObject::tr("无法加载模型,请检查设置。"));
return 1;
}
if (asr->kind() == lc::AsrKind::NativeStreaming && !parser.isSet("stream-chunk")) {
sop.chunkSec = 0.32;
window.show();
// 自截图(验证渲染用)
if (parser.isSet("screenshot")) {
const QString path = parser.value("screenshot");
QTimer::singleShot(parser.value("screenshot-delay").toInt() * 1000, &window,
[&window, path] {
const bool ok1 = window.grab().save(path);
window.setCollapsed(false);
QString expanded = path;
expanded.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png"));
const bool ok2 = window.grab().save(expanded);
std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n",
ok1 ? "成功" : "失败", ok2 ? "成功" : "失败");
QCoreApplication::quit();
});
}
return runGui(*asr, sop,
parser.value("screenshot"),
parser.value("screenshot-delay").toInt(),
parser.value("opacity").toInt());
const int rc = QCoreApplication::exec();
// 显式收尾,确保工作线程停下来之后再释放后端
live.stop();
asr.reset();
return rc;
}