diff --git a/ai/qml-migration-status.md b/ai/qml-migration-status.md index b6d6648..2584619 100644 --- a/ai/qml-migration-status.md +++ b/ai/qml-migration-status.md @@ -44,7 +44,7 @@ | 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`;登录→首页→收藏→详情→返回→设置→退出全链路,检查窗口按钮与托盘 | +| 2 | 应用壳 | `MainWindow` 标题栏、全局搜索、HomeView 侧栏与路由栈 | core | in-progress | `NavigationController`、单引擎 `Router.qml`、Home/Favorites/Search 已接入;待 Category/Library 等浏览 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`;新增/编辑/删除/扫描/取消扫描/排序 | @@ -65,7 +65,9 @@ 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 所有权。 +当前开发批次(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。 + +Phase 2 第二批(2026-07-15):Search 已加入根 Router。`HomeView::triggerSearch()` 现在生成规范 `search` route;同一 Quick host 内的 Home/Favorites/Search 切换只更新 Controller/Loader,进入 legacy 详情页时仍保存兼容历史。`SearchViewModel` 新增 active/generation 生命周期,隐藏或离开搜索时淘汰在途结果,返回时仅按需恢复;旧 `SearchView` 默认 active 行为保持兼容。下一批进入 Category/Library/Person/Filtered,重点解决带参数 route 的 ViewModel 实例所有权与滚动状态。 阶段门禁:每阶段至少通过 QML 静态检查、Debug 构建、明暗主题、窗口缩放、键盘焦点、页面反复进入退出、网络失败/空数据检查;依赖真实服务器的行为必须用 Emby/Jellyfin 各冒烟一次。 diff --git a/ai/specs/qml-navigation.md b/ai/specs/qml-navigation.md index 6867e98..5062bb1 100644 --- a/ai/specs/qml-navigation.md +++ b/ai/specs/qml-navigation.md @@ -402,3 +402,10 @@ qemby://search?q= - 播放、收藏、详情/媒体库/人物/分类导航、通用菜单及 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 输出。 diff --git a/src/qEmbyApp/qml/Router.qml b/src/qEmbyApp/qml/Router.qml index fba8a8f..5b8fc5f 100644 --- a/src/qEmbyApp/qml/Router.qml +++ b/src/qEmbyApp/qml/Router.qml @@ -9,7 +9,7 @@ Item { property var navigationController: navigation // Keep this allow-list deliberately small until each page's context // ViewModel has been moved to the root Quick host. - property var enabledRoutes: ["home", "favorites"] + property var enabledRoutes: ["home", "favorites", "search"] property var currentRoute: navigationController ? navigationController.currentRoute : ({ "name": "navigationError", @@ -69,7 +69,7 @@ Item { } function routeImplemented(name) { - return name === "home" || name === "favorites"; + return name === "home" || name === "favorites" || name === "search"; } function routeKnown(name) { @@ -102,6 +102,7 @@ Item { switch (name) { case "home": return homeComponent; case "favorites": return favoritesComponent; + case "search": return searchComponent; default: return null; } @@ -219,6 +220,7 @@ Item { Component { id: homeComponent; DashboardPage { anchors.fill: parent } } Component { id: favoritesComponent; FavoritesPage { anchors.fill: parent } } + Component { id: searchComponent; SearchPage { anchors.fill: parent } } Component { id: recoveryComponent diff --git a/src/qEmbyApp/tests/navigationcontroller_test.cpp b/src/qEmbyApp/tests/navigationcontroller_test.cpp index 6f4cd52..5f61daa 100644 --- a/src/qEmbyApp/tests/navigationcontroller_test.cpp +++ b/src/qEmbyApp/tests/navigationcontroller_test.cpp @@ -27,6 +27,13 @@ int main(int argc, char **argv) navigation.enterAuthenticatedSession(false); ok &= check(navigation.currentRoute().value("name") == "home", "session starts at home"); + ok &= check(navigation.push({{"name", "search"}, + {"params", QVariantMap{{"query", "star wars"}}}}), + "accepts a valid search route"); + ok &= check(navigation.currentRoute().value("key") == "search:star%20wars", + "search query participates in the route key"); + ok &= check(navigation.back() && navigation.currentRoute().value("name") == "home", + "back returns from search to its session root"); ok &= check(navigation.push({{"name", "library"}, {"params", QVariantMap{{"libraryId", "lib/1"}, {"title", "Movies"}}}}), diff --git a/src/qEmbyApp/views/search/searchviewmodel.cpp b/src/qEmbyApp/views/search/searchviewmodel.cpp index e739b77..fab3a2d 100644 --- a/src/qEmbyApp/views/search/searchviewmodel.cpp +++ b/src/qEmbyApp/views/search/searchviewmodel.cpp @@ -136,6 +136,28 @@ void SearchViewModel::setCurrentTab(int tab) { launchCurrentSearch(); } +void SearchViewModel::setActive(bool active) { + if (m_active == active) + return; + + m_active = active; + if (!active) { + ++m_searchGeneration; + if (m_loading) + m_retryOnActivate = true; + if (m_loading) { + m_loading = false; + emit stateChanged(); + } + return; + } + + if (m_retryOnActivate && !m_query.isEmpty()) { + m_retryOnActivate = false; + launchCurrentSearch(); + } +} + QString SearchViewModel::includeItemTypes(int tab) { switch (tab) { case 0: @@ -155,6 +177,10 @@ QString SearchViewModel::includeItemTypes(int tab) { void SearchViewModel::launchCurrentSearch() { if (m_query.isEmpty()) return; + if (!m_active) { + m_retryOnActivate = true; + return; + } QCoro::connect(search(m_query), this, []() {}); } @@ -163,7 +189,16 @@ QCoro::Task SearchViewModel::search(QString query) { if (query.isEmpty() || !m_core || !m_core->mediaService()) co_return; + const bool queryChanged = m_query != query; m_query = query; + if (!m_active) { + m_retryOnActivate = true; + if (queryChanged) + emit stateChanged(); + co_return; + } + + m_retryOnActivate = false; const quint64 generation = ++m_searchGeneration; const QString types = includeItemTypes(m_currentTab); m_loading = true; diff --git a/src/qEmbyApp/views/search/searchviewmodel.h b/src/qEmbyApp/views/search/searchviewmodel.h index d195988..ddffaa1 100644 --- a/src/qEmbyApp/views/search/searchviewmodel.h +++ b/src/qEmbyApp/views/search/searchviewmodel.h @@ -64,6 +64,7 @@ public: void setTileView(bool tileView); void setCurrentTab(int tab); + void setActive(bool active); QCoro::Task search(QString query); void updateItem(const MediaItem &item); @@ -92,6 +93,8 @@ private: QString m_statsText; QString m_errorText; bool m_loading = false; + bool m_active = true; + bool m_retryOnActivate = false; bool m_tileView = false; int m_currentTab = 0; quint64 m_searchGeneration = 0; diff --git a/src/qEmbyApp/views/user/homequickrouterview.cpp b/src/qEmbyApp/views/user/homequickrouterview.cpp index c0e3c94..78499a4 100644 --- a/src/qEmbyApp/views/user/homequickrouterview.cpp +++ b/src/qEmbyApp/views/user/homequickrouterview.cpp @@ -8,6 +8,7 @@ #include "../../components/moderntoast.h" #include "../../quick/navigationcontroller.h" #include "../../quick/quickrouterhost.h" +#include "../search/searchviewmodel.h" #include #include @@ -37,11 +38,14 @@ HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, m_host = new QuickRouterHost(core, navigation, this); m_dashboardViewModel = new UserDashboardViewModel(core, this); m_favoritesViewModel = new FavoritesViewModel(core, this); + m_searchViewModel = new SearchViewModel(core, this); m_host->setContextProperty(QStringLiteral("dashboardPage"), m_dashboardViewModel); m_host->setContextProperty(QStringLiteral("favoritesPage"), m_favoritesViewModel); + m_host->setContextProperty(QStringLiteral("searchPage"), + m_searchViewModel); m_host->loadRouter(); layout->addWidget(m_host); @@ -96,6 +100,18 @@ HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, { handleMoreMenuRequested(item, QCursor::pos()); }); + + connect(m_searchViewModel, &SearchViewModel::itemActivated, + this, &HomeQuickRouterView::forwardItemActivation); + connect(m_searchViewModel, &SearchViewModel::playRequested, + this, &BaseView::handlePlayRequested); + connect(m_searchViewModel, &SearchViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_searchViewModel, &SearchViewModel::moreRequested, + this, [this](const MediaItem &item, const QPoint &localPos) + { + handleMoreMenuRequested(item, m_host->mapToGlobal(localPos)); + }); } void HomeQuickRouterView::refreshHome() @@ -125,6 +141,7 @@ void HomeQuickRouterView::onMediaItemUpdated(const MediaItem &item) { m_dashboardViewModel->updateItem(item); m_favoritesViewModel->updateItem(item); + m_searchViewModel->updateItem(item); } void HomeQuickRouterView::onMediaItemRemoved(const QString &itemId) @@ -199,21 +216,50 @@ void HomeQuickRouterView::dispatchCardContextMenuRequest( void HomeQuickRouterView::updateRouteActivity() { - const QString routeName = m_navigation - ? m_navigation->currentRoute().value(QStringLiteral("name")).toString() - : QString(); + const QVariantMap route = m_navigation + ? m_navigation->currentRoute() + : QVariantMap{}; + const QString routeName = route.value(QStringLiteral("name")).toString(); const bool hostActive = m_host && m_host->isActive(); m_dashboardViewModel->setActive(hostActive && routeName == QLatin1String("home")); m_favoritesViewModel->setActive(hostActive && routeName == QLatin1String("favorites")); + + const bool searchActive = hostActive && + routeName == QLatin1String("search"); + if (!searchActive) + { + m_searchViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString query = params.value(QStringLiteral("query")) + .toString().trimmed(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const bool routeChanged = routeKey != m_searchRouteKey || + query != m_searchQuery; + if (!query.isEmpty() && routeChanged) + { + m_searchRouteKey = routeKey; + m_searchQuery = query; + + // SearchViewModel deliberately remembers a query while inactive. + // Queue the new query before activation so an interrupted previous + // query cannot be restarted immediately before this one. + QCoro::connect(m_searchViewModel->search(query), this, []() {}); + } + + m_searchViewModel->setActive(true); } void HomeQuickRouterView::deactivateRoutes() { m_dashboardViewModel->setActive(false); m_favoritesViewModel->setActive(false); + m_searchViewModel->setActive(false); } void HomeQuickRouterView::forwardItemActivation(const MediaItem &item) diff --git a/src/qEmbyApp/views/user/homequickrouterview.h b/src/qEmbyApp/views/user/homequickrouterview.h index 335b6fc..57c3ca4 100644 --- a/src/qEmbyApp/views/user/homequickrouterview.h +++ b/src/qEmbyApp/views/user/homequickrouterview.h @@ -5,6 +5,7 @@ class FavoritesViewModel; class NavigationController; class QuickRouterHost; +class SearchViewModel; class UserDashboardViewModel; // Bridges the first application-level QML routes to the existing BaseView @@ -52,4 +53,7 @@ private: QuickRouterHost *m_host = nullptr; UserDashboardViewModel *m_dashboardViewModel = nullptr; FavoritesViewModel *m_favoritesViewModel = nullptr; + SearchViewModel *m_searchViewModel = nullptr; + QString m_searchRouteKey; + QString m_searchQuery; }; diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp index ce5d50a..95ead6a 100644 --- a/src/qEmbyApp/views/user/homeview.cpp +++ b/src/qEmbyApp/views/user/homeview.cpp @@ -1085,7 +1085,12 @@ void HomeView::triggerSearch(const QString &query) m_libraryList->clearSelection(); - pushView(createSearchView(trimmedQuery)); + const QVariantMap searchRequest{ + {QStringLiteral("name"), QStringLiteral("search")}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("query"), trimmedQuery}}}}; + if (!pushQuickRoute(searchRequest)) + pushView(createSearchView(trimmedQuery)); } void HomeView::setupSearchHistory() @@ -1263,6 +1268,39 @@ void HomeView::pushView(QWidget *view) Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); } +bool HomeView::pushQuickRoute(const QVariantMap &request) +{ + if (!m_useQuickHomeRouter || !m_homeQuickRouterView || + !m_navigationController || !m_contentSwitcher) + return false; + + QWidget *current = m_contentSwitcher->currentWidget(); + if (!m_navigationController->push(request)) + return false; + + if (current) + { + RouteInfo info; + info.widget = current; + info.isDynamic = current->property("isDynamic").toBool(); + info.routeType = current->property("routeType").toString(); + info.routeId = current->property("routeId").toString(); + info.routeTitle = current->property("routeTitle").toString(); + info.routeExtraId = current->property("routeExtraId").toString(); + m_navStack.push(info); + } + + if (current != m_homeQuickRouterView) + { + if (m_contentSwitcher->indexOf(m_homeQuickRouterView) == -1) + m_contentSwitcher->addWidget(m_homeQuickRouterView); + m_contentSwitcher->slideInWgt(m_homeQuickRouterView, + SlidingStackedWidget::RightToLeft); + } + Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); + return true; +} + void HomeView::navigateBack() { QWidget *current = m_contentSwitcher->currentWidget(); @@ -1349,6 +1387,12 @@ void HomeView::navigateBack() if (!targetView) targetView = homeRootWidget(); + if (targetView == current) + { + Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); + return; + } + diff --git a/src/qEmbyApp/views/user/homeview.h b/src/qEmbyApp/views/user/homeview.h index 01aaeb5..4dd30d8 100644 --- a/src/qEmbyApp/views/user/homeview.h +++ b/src/qEmbyApp/views/user/homeview.h @@ -116,6 +116,7 @@ private: void pushView(QWidget* view); + bool pushQuickRoute(const QVariantMap &request); void resetToView(QWidget* view, const QString &rootRoute = QString()); QWidget* homeRootWidget() const;