Files
qemby/ai/specs/qml-navigation.md

421 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 属性的问题。
2026-07-15 完成 Phase 2 第二批 Search:
- `Router.qml` 增加 Search 静态组件;Search 与 Home/Favorites 共享根 engine、图片 provider 和 action bridge。
- `HomeView::triggerSearch()` 改为 push 规范 search route;同一 Router widget 内切换也会记录历史,因此 Home → Search → Back 可以恢复 Home,Search → legacy Detail → Back 可以恢复原搜索。
- `SearchViewModel` 增加 active/generation 取消语义;inactive 时只记录待搜索 query,重新激活后按需恢复,避免隐藏页面回写陈旧结果。
- 导航契约测试新增 search key 和返回用例;默认 Router 与 legacy fallback 的无服务器 offscreen 冒烟均无 runtime 输出。
2026-07-15 完成 Phase 2 第三批 Category/Library/Person/Filtered:
- 根 Router 静态注册 CategoryPage 与 LibraryPage;Library、Person、Filtered 复用同一页面类型,但 route key 变化时重新创建 QML 页面。
- HomeView 的分类、媒体库、文件夹、人物和过滤入口统一 push 规范 route;legacy fallback 仍调用原 `create*View()`。
- Favorite Category 将 `favorite` 纳入规范 key,避免与普通 Category 使用相同身份。
- Category/Library ViewModel 的 inactive 路由更新只保存最新参数;generation 同时覆盖详情探测、首屏、渐进加载和 loadMore,防止离开后陈旧回写。
- 当前每类参数化页面仍共享一个会话 ViewModel;已加载 route 返回时复用数据,切换 route 时重建状态。每个历史 route 独立的 LRU 实例和滚动 uiState 尚未实现。
- 导航测试增加 Favorite Category 身份用例;Debug 构建、qmllint、默认 Router 与 legacy fallback 无服务器启动冒烟通过。