feat: checkpoint QML view migration

This commit is contained in:
dela
2026-07-15 11:57:34 +08:00
parent 3002adb7bf
commit b117bbf99e
133 changed files with 23964 additions and 17074 deletions
+100
View File
@@ -0,0 +1,100 @@
# qEmby QML 迁移状态
更新时间:2026-07-15
## 结论与口径
当前架构是 **Widgets 应用壳 + `QQuickWidget` 局部页面**,还不是纯 QML 应用:`MainWindow`、登录/主页导航、侧栏、标题栏和页面栈仍由 Widgets 负责;`QuickPageHost` 把 QML 页面嵌入 `QStackedWidget`/`SlidingStackedWidget`。迁移完成的判定必须同时满足“QML 页面存在、由实际导航入口加载、核心交互可用”,不能只以 `.qml` 文件存在为准。
代码依据:
- `src/qEmbyApp/CMakeLists.txt` 的 `qt_add_qml_module(qEmbyApp ...)` 是当前 QML 模块清单。
- `src/qEmbyApp/quick/quickpagehost.cpp` 使用 `QQuickWidget`,并处理主题底色、context property、image provider 与加载错误。
- `src/qEmbyApp/views/settings/settingsview.cpp` 实际加载 5 个设置 QML 页面。
- `src/qEmbyApp/views/admin/manageview.cpp` 保留 Widgets 管理壳,但仪表盘和六个管理页的页面主体均已由各自的 `QuickPageHost` 加载 QML。
- `src/qEmbyApp/views/user/homeview.cpp` 仍负责主路由,并创建 Dashboard/Favorites/Category/Library/Detail/Season/Search/Player/Settings/Manage 等 QWidget 页面。
- `src/qEmbyApp/quick/navigationcontroller.*` 已实现规范 route、参数/权限校验、会话清理和 UI state;`HomeView` 已旁路接入并与旧 `m_navStack` 并行运行。
状态口径:`done` = 已接入实际入口;`in-progress` = 已分配并正在迁移;`ready` = 前置基本具备,可领取;`blocked` = 应先完成列出的依赖;`legacy` = 迁移期间刻意保留的原生边界。
## 已迁移并接入
| 子系统 | 页面 | QML / 数据层 | owner | status | 验收命令 |
|---|---|---|---|---|---|
| 设置 | 通用 | `SettingsGeneralPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 外观 | `SettingsAppearancePage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 媒体库 | `SettingsLibraryPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 播放器 | `SettingsPlayerPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 关于 | `SettingsAboutPage.qml` / `AboutViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 管理 | 仪表盘 | `ManageDashboardPage.qml` / `DashboardViewModel`、`EmbyImageProvider` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;登录管理员账号后检查 2 秒刷新、重启/关机确认框 |
说明:设置与管理的左侧导航容器仍是 Widgets;About 的许可证窗口以及管理仪表盘的确认框/Toast 仍通过 Widgets 打开,因此这里的 `done` 指页面主体,不代表其父壳和所有弹窗都已迁移。
## 待迁移清单
以下 owner 是任务归属,不是 C++ 类所有者。`login-agent` 与 `search-agent` 已按本轮协作安排标为进行中。
| 阶段 | 子系统 | 页面/范围(现有 Widgets 事实) | owner | status | 依赖 | 验收命令 |
|---|---|---|---|---|---|---|
| 1 | 公共入口 | 登录 `views/public/LoginView` | login-agent | done | 主题输入框、按钮、忙碌/错误状态;认证 ViewModel | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;冷启动后完成服务器选择、登录成功/失败各一次 |
| 1 | 搜索 | 搜索结果 `views/search/SearchView` | search-agent | done | 媒体列表模型、MediaCard、分页/空态 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;提交关键词并验证结果、空结果、进入详情、返回 |
| 1 | 公共基础 | QML 弹窗/Toast/确认框、菜单、空态/错误态 | feedback-agent | done | `Theme`、现有 `ModernDialog.qml` | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;键盘 Esc/Enter、焦点回收、明暗主题各冒烟一次 |
| 1 | 公共基础 | MediaCard + 可复用 List/Grid + 图片加载/占位/错误 | media-primitives-agent | done | `EmbyImageProvider` 泛化、QAbstractListModel 角色契约 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;100+ 项滚动、图片失败、键鼠激活检查 |
| 2 | 用户首页 | 首页 `views/user/DashboardView` | dashboard-agent | done | MediaCard/List、首页 section 模型、请求取消 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;继续观看/最新/推荐/媒体库分区加载与跳转 |
| 2 | 用户首页 | 收藏 `views/user/FavoritesView` | favorites-agent | done | MediaCard/List、收藏模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类、详情、文件夹、人物跳转及返回 |
| 2 | 用户首页 | 分类 `views/user/CategoryView` | category-agent | done | MediaCard/List、筛选/分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类切换、分页、空态、返回状态保持 |
| 2 | 媒体浏览 | 媒体库/人物/过滤结果(共用 `views/media/LibraryView`) | library-agent | done | MediaCard/Grid、排序筛选菜单、分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;海报/平铺视图、排序筛选、滚动续载、人物页 |
| 2 | 应用壳 | `MainWindow` 标题栏、全局搜索、HomeView 侧栏与路由栈 | core | in-progress | `NavigationController`、单引擎 `Router.qml`、Home/Favorites singleton 已接入;待 Search/浏览 route、侧栏与标题导航 | `cmake --build cmake-build-debug --target qEmbyNavigationTest qEmbyApp_qmllint qEmbyApp -j2 && ./cmake-build-debug/bin/qEmbyNavigationTest`;登录→首页→收藏→详情→返回→设置→退出全链路,检查窗口按钮与托盘 |
| 3 | 媒体详情 | 详情 `views/media/DetailView` | detail-agent | done | MediaCard、Flow/Tag、动作菜单、编辑/识别弹窗桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;电影/剧集/人物各一项,播放、收藏、版本选择、编辑入口 |
| 3 | 媒体详情 | 季 `views/media/SeasonView` | season-agent | done | Episode 列表模型、MediaCard、播放动作 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;切季、剧集续播/播放、已播状态、返回 |
| 3 | 管理 | 媒体库 `PageLibraries` | admin-libraries-agent | done | 管理卡片/Grid、拖拽排序、Library CRUD 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增/编辑/删除/扫描/取消扫描/排序 |
| 3 | 管理 | 合集/播放列表 `PageCollections` | admin-collections-agent | done | 管理卡片/Grid、拖拽排序、文本/确认弹窗 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;创建/重命名/删除/批量删除/排序 |
| 3 | 管理 | 转码 `PageTranscoding` | admin-transcoding-agent | done | 表单组件、折叠区、编解码器模型与复杂校验 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;加载、修改、保存、校验失败、编解码器参数弹窗 |
| 3 | 管理 | 用户 `PageUsers` | admin-users-agent | done | 用户卡片模型、Add/Edit User 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增、编辑、启停、删除及权限错误 |
| 3 | 管理 | 任务 `PageTasks` | admin-tasks-agent | done | 任务列表模型、TaskEdit 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;启动、停止、编辑任务及轮询刷新 |
| 3 | 管理 | 日志 `PageLogs` | admin-logs-agent | done | 日志树/列表、文件保存桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;日志列表、内容加载、失败态、另存为 |
| 4 | 播放 | 播放页面/OSD `views/media/PlayerView` 与 `components/player*` | unassigned | blocked | QML 视频承载方案、PlaybackManager 契约、所有 OSD/字幕/弹幕组件 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;libmpv 播放/暂停/seek/音量/字幕/弹幕/全屏/退出,重复 10 次无崩溃 |
| 4 | 对话框 | 媒体编辑/识别/图片、播放列表、下载、WebDAV、代理、许可证等 `components/*dialog*` | unassigned | blocked | QML Overlay/Dialog 框架;文件选择器与原生能力桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;按对话框建立交互矩阵,覆盖接受/取消/错误/窗口关闭 |
| 4 | 平台集成 | 系统托盘、无边框窗口、文件选择器、外部播放器/IPC | platform | legacy | 纯 QML 壳稳定后再决定是否包装;不要求强行 QML 重写 | `cmake --build cmake-build-debug --target qEmbyApp -j2`;Linux/Windows/macOS 各执行关闭到托盘、恢复、退出和窗口按钮冒烟 |
## 建议阶段与依赖顺序
1. **固定 QML 契约**:先完成登录、搜索,以及弹窗/Toast、MediaCard/List/Grid;统一 ViewModel 的 loading/error/empty/cancelled 生命周期。没有这些基础件,后续页面会复制临时控件和异步处理。
2. **迁移高复用浏览链路**:Dashboard、Favorites、Category、Library/Person/Filtered。它们共享卡片、图片、分页和导航,是验证模型契约与性能的最佳批次。
3. **替换应用壳**:浏览链路稳定后,再把 MainWindow/HomeView 的标题栏、侧栏、路由栈换成 QML;期间 `QuickPageHost` 继续充当兼容层。壳迁移必须保留托盘、窗口按钮、主题切换和返回栈行为。
4. **迁移复杂业务页**:详情/季与六个剩余管理页。先提取 C++ ViewModel/`QAbstractListModel`,QML 不直接持有 service 协程或业务缓存。
5. **最后处理播放与长尾弹窗**:播放器涉及 libmpv/OpenGL/原生窗口和输入焦点,是最高风险项。平台集成可以保留 C++,目标应是“QML 表现层 + C++ 原生能力”,而不是消灭所有 QWidget/C++。
当前开发批次(2026-07-15):导航 Phase 0/1 已落地,Phase 2 首批完成。新增强类型 `NavigationRoute`、`NavigationController` 和轻量契约测试;Widgets 页面统一先转换为规范 route,再进入旧页面栈。`QuickRouterHost` 以单个 QML engine 承载 `Router.qml`,Home/Favorites 共享该 host 与图片 provider,并由 `HomeQuickRouterView` 协调 ViewModel 生命周期和 BaseView 媒体动作。其他 route 暂时保持旧 Widgets host,切入时 Router 保留底层页面且暂停生命周期,避免过渡闪屏。可用 `QEMBY_USE_LEGACY_HOME_PAGES=1` 临时回退旧 Home/Favorites 双 host。下一批先接 Search,再处理 Category/Library 的参数加载与 LRU ViewModel 所有权。
阶段门禁:每阶段至少通过 QML 静态检查、Debug 构建、明暗主题、窗口缩放、键盘焦点、页面反复进入退出、网络失败/空数据检查;依赖真实服务器的行为必须用 Emby/Jellyfin 各冒烟一次。
## 公共组件与基础设施缺口
- **导航**:声明式 route、参数、返回栈、页面缓存/销毁策略;当前契约散落在 `HomeView::create*View()` 与动态 `routeType` 属性中。
- **异步状态**:统一 `loading/error/empty/refreshing`、请求去重、页面销毁后的取消/弱引用;当前 Dashboard VM 是可参考样例,但尚无通用基类。
- **媒体模型**:统一 `QAbstractListModel` roles、分页、排序、筛选、收藏/已播状态增量更新,避免 QML 读取复杂 Core DTO。
- **视觉组件**:MediaCard、Section、IconButton、Menu/ContextMenu、Dialog/MessageBox、Toast、Busy/Skeleton、Error/Empty、Tabs、Tag/Chip、拖拽排序、文件/目录选择入口。
- **图片**:现有 `EmbyImageProvider`/`AdaptiveIconProvider` 只在管理仪表盘 host 中显式注册;全局 QML 引擎需要统一注册、缓存策略、取消和失败占位。
- **可访问性与输入**:焦点环、Tab 顺序、快捷键、右键菜单、触屏/高 DPI、屏幕阅读器语义。
- **测试**:仓库当前没有 qEmbyApp 页面级自动测试;应增加 Qt Quick Test(组件/VM)与最小导航冒烟,避免只靠截图判断。
- **国际化**:新 QML 文案需进入现有 `qEmby_zh_CN.ts`/`qEmby_fr_FR.ts` 流程,并核验运行时语言切换/重启语义。
## 高风险边界
1. **QQuickWidget 只是过渡层**:它使用离屏 FBO;仓库已经为透明合成黑底加了不透明 clear color。嵌入页越多,渲染、焦点、弹窗层级和显存成本越高,应避免把每个小组件都拆成独立 `QQuickWidget`。
2. **播放器**:`PlayerView`、`MpvWidget`、OpenGL 与大量 `player*` 控件耦合,且有全屏、长按、字幕、弹幕、媒体源切换。先做单一播放器原型和压力冒烟,再决定 `QQuickFramebufferObject`/原生纹理/保留 QWidget 视频面方案。
3. **生命周期与协程**:页面快速返回、切服务器或退出时,QCoro 任务、WebSocket 与定时刷新不能回调已销毁的 QML 对象。管理仪表盘已有 active/hidden 控制,但尚未形成全局规则。
4. **业务逻辑回流 QML**:现有 Widgets 页面体量大(尤其详情、转码、媒体库管理);迁移应先抽取 ViewModel/模型,不能把 API 编排、配置序列化和缓存策略复制进 JavaScript。
5. **导航状态兼容**:`HomeView` 当前重建历史页面并依赖 routeId/routeTitle/routeExtraId;切换为 QML 路由时必须保留人物、过滤、季、管理、设置等返回参数和滚动状态。
6. **原生能力**:托盘、无边框窗口、文件保存、外部播放器 IPC 适合继续放在 C++;“全面迁移到 QML”应限定为 UI/交互层迁移。
7. **主题双栈**:Widgets 仍走 QSS/`ThemeManager`,QML 走 `QuickTheme`/Themed controls。过渡期必须以同一 token 为源,验证实时明暗切换,避免两套颜色和尺寸继续分叉。
## 每个迁移任务的完成定义
- 实际导航已切到 QML,旧 Widgets 页面不再是默认路径;需要回退时必须有明确 feature flag。
- C++ 只暴露稳定属性、模型、信号和 invokable;QML 不直接编排网络 service 或持有 Core DTO。
- `qEmbyApp_qmllint` 与 `qEmbyApp` 构建通过,无新增 QML runtime error/warning。
- loading/error/empty/disabled、键鼠焦点、明暗主题、窗口缩放和中英文至少完成一次验证。
- 页面进入/退出及刷新至少重复 10 次;涉及异步或播放器的任务额外验证退出、切服务器和应用关闭。
- 删除旧实现应单独提交,在 QML 路径稳定后进行,便于回退与差异核对。
+404
View File
@@ -0,0 +1,404 @@
# QML 导航契约与兼容迁移规格
状态:Draft(Phase 0/1 兼容基础已实现,尚未形成已接受 ADR)
更新时间:2026-07-15
范围:应用登录/主页切换、`HomeView` 业务页面栈、设置/管理子页面导航,以及迁移到 QML 时的最小 C++/QML 边界。
非目标:本规格不决定播放器渲染技术、不迁移任何页面、不替换平台窗口/托盘代码,也不规定业务 ViewModel 的数据接口。
## 1. 证据口径
本文严格区分:
- **Observed fact(已观察事实)**:直接来自当前源码。
- **Proposed decision(建议决策)**:为 QML 迁移定义的目标契约,只有在评审接受后才是实现约束。
- **Needs confirmation(待确认)**:产品或架构选择,当前源码不足以给出唯一答案。
主要证据文件:
- `src/qEmbyApp/mainwindow.{h,cpp}`
- `src/qEmbyApp/views/user/homeview.{h,cpp}`
- `src/qEmbyApp/views/baseview.{h,cpp}`
- `src/qEmbyApp/views/settings/settingsview.cpp`
- `src/qEmbyApp/views/admin/manageview.cpp`
- `src/qEmbyApp/quick/quickpagehost.{h,cpp}`
## 2. 当前导航模型(Observed facts)
### 2.1 两层页面切换
1. `MainWindow` 持有 `QStackedWidget m_viewStack`,其中长期存在一个 `LoginView` 和一个 `HomeView`。
2. `LoginView::loginCompleted` 调用 `MainWindow::navigateToHome()`;`HomeView::logoutRequested` 调用 `MainWindow::navigateToLogin()`。
3. 登录/主页之间使用淡入动画;配置 `UiAnimations` 命中的现有分支会直接切换。
4. `navigateToLogin()` 在切到登录页后调用 `AuthService::logout()`。退出应用也会调用该方法。
5. `HomeView` 内部使用 `SlidingStackedWidget m_contentSwitcher` 和 `QStack<RouteInfo> m_navStack` 管业务页面。
6. `DashboardView` 与 `FavoritesView` 在 `HomeView::setupUi()` 中创建并长期保留;当前没有给这两个 QWidget 设置 `routeType/routeId/routeTitle/isDynamic`。
### 2.2 当前 RouteInfo 与动态属性
`RouteInfo` 当前字段为:
```cpp
QPointer<QWidget> widget;
bool isDynamic;
QString routeType;
QString routeId;
QString routeTitle;
QString routeExtraId;
```
`pushView()` 在切换前从当前 QWidget 的同名动态属性复制元数据到历史栈。页面实例可能被删除,因此返回逻辑也依赖这些字符串重建页面。
| create/静态实例 | 实际 QWidget | `isDynamic` | `routeType` | `routeId` | `routeTitle` | `routeExtraId` | 初始化事实 |
|---|---|---:|---|---|---|---|---|
| `m_dashboardView` | `DashboardView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goHome()` 复用;已在首页时再次调用会刷新数据 |
| `m_favoritesView` | `FavoritesView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goFav()` 复用;已在收藏时再次调用会刷新数据 |
| `createDetailView(itemId,itemName,seedItem)` | `DetailView` | true | `DetailView` | `itemId` | `itemName` | 未设置 | `loadItem(itemId, seedItem)`;历史重建时没有 `seedItem` |
| `createCategoryView(categoryId,title)` | `CategoryView` | true | `CategoryView` | `categoryId` | `title` | 未设置 | 异步排队调用 `loadCategory(categoryId,title)`;收藏分类入口给 id 加 `Favorite_` 前缀 |
| `createLibraryView(libraryId,title)` | `LibraryView` | true | `LibraryView` | `libraryId` | `title` | 未设置 | `loadLibrary(libraryId,title)` |
| `createPersonView(personId,personName)` | `LibraryView` | true | `PersonView` | `personId` | `personName` | 未设置 | 复用 `LibraryView`,调用 `loadPerson(personId,personName)` |
| `createSearchView(query)` | `SearchView` | true | `SearchView` | `query` | 未设置 | 未设置 | `performSearch(query)` |
| `createFilteredView(filterType,filterValue)` | `LibraryView` | true | `FilteredView` | `filterType + ":" + filterValue` | `filterValue` | `filterType` | 复用 `LibraryView`,调用 `loadFiltered(filterType,filterValue)` |
| `createSeasonView(seriesId,seasonId,seasonName)` | `SeasonView` | true | `SeasonView` | `seasonId` | `seasonName` | `seriesId` | `loadSeason(seriesId,seasonId,seasonName)` |
| `createPlayerView(mediaId,title,streamUrl,ticks,extraData)` | `PlayerView` | true | `PlayerView` | `mediaId` | `title` | 未设置 | 等切页动画结束后 `playMedia(...)`;进入/退出播放器采用瞬切 |
| `createSettingsView()` | `SettingsView` | true | `SettingsView` | `settings_global` | 翻译后的 Settings | 未设置 | 侧栏仅避免连续打开当前 `SettingsView` |
| `createManageView()` | `ManageView` | true | `ManageView` | `manage_global` | 翻译后的 Server Management | 未设置 | 侧栏仅避免连续打开当前 `ManageView` |
注意:当前 `navigateBack()` 能按元数据重建 Detail、Library、Category、Search、Settings、Person、Season、Manage、Filtered;没有 `PlayerView` 重建分支。找不到实例且无法重建时回退到 Dashboard。
### 2.3 create 方法和导航信号的事实映射
`BaseView` 暴露以下导航信号:
| 现有信号 | 参数 | `HomeView` 中的实际目标 |
|---|---|---|
| `navigateToDetail` | `itemId,itemName,seedItem` | `pushView(createDetailView(...))` |
| `navigateToFolder` | `folderId,folderName` | `pushView(createLibraryView(...))` |
| `navigateToPerson` | `personId,personName` | `pushView(createPersonView(...))` |
| `triggerSearch` | `query` | trim、记录服务器维度搜索历史、`pushView(createSearchView(query))` |
| `navigateToFilteredView` | `filterType,filterValue` | `pushView(createFilteredView(...))` |
| `navigateBack` | 无 | `HomeView::navigateBack()`(只在部分页面显式连接) |
| `navigateToCategory` | `categoryId,title` | Dashboard/Favorites 的专用连接进入 Category;Favorites 会加 `Favorite_` 前缀 |
| `navigateToPlayer` | `mediaId,title,streamUrl,ticks,extraData` | `launchPlayer()` → `PlaybackManager::startPlayback()`;嵌入播放请求最终 `pushView(createPlayerView(...))` |
| `navigateToSeason` | `seriesId,seasonId,seasonName` | `pushView(createSeasonView(...))` |
页面连接并不完全一致:Detail 额外连接过滤和搜索;Season 只连接详情、播放和返回;Search/Category/Library 连接其各自支持的详情/文件夹/人物/播放/季入口。目标导航控制器不应依赖每个页面重复手写这些连接。
### 2.4 push/back/home 的现有生命周期
- `pushView(view)`:把当前页面压栈;把目标加入 switcher;排队调用目标的 `scrollToTop()`;非 Player 向左滑入,Player 瞬切。
- 活跃历史中最多保留 12 个仍有 QWidget 指针的动态页面。超过限制的旧动态页面从 switcher 移除并 `deleteLater()`,但历史元数据仍保留以便重建。
- `navigateBack()`:先让当前 `BaseView::handleBackNavigation()` 尝试消费返回;未消费时调用 `prepareForStackLeave()`,弹出历史;必要时重建;完成切换后销毁离开的动态页。
- `resetToView(Dashboard/Favorites)`:清空整个历史栈、调用当前页 `prepareForStackLeave()`、滚动目标到顶并销毁相关动态页面。
- `goHome()/goFav()` 在已经位于目标静态页面时刷新,而不是产生新历史。
- `MainWindow` 返回按钮顺序是:业务栈 back → 回 Dashboard → 2 秒内再次返回触发退出登录提示/动作。
- `BaseView::scrollToTop()` 默认为空;当前检索到 Dashboard 和 Detail 有 override。没有通用的滚动位置序列化/恢复契约。
- `PlayerView` override `prepareForStackLeave()`;Manage 的 show/hide 连接/断开 WebSocket;离开 Manage 后 `HomeView` 还会刷新侧栏媒体库。
### 2.5 设置与管理子导航
- 设置是一个全局 `SettingsView` route,内部 5 个索引依次是 `general`、`appearance`、`library`、`player`、`about`。页面惰性实例化;当前外层 route 不记录选中索引或滚动状态。
- 管理是一个全局 `ManageView` route,内部 7 个索引依次是 `dashboard`、`libraries`、`collections/lists`、`transcoding`、`users`、`tasks`、`logs`。当前外层 route 不记录选中索引或滚动状态。
- 设置五个页面主体已由 `QuickPageHost` 加载 QML;管理仅 Dashboard 主体由该 host 加载。两者的左侧导航和父级页面栈仍是 Widgets。
- `QuickPageHost` 通过独立 `QQuickWidget` 加载 URL、注入 context property/image provider,并把 QWidget show/hide 转成 `pageShown/pageHidden`。
## 3. 目标职责边界(Proposed decision)
采用一个 C++ `NavigationController` 作为**唯一规范导航状态所有者**,QML `Router.qml` 作为声明式页面注册、实例化和视觉切换层:
```text
QML 页面/侧栏/标题栏
│ push/replace/back/home
▼
NavigationController (C++:校验、权限、规范栈、会话清理)
│ currentRoute/transition request
▼
Router.qml (QML:route→Component、Loader/StackView、过渡、UI state 回写)
│
├─ QML Page + C++ ViewModel
└─ 迁移期 LegacyWidgetRouteHost
```
约束:
1. 页面不得自行维护第二份全局历史,也不得直接实例化另一个顶级页面。
2. C++ Controller 不持有 QML `Item*` 作为历史真相;历史必须只靠可验证 route 数据恢复。
3. QML Router 不做认证、管理员权限或 deep-link 参数校验;这些属于 Controller。
4. 业务加载/错误属于页面 ViewModel;“未知 route、参数非法、权限拒绝、无法实例化”属于导航错误。
5. 迁移期间 Widgets 和 QML 入口都调用同一 Controller,旧 `HomeView` 栈仅作为尚未迁移页面的适配实现。
## 4. Route shape(Proposed decision)
C++/QML 边界使用 `QVariantMap`,内部 C++ 应使用强类型 `Route` 后再导出,避免核心逻辑依赖无类型 map。
```qml
{
name: "detail", // 稳定、非翻译的 route 名
key: "detail:item-123", // Controller 规范化生成的实例标识
title: "Movie title", // 展示提示;不得作为重建必需参数
params: { itemId: "item-123" },
presentation: "page", // page | immersive
cachePolicy: "lru", // singleton | lru | destroyOnPop
uiState: { scrollY: 0 }, // 可丢失的页面表现状态
version: 1
}
```
规则:
- `name + params` 是恢复页面的规范输入;`title` 只用于即时展示。
- `key` 由 Controller 根据 route 注册表生成,调用方不能伪造。
- `params` 只允许 JSON/QVariant 可安全复制的标量、list、map;不得把 `QObject*`、QWidget 指针、原始 `MediaItem` 或带凭据的流 URL写入历史。
- `uiState` 可更新、可淘汰,不参与 route 身份;第一阶段至少支持 `scrollY` 和嵌套页面 `section`。
- 播放的 `streamUrl/extraData` 目前可能包含瞬态或复杂数据。它们保留在 PlaybackManager 的启动上下文,不进入可持久化 deep link;Player route 历史只保存 `mediaId/title/startPositionTicks`,且默认 `destroyOnPop`。
- `seedItem` 是详情加载优化,不是恢复必需数据;可作为一次性 Controller payload 传递,但不能成为 route schema 的必填项。
### 4.1 规范 route 注册表
| `name` | 必填 `params` | 可选 `params` | 兼容来源 | 默认缓存 |
|---|---|---|---|---|
| `login` | 无 | 无 | MainWindow LoginView | singleton(应用级) |
| `home` | 无 | 无 | DashboardView | singleton(会话级) |
| `favorites` | 无 | 无 | FavoritesView | singleton(会话级) |
| `detail` | `itemId` | `title` | DetailView | lru |
| `category` | `categoryId` | `title`, `favorite` | CategoryView;兼容期解析 `Favorite_` 前缀 | lru |
| `library` | `libraryId` | `title` | LibraryView | lru |
| `person` | `personId` | `title` | PersonView/LibraryView | lru |
| `search` | `query` | 无 | SearchView | lru |
| `filtered` | `filterType`,`filterValue` | `title` | FilteredView | lru |
| `season` | `seriesId`,`seasonId` | `title` | SeasonView | lru |
| `player` | `mediaId` | `title`,`startPositionTicks` | PlayerView | destroyOnPop |
| `settings` | 无 | `section` | SettingsView | lru |
| `manage` | 无 | `section` | ManageView | lru;admin guard |
| `navigationError` | `code` | `message`,`failedRoute` | 当前无等价页 | destroyOnPop |
Settings `section` allowlist:`general|appearance|library|player|about`。
Manage `section` allowlist:`dashboard|libraries|collections|transcoding|users|tasks|logs`。
**Needs confirmation:** `collections` 是否作为面向用户的稳定 deep-link 名,还是沿用界面文字 `lists`;建议内部稳定名使用 `collections`。
## 5. NavigationController 最小契约(Proposed decision)
以下是语义契约,不强制具体头文件拼写:
```cpp
class NavigationController : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantMap currentRoute READ currentRoute NOTIFY currentRouteChanged)
Q_PROPERTY(bool canGoBack READ canGoBack NOTIFY stackChanged)
Q_PROPERTY(int depth READ depth NOTIFY stackChanged)
public:
Q_INVOKABLE bool push(const QVariantMap &request);
Q_INVOKABLE bool replace(const QVariantMap &request);
Q_INVOKABLE bool back();
Q_INVOKABLE void home();
Q_INVOKABLE void updateUiState(const QVariantMap &patch);
// 仅认证/会话协调层调用,不暴露给任意内容页。
void enterAuthenticatedSession();
void leaveAuthenticatedSession(LogoutReason reason);
signals:
void currentRouteChanged();
void stackChanged();
void navigationRejected(QString code, QString message,
QVariantMap request);
void sessionRoutesCleared();
};
```
操作语义:
- `push`:校验并规范化 request;保存当前 `uiState`;压入新 route。新页面 `scrollY=0`。默认允许相同 route 重复入栈。
- `replace`:校验成功后用新 route 替换栈顶,不保留被替换项;适用于登录完成、参数规范化和不可返回的重定向。
- `back`:先请求当前页面处理局部返回(关闭 overlay、退出子模式);未消费才弹全局栈。目标恢复已保存 `uiState`。
- `home`:清空业务历史并切到 `home`;已经在 `home` 时发出 `refreshRequested(key)`,对齐现有刷新语义。
- 收藏入口提供等价的 `resetTo({name:"favorites"})` 内部 API;它和 `home()` 一样清业务栈。若不希望扩大公开 API,可由 `replace` 的受控 `clearHistory` option 实现,但普通页面不能传此 option。
- `updateUiState`:只合并 allowlist 字段,节流写入当前栈项;不产生页面切换。
- 所有失败操作都必须原子化:返回 false、栈不变、发 `navigationRejected`。
**Needs confirmation:** 当前“点收藏清栈”的行为是否继续保留。建议保留以匹配 `resetToView(m_favoritesView)`,并将 `favorites` 与 `home` 都视为会话根页面。
### 5.1 局部返回与离开钩子
为兼容 `BaseView::handleBackNavigation()` 和 `prepareForStackLeave()`,QML 页面统一可选实现:
```qml
function handleBackRequest() { return false }
function prepareForRouteLeave(reason) { }
function captureRouteUiState() { return { scrollY: flickable.contentY } }
function restoreRouteUiState(state) { ... }
```
Router 在 Controller `back/home/replace/sessionClear` 提交前调用前两个钩子。`handleBackRequest()` 返回 true 时,不调用 Controller `back()`。异步清理不能无限阻塞导航;耗时业务取消应由 ViewModel 的 active/generation/cancellation 机制完成。
## 6. QML Router 最小职责(Proposed decision)
1. 维护静态 route name → `Component` 注册表,不根据任意 URL 拼 QML 文件路径。
2. 监听 `currentRoute` 并实例化对应组件;把 `params`、一次性 payload、Controller/ViewModel 注入页面。
3. 根据 `cachePolicy` 执行:
- `singleton`:会话内唯一实例,离开时停用但不销毁;
- `lru`:最多 12 个**活页面实例**,与现有上限一致;被淘汰后保留 route/uiState,返回时重建;
- `destroyOnPop`:离开即销毁,Player 默认使用。
4. 页面 `visible`/active 变化必须传给 ViewModel,以替代 `QuickPageHost::pageShown/pageHidden`,停止轮询、WebSocket 或过期请求。
5. Fresh push 从顶部开始;back 恢复保存的滚动位置;home/favorites reset 后目标滚到顶部。滚动恢复要在模型/布局可用后 clamp 到合法范围。
6. Player 使用 immersive presentation 并禁用普通滑页动画;其余页面由统一 transition policy 决定,reduce-motion 时无动画。
7. 实例创建或 Component error 时报告 Controller,并展示 `navigationError` 或安全回退;禁止黑屏或静默回 home。
## 7. 深链与参数(Proposed decision)
第一阶段先支持内部 deep link(规范 route map),外部 URI 注册推迟到纯 QML 壳稳定后。建议 URI 形态:
```text
qemby://detail/<itemId>
qemby://library/<libraryId>
qemby://season/<seriesId>/<seasonId>
qemby://settings/<section>
qemby://manage/<section>
qemby://search?q=<percent-encoded-query>
```
处理规则:
- Controller 维护 route/参数 allowlist、长度限制和 percent-decoding;未知字段忽略还是拒绝必须逐 route 固定,建议拒绝必填字段冲突、忽略未知可选字段并记录日志。
- 未登录时收到业务 deep link:保存**一个经过校验的 pending route**,切到 login;登录成功后 replace 为 home,再 push pending route。
- `manage` 在未登录、非管理员或服务器不支持时拒绝,不实例化管理页。
- server-scoped item deep link 必须绑定当前 server;跨服务器解析/选择流程当前无事实依据。
- 不允许在 URI、route 或日志中携带 access token、密码、完整 stream URL。
**Needs confirmation:** 是否支持 OS 外部 deep link、跨服务器 deep link,以及登录后 pending link 的过期时间。建议首期只保证应用内 route map,外部 URI 单独立项。
## 8. 登录、退出与服务器切换(Proposed decision)
### 登录成功
1. Controller 建立新的 session generation。
2. 清除任何上一个会话的页面实例、历史和 uiState。
3. `replace(home)`,不允许 back 回 login。
4. 若存在合法 pending deep link,再 `push` 目标。
### 退出登录
1. 先让 Player/当前页执行 leave hook,并停止会话级任务。
2. 原子清空业务栈、LRU cache、一次性 payload、pending deep link、滚动状态和所有 session singleton。
3. 断开 Manage WebSocket/页面轮询。
4. 调用认证服务 logout,然后 `replace(login)`;不允许 back 回任何业务页。
### 切换服务器/用户
按“退出旧 session + 建立新 session”处理,不能复用旧 server 的 route 页面或滚动状态。搜索历史本身当前按 server id 隔离,这不代表页面栈可跨 server 复用。
当前 Widgets 行为在侧栏 logout 时会先 reset Dashboard,但 `MainWindow::navigateToLogin()` 本身不直接拥有 `HomeView` 清栈 API。目标契约必须把清栈变成 Controller 的会话不变量,而不是依赖退出入口恰好来自侧栏。
## 9. 设置与管理页(Proposed decision)
- 顶层保持一个 `settings` 和一个 `manage` route,不把每个 section 都变成全局历史项。
- `params.section` 支持侧栏/深链直达;同一页面内切 section 使用 `replace` 更新当前 route 参数,因此 back 返回离开 Settings/Manage 前的业务页面,而不是逐 tab 返回。
- section 自己保存独立 `scrollY`,建议 `uiState.sections[section].scrollY`;LRU 重建后恢复所选 section 和滚动。
- Manage route 必须由 Controller 做 admin guard;页面仍应处理服务端 401/403,因为登录后权限可能变化。
- 离开 Manage(包括 back/home/logout/server switch)必须停 WebSocket;进入时才连接。迁移期继续触发当前“离开管理后刷新侧栏媒体库”的兼容动作。
- 设置/管理内部 QML 页面不应各自创建 `QQuickWidget`。壳迁移后在同一 QML engine 中用 Component/Loader 承载;`QuickPageHost` 只保留给尚未迁移的 Widgets 壳阶段。
**Needs confirmation:** 切换设置/管理 section 是否应该进入浏览器式 back 历史。建议不进入,以保持当前全局栈行为。
## 10. 错误处理(Proposed decision)
| 错误 | Controller/Router 行为 | 用户可恢复动作 |
|---|---|---|
| 未知 route name | 拒绝、栈不变、日志 + `navigationRejected(unknownRoute)` | 保持当前页 |
| 缺失/非法参数 | 拒绝、栈不变,不启动 ViewModel 请求 | 修正入口;外部 link 显示不可打开提示 |
| 未认证 | 保存合法 pending route,replace login | 登录或取消 |
| 非管理员访问 manage | 拒绝;不得短暂显示管理内容 | 返回当前页/提示权限不足 |
| QML Component 加载失败 | 标记 `componentLoadFailed`,展示 navigationError;保留可 back/home 的壳 | 重试、back、home |
| 页面数据请求失败 | route 保持不变,由页面 ErrorState/ViewModel 处理 | 重试、back |
| LRU 页面已销毁 | 由 route params 重建并恢复可用 uiState | 无需用户干预 |
| 历史数据版本不兼容 | 丢弃该项并继续向前找安全 route;记录原因 | 最终回 home/login |
| leave hook/动画期间重复导航 | Controller 串行化或合并;不能出现双 pop/重复实例 | 操作完成后状态一致 |
不得使用“任何错误都静默回 Dashboard”的策略;现有 fallback 只作为兼容期最后保险。
## 11. 分阶段兼容方案(Proposed decision)
### Phase 0:固定契约与测试夹具
- 新增强类型 Route/注册表和 Controller 单元测试,不改变实际导航入口。
- 建立 legacy 属性 ↔ route 的双向转换;记录不完整历史项但仍走现有 HomeView。
- 为 login/home/favorites 补稳定 route 名(可先只在适配器中表达,不要求立刻改 QWidget 属性)。
### Phase 1:Controller 旁路接管
- 所有 Widgets 信号先转换成 route request,再由 legacy adapter 调用现有 `create*View/pushView/resetToView`。
- 对比 Controller 栈与 `m_navStack`,Debug 下发现漂移立即记录。
- 会话退出、服务器切换首先统一走 Controller clear-session 流程。
### Phase 2:单引擎 QML Router + 已迁移页面
- 在现有 Widgets MainWindow/HomeView 内放置**一个**根 Quick host,Router 接管 Login、Search、Settings 主体、Manage Dashboard 等已迁移页面。
- 未迁移 route 通过 `LegacyWidgetRouteHost`/明确的边界回到 Widgets;不要为每个 QML 子组件新增独立 QQuickWidget。
- Controller 成为唯一历史;停止让 QML 路径同时写 `m_navStack`。
### Phase 3:QML Home shell
- 迁移侧栏、全局搜索、标题导航和内容 StackView;保留 C++ 原生窗口 agent、托盘及必要平台能力。
- Dashboard/Favorites/Category/Library/Detail/Season 逐个切 route factory;每个入口保留 feature flag 回退一个发布周期。
- 设置/管理 section 和滚动状态切到本文契约。
### Phase 4:移除 legacy 业务栈
- 最后迁移 Player/长尾页面或明确其原生 host 边界。
- 删除 `HomeView::create*View()`、`RouteInfo`、重复 signal wiring 和 `QuickPageHost` 多实例路径前,必须完成全链路验收。
- 平台窗口、托盘、文件选择器等 C++ 能力无需为了“纯 QML”被删除。
## 12. 验收标准
### 契约与自动测试
- 每个注册 route 的合法/缺参/错参用例都有 Controller 测试;失败时栈严格不变。
- push → push → back、replace、home/favorites reset、空栈 back 均有确定结果。
- LRU 超过 12 个实例后,历史 route 仍能重建;Player 不被重建为带失效 stream context 的页面。
- uiState 不参与 key;fresh push 到顶,back 恢复滚动,超出新 contentHeight 时正确 clamp。
- logout、切 server/user 后 depth、cache、pending payload、scroll state 均不含旧 session 数据。
- 未登录 deep link、非法 deep link、非管理员 manage deep link 有测试。
### 兼容行为
- Dashboard、Favorites、Detail、Category、Library、Person、Search、Filtered、Season、Settings、Manage、Player 的所有现有入口都有 route 映射。
- `BaseView` 九类导航信号在兼容适配器中无遗漏;同一入口不会同时 push 两个栈。
- 返回优先关闭当前页面局部状态,再 pop;离开 Player 执行 stop/report 语义,离开 Manage 停 WebSocket。
- 标题栏 back/home/favorites、侧栏入口、全局搜索、登录成功、侧栏退出、双击返回退出都到达同一 Controller API。
- Settings/Manage 直达 section、返回到来源页、重建后恢复 section/滚动均可用。
### 运行与视觉
- `qEmbyApp_qmllint` 与 Debug 构建通过,无新增 runtime QML error/warning。
- 登录 → Home → Library → Detail → Season/Player → back → Settings → Manage → logout 完整链路重复 10 次无崩溃、无幽灵页面、无跨账号数据。
- reduce-motion 与普通动画两种配置下快速连续 back/push 不产生双 pop。
- Emby/Jellyfin、管理员/普通用户分别冒烟;窗口缩放、键盘 Back/Escape、鼠标侧键、托盘恢复后导航状态一致。
- QML Component 故障和网络错误分别验证:前者进入导航错误 UI,后者留在当前业务 route 的错误态。
## 13. 待评审决定汇总
1. Favorites 是否继续作为会话根并在进入时清空历史(建议:是,兼容现状)。
2. Settings/Manage section 切换是否写入全局 back 历史(建议:否)。
3. 管理合集 section 的稳定名使用 `collections` 还是 `lists`(建议:`collections`)。
4. 首期是否包含 OS 外部 deep link 和跨服务器解析(建议:不包含,只做内部 route map)。
5. pending deep link 的过期策略(建议:只存一个、仅当前登录流程有效,取消登录即清除)。
6. QML 壳过渡期采用何种 Legacy QWidget 承载技术,需要在 Player/原生复杂页原型后单独决策;本规格只要求单一规范栈,未把具体承载方案当作事实。
## 14. 实施记录
2026-07-15 完成 Phase 0/1 的首个兼容批次:
- 新增 `NavigationRoute` 与 `NavigationController`,支持规范 key、route 参数校验、认证/管理员门禁、push/replace/back、Home/Favorites reset、会话清理和 UI state 白名单。
- `HomeView` 将现有 legacy 动态属性统一转换为 route request;控制器校验成功后才操作 Widgets 页面栈。旧 `m_navStack` 暂时保留用于页面实例和动画兼容。
- `MainWindow` 的登录/退出切换已通知控制器建立或清理认证会话。
- 采用建议默认值:Favorites 是会话根;Settings/Manage section 不写全局历史;管理合集稳定名为 `collections`;当前不实现 OS/跨服务器 deep link。
- 新增 `qEmbyNavigationTest`,覆盖原子拒绝、栈操作、权限、UI state 与 logout 清理。下一批实现单引擎 `Router.qml`,届时控制器才从“旁路规范状态”升级为页面实例切换的直接驱动者。
2026-07-15 完成 Phase 2 首批 Home/Favorites:
- 新增应用级 `QuickRouterHost` 与 `Router.qml`;首批只静态注册 Home/Favorites,其他 route 不解析未注入的页面类型,也不会在 legacy Widgets 滑入期间闪现恢复页。
- 新增 `HomeQuickRouterView`,在同一 engine 中注入 Dashboard/Favorites 两个会话单例 ViewModel,并按规范 route 显式切换 active 生命周期。
- 播放、收藏、详情/媒体库/人物/分类导航、通用菜单及 Dashboard 管理媒体库专用菜单继续复用 `BaseView`/C++ action bridge,避免页面迁移造成功能回退。
- Home/Favorites 单引擎路径默认开启;设置环境变量 `QEMBY_USE_LEGACY_HOME_PAGES=1` 可回退旧双 `QuickPageHost`,用于一个发布周期内的兼容验证。
- 首批无服务器 offscreen 冒烟和 legacy fallback 冒烟均可稳定运行至测试超时,未出现 QML runtime error。期间发现并修复 `AdminTranscodingPage.DoubleField` 覆盖 `QQuickItem` final `bottom/top` 属性的问题。