From e2357aaa32b0618b2a5ee68aef052788bd2e1b92 Mon Sep 17 00:00:00 2001 From: dela Date: Wed, 15 Jul 2026 14:03:31 +0800 Subject: [PATCH] feat: route library browsing through QML shell --- ai/qml-migration-status.md | 6 +- ai/specs/qml-navigation.md | 9 + src/qEmbyApp/qml/Router.qml | 21 +- src/qEmbyApp/quick/navigationcontroller.cpp | 3 + .../tests/navigationcontroller_test.cpp | 8 + src/qEmbyApp/views/media/libraryviewmodel.cpp | 240 +++++++++++------- src/qEmbyApp/views/media/libraryviewmodel.h | 7 + src/qEmbyApp/views/user/categoryviewmodel.cpp | 27 +- src/qEmbyApp/views/user/categoryviewmodel.h | 1 + .../views/user/homequickrouterview.cpp | 134 +++++++++- src/qEmbyApp/views/user/homequickrouterview.h | 9 + src/qEmbyApp/views/user/homeview.cpp | 87 +++++-- src/qEmbyApp/views/user/homeview.h | 4 + 13 files changed, 439 insertions(+), 117 deletions(-) diff --git a/ai/qml-migration-status.md b/ai/qml-migration-status.md index 2584619..3c493e3 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/Search 已接入;待 Category/Library 等浏览 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/Person/Filtered 已接入;待 Detail/Season、侧栏与标题导航 | `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`;新增/编辑/删除/扫描/取消扫描/排序 | @@ -67,7 +67,9 @@ 当前开发批次(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 实例所有权与滚动状态。 +Phase 2 第二批(2026-07-15):Search 已加入根 Router。`HomeView::triggerSearch()` 现在生成规范 `search` route;同一 Quick host 内的 Home/Favorites/Search 切换只更新 Controller/Loader,进入 legacy 详情页时仍保存兼容历史。`SearchViewModel` 新增 active/generation 生命周期,隐藏或离开搜索时淘汰在途结果,返回时仅按需恢复;旧 `SearchView` 默认 active 行为保持兼容。 + +Phase 2 第三批(2026-07-15):Category、Library、Person、Filtered 已加入根 Router。`HomeView` 的首页媒体库、侧栏媒体库、文件夹、人物和过滤入口统一生成规范 route;Favorite Category 在 key 中具有独立身份。Category/Library ViewModel 在 inactive 时只保存最新参数,并用 generation 淘汰详情探测、首屏与 loadMore 的过期回写;旧包装器继续保持默认 active 兼容。当前 Category 和 Library 系列各使用一个会话级 ViewModel,返回已完成 route 时可复用数据,切换参数时重建当前状态;尚未实现每个历史 route 独立的 LRU ViewModel 与滚动位置。下一批优先接 Detail/Season,再统一 `uiState.scrollY`。 阶段门禁:每阶段至少通过 QML 静态检查、Debug 构建、明暗主题、窗口缩放、键盘焦点、页面反复进入退出、网络失败/空数据检查;依赖真实服务器的行为必须用 Emby/Jellyfin 各冒烟一次。 diff --git a/ai/specs/qml-navigation.md b/ai/specs/qml-navigation.md index 5062bb1..af80f47 100644 --- a/ai/specs/qml-navigation.md +++ b/ai/specs/qml-navigation.md @@ -409,3 +409,12 @@ qemby://search?q= - `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 无服务器启动冒烟通过。 diff --git a/src/qEmbyApp/qml/Router.qml b/src/qEmbyApp/qml/Router.qml index 5b8fc5f..b03fae1 100644 --- a/src/qEmbyApp/qml/Router.qml +++ b/src/qEmbyApp/qml/Router.qml @@ -9,7 +9,8 @@ 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", "search"] + property var enabledRoutes: ["home", "favorites", "search", "category", + "library", "person", "filtered"] property var currentRoute: navigationController ? navigationController.currentRoute : ({ "name": "navigationError", @@ -69,7 +70,9 @@ Item { } function routeImplemented(name) { - return name === "home" || name === "favorites" || name === "search"; + return name === "home" || name === "favorites" || name === "search" + || name === "category" || name === "library" || name === "person" + || name === "filtered"; } function routeKnown(name) { @@ -103,6 +106,11 @@ Item { case "home": return homeComponent; case "favorites": return favoritesComponent; case "search": return searchComponent; + case "category": return categoryComponent; + case "library": + case "person": + case "filtered": + return libraryComponent; default: return null; } @@ -185,6 +193,13 @@ Item { return true; } + function scrollToTop() { + if (!pageLoader.item) + return false; + callHook(pageLoader.item, "scrollToTop", undefined); + return true; + } + onCurrentRouteChanged: applyRoute(currentRoute) onActiveChanged: { if (!pageLoader.item) @@ -221,6 +236,8 @@ Item { Component { id: homeComponent; DashboardPage { anchors.fill: parent } } Component { id: favoritesComponent; FavoritesPage { anchors.fill: parent } } Component { id: searchComponent; SearchPage { anchors.fill: parent } } + Component { id: categoryComponent; CategoryPage { anchors.fill: parent } } + Component { id: libraryComponent; LibraryPage { anchors.fill: parent } } Component { id: recoveryComponent diff --git a/src/qEmbyApp/quick/navigationcontroller.cpp b/src/qEmbyApp/quick/navigationcontroller.cpp index 780d607..d6f6f72 100644 --- a/src/qEmbyApp/quick/navigationcontroller.cpp +++ b/src/qEmbyApp/quick/navigationcontroller.cpp @@ -191,6 +191,9 @@ bool NavigationController::normalize(const QVariantMap &request, QStringList identity; for (const QString &field : required) identity << encoded(params.value(field).toString()); + if (name == QLatin1String("category") && + params.value(QStringLiteral("favorite")).toBool()) + identity << QStringLiteral("favorite"); route->key = identity.isEmpty() ? name : name + QLatin1Char(':') + identity.join(QLatin1Char(':')); return true; } diff --git a/src/qEmbyApp/tests/navigationcontroller_test.cpp b/src/qEmbyApp/tests/navigationcontroller_test.cpp index 5f61daa..326db32 100644 --- a/src/qEmbyApp/tests/navigationcontroller_test.cpp +++ b/src/qEmbyApp/tests/navigationcontroller_test.cpp @@ -34,6 +34,14 @@ int main(int argc, char **argv) "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", "category"}, + {"params", QVariantMap{{"categoryId", "Movie"}, + {"favorite", true}}}}), + "accepts a favorite category route"); + ok &= check(navigation.currentRoute().value("key") == + "category:Movie:favorite", + "favorite category has a distinct route identity"); + ok &= check(navigation.back(), "returns from favorite category"); ok &= check(navigation.push({{"name", "library"}, {"params", QVariantMap{{"libraryId", "lib/1"}, {"title", "Movies"}}}}), diff --git a/src/qEmbyApp/views/media/libraryviewmodel.cpp b/src/qEmbyApp/views/media/libraryviewmodel.cpp index d56e91e..588bcba 100644 --- a/src/qEmbyApp/views/media/libraryviewmodel.cpp +++ b/src/qEmbyApp/views/media/libraryviewmodel.cpp @@ -214,119 +214,111 @@ void LibraryViewModel::setActive(bool active) { m_active = active; if (!active) { ++m_generation; - if (m_loading || m_loadingMore) + if (m_routeRequestInFlight || m_loading || m_loadingMore) m_reloadOnActivate = true; + m_routeRequestInFlight = false; m_loading = false; m_loadingMore = false; emit stateChanged(); } else if (m_reloadOnActivate) { m_reloadOnActivate = false; - launchReload(); + launchCurrentRouteLoad(); } } QCoro::Task LibraryViewModel::loadLibrary(QString libraryId, QString libraryName) { + libraryId = libraryId.trimmed(); + if (isCurrentRoute(LibraryMode, libraryId, {})) { + if (m_title != libraryName) { + m_title = std::move(libraryName); + emit stateChanged(); + } + co_return; + } + const quint64 generation = ++m_generation; - m_mode = LibraryMode; - m_targetId = std::move(libraryId); - m_title = std::move(libraryName); - m_filterType.clear(); - m_filterValue.clear(); + configureRoute(LibraryMode, std::move(libraryId), std::move(libraryName)); + + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + co_await loadCurrentRoute(generation); +} + +QCoro::Task LibraryViewModel::loadPerson(QString personId, + QString personName) { + personId = personId.trimmed(); + if (isCurrentRoute(PersonMode, personId, {})) { + if (m_title != personName) { + m_title = std::move(personName); + emit stateChanged(); + } + co_return; + } + + const quint64 generation = ++m_generation; + configureRoute(PersonMode, std::move(personId), std::move(personName)); + + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + co_await loadCurrentRoute(generation); +} + +QCoro::Task LibraryViewModel::loadFiltered(QString filterType, + QString filterValue) { + filterType = filterType.trimmed(); + filterValue = filterValue.trimmed(); + if (isCurrentRoute(FilteredMode, filterType, filterValue)) + co_return; + + const quint64 generation = ++m_generation; + configureRoute(FilteredMode, std::move(filterType), std::move(filterValue)); + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + co_await loadCurrentRoute(generation); +} + +bool LibraryViewModel::isCurrentRoute(Mode mode, const QString &primary, + const QString &secondary) const { + if (!m_routeInitialized || m_mode != mode) + return false; + if (mode == FilteredMode) + return m_filterType == primary && m_filterValue == secondary; + return m_targetId == primary; +} + +void LibraryViewModel::configureRoute(Mode mode, QString primary, + QString secondary) { + m_routeInitialized = true; + m_mode = mode; m_currentItem = {}; m_tabIndex = 0; m_firstTabText = tr("All"); m_tabsVisible = false; m_favoriteVisible = false; + m_reloadOnActivate = false; + + if (mode == FilteredMode) { + m_targetId.clear(); + m_filterType = std::move(primary); + m_filterValue = std::move(secondary); + m_title = m_filterValue; + } else { + m_targetId = std::move(primary); + m_title = std::move(secondary); + m_filterType.clear(); + m_filterValue.clear(); + } + restorePreferences(); emit stateChanged(); emit controlsChanged(); - - if (!m_active) { - m_reloadOnActivate = true; - co_return; - } - - QPointer guard(this); - if (m_active && m_core && m_core->mediaService() && !m_targetId.isEmpty()) { - try { - const MediaItem item = - co_await m_core->mediaService()->getItemDetail(m_targetId); - if (!guard || !m_active || generation != m_generation) - co_return; - if (item.id == m_targetId) - configureCurrentItem(item); - } catch (const std::exception &error) { - if (!guard || generation != m_generation) - co_return; - qWarning() << "[LibraryViewModel] detail probe failed:" << error.what(); - } - } - if (!guard || !m_active || generation != m_generation) - co_return; - co_await reload(); -} - -QCoro::Task LibraryViewModel::loadPerson(QString personId, - QString personName) { - const quint64 generation = ++m_generation; - m_mode = PersonMode; - m_targetId = std::move(personId); - m_title = std::move(personName); - m_filterType.clear(); - m_filterValue.clear(); - m_currentItem = {}; - m_tabIndex = 0; - m_tabsVisible = false; - m_favoriteVisible = false; - restorePreferences(); - emit stateChanged(); - emit controlsChanged(); - - if (!m_active) { - m_reloadOnActivate = true; - co_return; - } - - QPointer guard(this); - if (m_active && m_core && m_core->mediaService() && !m_targetId.isEmpty()) { - try { - const MediaItem item = - co_await m_core->mediaService()->getItemDetail(m_targetId); - if (!guard || !m_active || generation != m_generation) - co_return; - if (item.id == m_targetId) { - m_currentItem = item; - m_favoriteVisible = true; - emit controlsChanged(); - } - } catch (const std::exception &error) { - if (!guard || generation != m_generation) - co_return; - qWarning() << "[LibraryViewModel] person detail failed:" << error.what(); - } - } - if (!guard || !m_active || generation != m_generation) - co_return; - co_await reload(); -} - -QCoro::Task LibraryViewModel::loadFiltered(QString filterType, - QString filterValue) { - ++m_generation; - m_mode = FilteredMode; - m_filterType = std::move(filterType); - m_filterValue = std::move(filterValue); - m_targetId.clear(); - m_title = m_filterValue; - m_currentItem = {}; - m_tabIndex = 0; - m_tabsVisible = false; - m_favoriteVisible = false; - restorePreferences(); - emit stateChanged(); - emit controlsChanged(); - co_await reload(); } void LibraryViewModel::configureCurrentItem(const MediaItem &item) { @@ -499,11 +491,72 @@ void LibraryViewModel::updateStats() { emit stateChanged(); } +void LibraryViewModel::launchCurrentRouteLoad() { + if (!m_active || !m_routeInitialized) { + m_reloadOnActivate = m_routeInitialized; + return; + } + const quint64 generation = ++m_generation; + QCoro::connect(loadCurrentRoute(generation), this, []() {}); +} + +QCoro::Task LibraryViewModel::loadCurrentRoute(quint64 generation) { + if (!m_active || !m_routeInitialized || generation != m_generation) { + if (!m_active) + m_reloadOnActivate = true; + co_return; + } + + m_routeRequestInFlight = true; + m_loading = true; + m_loadingMore = false; + m_hasMore = false; + m_totalCount = 0; + m_errorText.clear(); + m_statsText = tr("Loading..."); + m_items.clear(); + emit stateChanged(); + + QPointer guard(this); + if (m_mode != FilteredMode && m_core && m_core->mediaService() && + !m_targetId.isEmpty()) { + try { + const MediaItem item = + co_await m_core->mediaService()->getItemDetail(m_targetId); + if (!guard || !m_active || generation != m_generation) + co_return; + + if (item.id == m_targetId) { + if (m_mode == PersonMode) { + m_currentItem = item; + m_favoriteVisible = true; + emit controlsChanged(); + } else { + configureCurrentItem(item); + } + } + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + qWarning() << (m_mode == PersonMode + ? "[LibraryViewModel] person detail failed:" + : "[LibraryViewModel] detail probe failed:") + << error.what(); + } + } + + if (!guard || !m_active || generation != m_generation) + co_return; + m_routeRequestInFlight = false; + co_await reload(); +} + void LibraryViewModel::launchReload() { if (!m_active) { m_reloadOnActivate = true; return; } + m_routeRequestInFlight = false; QCoro::connect(reload(), this, []() {}); } @@ -512,6 +565,7 @@ QCoro::Task LibraryViewModel::reload() { m_reloadOnActivate = true; co_return; } + m_routeRequestInFlight = false; const QueryState query = buildQuery(); const quint64 generation = ++m_generation; m_query = query; diff --git a/src/qEmbyApp/views/media/libraryviewmodel.h b/src/qEmbyApp/views/media/libraryviewmodel.h index c0e122b..93b9931 100644 --- a/src/qEmbyApp/views/media/libraryviewmodel.h +++ b/src/qEmbyApp/views/media/libraryviewmodel.h @@ -138,6 +138,9 @@ private: }; QString preferenceTargetId() const; + bool isCurrentRoute(Mode mode, const QString &primary, + const QString &secondary) const; + void configureRoute(Mode mode, QString primary, QString secondary); QString sortBy() const; QueryState buildQuery() const; bool queryValid(const QueryState &query) const; @@ -146,6 +149,8 @@ private: void saveSortPreference(); void saveViewPreference(); void updateStats(); + void launchCurrentRouteLoad(); + QCoro::Task loadCurrentRoute(quint64 generation); void launchReload(); QCoro::Task reload(); QCoro::Task fetchNextPage(quint64 generation, QueryState query, @@ -172,6 +177,8 @@ private: bool m_tabsVisible = false; bool m_favoriteVisible = false; bool m_active = true; + bool m_routeInitialized = false; + bool m_routeRequestInFlight = false; bool m_reloadOnActivate = false; int m_tabIndex = 0; int m_sortIndex = 0; diff --git a/src/qEmbyApp/views/user/categoryviewmodel.cpp b/src/qEmbyApp/views/user/categoryviewmodel.cpp index 8156ae5..de09515 100644 --- a/src/qEmbyApp/views/user/categoryviewmodel.cpp +++ b/src/qEmbyApp/views/user/categoryviewmodel.cpp @@ -237,13 +237,24 @@ void CategoryViewModel::setActive(bool active) { m_loadingMore = false; emit stateChanged(); } else if (m_reloadOnActivate) { + const bool clearRecommendCache = m_clearRecommendCacheOnActivate; m_reloadOnActivate = false; - launchReload(); + m_clearRecommendCacheOnActivate = false; + launchReload(clearRecommendCache); } } QCoro::Task CategoryViewModel::loadCategory(QString categoryType, QString title) { + const bool routeChanged = + m_category != categoryType || m_title != title; + if (!routeChanged) { + if (!m_active) + m_reloadOnActivate = true; + co_return; + } + + ++m_generation; m_category = std::move(categoryType); m_title = std::move(title); configureCategory(); @@ -251,6 +262,15 @@ QCoro::Task CategoryViewModel::loadCategory(QString categoryType, emit stateChanged(); emit sortChanged(); emit viewChanged(); + + if (!m_active) { + m_loading = false; + m_loadingMore = false; + m_reloadOnActivate = true; + m_clearRecommendCacheOnActivate = false; + co_return; + } + co_await reload(); } @@ -346,6 +366,8 @@ QString CategoryViewModel::sortBy() const { void CategoryViewModel::launchReload(bool clearRecommendCache) { if (!m_active) { m_reloadOnActivate = true; + m_clearRecommendCacheOnActivate = + m_clearRecommendCacheOnActivate || clearRecommendCache; return; } QCoro::connect(reload(clearRecommendCache), this, []() {}); @@ -354,6 +376,8 @@ void CategoryViewModel::launchReload(bool clearRecommendCache) { QCoro::Task CategoryViewModel::reload(bool clearRecommendCache) { if (!m_active) { m_reloadOnActivate = true; + m_clearRecommendCacheOnActivate = + m_clearRecommendCacheOnActivate || clearRecommendCache; co_return; } if (!m_core || !m_core->mediaService() || m_category.isEmpty()) @@ -361,6 +385,7 @@ QCoro::Task CategoryViewModel::reload(bool clearRecommendCache) { const quint64 generation = ++m_generation; m_reloadOnActivate = false; + m_clearRecommendCacheOnActivate = false; m_loading = true; m_loadingMore = false; m_errorText.clear(); diff --git a/src/qEmbyApp/views/user/categoryviewmodel.h b/src/qEmbyApp/views/user/categoryviewmodel.h index 4f53ad8..cc589a7 100644 --- a/src/qEmbyApp/views/user/categoryviewmodel.h +++ b/src/qEmbyApp/views/user/categoryviewmodel.h @@ -138,6 +138,7 @@ private: bool m_descending = true; bool m_active = true; bool m_reloadOnActivate = false; + bool m_clearRecommendCacheOnActivate = false; int m_sortIndex = 0; quint64 m_generation = 0; }; diff --git a/src/qEmbyApp/views/user/homequickrouterview.cpp b/src/qEmbyApp/views/user/homequickrouterview.cpp index 78499a4..0cf0726 100644 --- a/src/qEmbyApp/views/user/homequickrouterview.cpp +++ b/src/qEmbyApp/views/user/homequickrouterview.cpp @@ -2,6 +2,7 @@ #include "dashboardviewmodel.h" #include "favoritesviewmodel.h" +#include "categoryviewmodel.h" #include "../../components/libraryactionmenu.h" #include "../../components/mediaimageeditdialog.h" @@ -9,6 +10,7 @@ #include "../../quick/navigationcontroller.h" #include "../../quick/quickrouterhost.h" #include "../search/searchviewmodel.h" +#include "../media/libraryviewmodel.h" #include #include @@ -18,6 +20,8 @@ #include #include #include +#include +#include #include #include @@ -39,6 +43,8 @@ HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, m_dashboardViewModel = new UserDashboardViewModel(core, this); m_favoritesViewModel = new FavoritesViewModel(core, this); m_searchViewModel = new SearchViewModel(core, this); + m_categoryViewModel = new CategoryViewModel(core, this); + m_libraryViewModel = new LibraryViewModel(core, this); m_host->setContextProperty(QStringLiteral("dashboardPage"), m_dashboardViewModel); @@ -46,6 +52,10 @@ HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, m_favoritesViewModel); m_host->setContextProperty(QStringLiteral("searchPage"), m_searchViewModel); + m_host->setContextProperty(QStringLiteral("categoryPage"), + m_categoryViewModel); + m_host->setContextProperty(QStringLiteral("libraryPage"), + m_libraryViewModel); m_host->loadRouter(); layout->addWidget(m_host); @@ -112,6 +122,32 @@ HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, { handleMoreMenuRequested(item, m_host->mapToGlobal(localPos)); }); + + connect(m_categoryViewModel, &CategoryViewModel::itemActivated, + this, &HomeQuickRouterView::forwardItemActivation); + connect(m_categoryViewModel, &CategoryViewModel::playRequested, + this, &BaseView::handlePlayRequested); + connect(m_categoryViewModel, &CategoryViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_categoryViewModel, &CategoryViewModel::moreRequested, + this, [this](const MediaItem &item) + { + handleMoreMenuRequested(item, QCursor::pos()); + }); + + connect(m_libraryViewModel, &LibraryViewModel::itemActivated, + this, &HomeQuickRouterView::forwardItemActivation); + connect(m_libraryViewModel, &LibraryViewModel::playRequested, + this, &BaseView::handlePlayRequested); + connect(m_libraryViewModel, &LibraryViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_libraryViewModel, &LibraryViewModel::currentFavoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_libraryViewModel, &LibraryViewModel::moreRequested, + this, [this](const MediaItem &item) + { + handleMoreMenuRequested(item, QCursor::pos()); + }); } void HomeQuickRouterView::refreshHome() @@ -135,6 +171,9 @@ void HomeQuickRouterView::scrollToTop() m_dashboardViewModel->scrollToTop(); else if (routeName == QLatin1String("favorites")) m_favoritesViewModel->scrollToTop(); + else if (m_host && m_host->quickWidget()->rootObject()) + QMetaObject::invokeMethod(m_host->quickWidget()->rootObject(), + "scrollToTop"); } void HomeQuickRouterView::onMediaItemUpdated(const MediaItem &item) @@ -142,12 +181,16 @@ void HomeQuickRouterView::onMediaItemUpdated(const MediaItem &item) m_dashboardViewModel->updateItem(item); m_favoritesViewModel->updateItem(item); m_searchViewModel->updateItem(item); + m_categoryViewModel->updateItem(item); + m_libraryViewModel->updateItem(item); } void HomeQuickRouterView::onMediaItemRemoved(const QString &itemId) { m_dashboardViewModel->removeItem(itemId); m_favoritesViewModel->removeItem(itemId); + m_categoryViewModel->removeItem(itemId); + m_libraryViewModel->removeItem(itemId); } CardContextMenuRequest HomeQuickRouterView::showCardContextMenu( @@ -227,8 +270,16 @@ void HomeQuickRouterView::updateRouteActivity() m_favoritesViewModel->setActive(hostActive && routeName == QLatin1String("favorites")); - const bool searchActive = hostActive && - routeName == QLatin1String("search"); + updateSearchRoute(route, hostActive); + updateCategoryRoute(route, hostActive); + updateLibraryRoute(route, hostActive); +} + +void HomeQuickRouterView::updateSearchRoute(const QVariantMap &route, + bool hostActive) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + const bool searchActive = hostActive && routeName == QLatin1String("search"); if (!searchActive) { m_searchViewModel->setActive(false); @@ -255,11 +306,90 @@ void HomeQuickRouterView::updateRouteActivity() m_searchViewModel->setActive(true); } +void HomeQuickRouterView::updateCategoryRoute(const QVariantMap &route, + bool hostActive) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + const bool categoryActive = hostActive && + routeName == QLatin1String("category"); + if (!categoryActive) + { + m_categoryViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + QString categoryId = params.value(QStringLiteral("categoryId")) + .toString().trimmed(); + if (params.value(QStringLiteral("favorite")).toBool()) + categoryId.prepend(QStringLiteral("Favorite_")); + const QString title = params.value(QStringLiteral("title")).toString(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const QString routeIdentity = routeKey + QLatin1Char('\n') + title; + if (!categoryId.isEmpty() && routeIdentity != m_categoryRouteKey) + { + m_categoryViewModel->setActive(false); + m_categoryRouteKey = routeIdentity; + QCoro::connect(m_categoryViewModel->loadCategory(categoryId, title), + this, []() {}); + } + m_categoryViewModel->setActive(true); +} + +void HomeQuickRouterView::updateLibraryRoute(const QVariantMap &route, + bool hostActive) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + const bool libraryActive = hostActive && + (routeName == QLatin1String("library") || + routeName == QLatin1String("person") || + routeName == QLatin1String("filtered")); + if (!libraryActive) + { + m_libraryViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const QString title = params.value(QStringLiteral("title")).toString(); + const QString routeIdentity = routeKey + QLatin1Char('\n') + title; + if (routeIdentity != m_libraryRouteKey) + { + m_libraryViewModel->setActive(false); + m_libraryRouteKey = routeIdentity; + if (routeName == QLatin1String("library")) + { + QCoro::connect(m_libraryViewModel->loadLibrary( + params.value(QStringLiteral("libraryId")).toString(), + title), + this, []() {}); + } + else if (routeName == QLatin1String("person")) + { + QCoro::connect(m_libraryViewModel->loadPerson( + params.value(QStringLiteral("personId")).toString(), + title), + this, []() {}); + } + else + { + QCoro::connect(m_libraryViewModel->loadFiltered( + params.value(QStringLiteral("filterType")).toString(), + params.value(QStringLiteral("filterValue")).toString()), + this, []() {}); + } + } + m_libraryViewModel->setActive(true); +} + void HomeQuickRouterView::deactivateRoutes() { m_dashboardViewModel->setActive(false); m_favoritesViewModel->setActive(false); m_searchViewModel->setActive(false); + m_categoryViewModel->setActive(false); + m_libraryViewModel->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 57c3ca4..63bacfa 100644 --- a/src/qEmbyApp/views/user/homequickrouterview.h +++ b/src/qEmbyApp/views/user/homequickrouterview.h @@ -3,6 +3,8 @@ #include "../baseview.h" class FavoritesViewModel; +class CategoryViewModel; +class LibraryViewModel; class NavigationController; class QuickRouterHost; class SearchViewModel; @@ -40,6 +42,9 @@ protected: private: void updateRouteActivity(); + void updateSearchRoute(const QVariantMap &route, bool hostActive); + void updateCategoryRoute(const QVariantMap &route, bool hostActive); + void updateLibraryRoute(const QVariantMap &route, bool hostActive); void deactivateRoutes(); void forwardItemActivation(const MediaItem &item); void launchDashboardTask(QCoro::Task &&task); @@ -54,6 +59,10 @@ private: UserDashboardViewModel *m_dashboardViewModel = nullptr; FavoritesViewModel *m_favoritesViewModel = nullptr; SearchViewModel *m_searchViewModel = nullptr; + CategoryViewModel *m_categoryViewModel = nullptr; + LibraryViewModel *m_libraryViewModel = nullptr; QString m_searchRouteKey; QString m_searchQuery; + QString m_categoryRouteKey; + QString m_libraryRouteKey; }; diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp index 95ead6a..3ad46db 100644 --- a/src/qEmbyApp/views/user/homeview.cpp +++ b/src/qEmbyApp/views/user/homeview.cpp @@ -259,9 +259,9 @@ void HomeView::setupUi() auto navigateToDetailSlot = [this](const QString &itemId, const QString &itemName, const MediaItem &seedItem) { pushView(createDetailView(itemId, itemName, seedItem)); }; auto navigateToFolderSlot = [this](const QString &id, const QString &name) - { pushView(createLibraryView(id, name)); }; + { openLibraryRoute(id, name); }; auto navigateToPersonSlot = [this](const QString &id, const QString &name) - { pushView(createPersonView(id, name)); }; + { openPersonRoute(id, name); }; auto navigateToPlayerSlot = [this](const QString &id, const QString &title, const QString &url, long long ticks, const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }; auto navigateToSeasonSlot = [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) @@ -278,12 +278,12 @@ void HomeView::setupUi() break; } } - pushView(createLibraryView(id, name)); + openLibraryRoute(id, name); }; const auto openCategory = [this](const QString &categoryId, const QString &title) { m_libraryList->clearSelection(); - pushView(createCategoryView(categoryId, title)); + openCategoryRoute(categoryId, title); }; m_useQuickHomeRouter = qEnvironmentVariableIntValue("QEMBY_USE_LEGACY_HOME_PAGES") != 1; @@ -410,11 +410,11 @@ QWidget *HomeView::createDetailView(const QString &itemId, const QString &itemNa connect(view, &DetailView::navigateToDetail, this, [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); connect(view, &DetailView::navigateToFolder, this, - [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }); + [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); connect(view, &BaseView::navigateToPerson, this, [this](const QString &id, const QString &name) { - pushView(createPersonView(id, name)); + openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, @@ -429,7 +429,7 @@ QWidget *HomeView::createDetailView(const QString &itemId, const QString &itemNa connect(view, &DetailView::triggerSearch, this, &HomeView::triggerSearch); connect(view, &BaseView::navigateToFilteredView, this, [this](const QString &type, const QString &value) - { pushView(createFilteredView(type, value)); }); + { openFilteredRoute(type, value); }); return view; } @@ -449,10 +449,10 @@ QWidget *HomeView::createCategoryView(const QString &categoryId, const QString & [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); connect(view, &CategoryView::navigateToFolder, this, - [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }); + [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); connect(view, &CategoryView::navigateToPerson, this, - [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }); + [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, @@ -481,10 +481,10 @@ QWidget *HomeView::createLibraryView(const QString &libraryId, const QString &ti [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); connect(view, &LibraryView::navigateToFolder, this, - [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }); + [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }); + [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, @@ -520,7 +520,7 @@ QWidget *HomeView::createPersonView(const QString &personId, const QString &pers connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }); + [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, @@ -556,10 +556,10 @@ QWidget *HomeView::createSearchView(const QString &query) [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); connect(view, &SearchView::navigateToFolder, this, - [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }); + [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); connect(view, &SearchView::navigateToPerson, this, - [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }); + [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, @@ -589,9 +589,9 @@ QWidget *HomeView::createFilteredView(const QString &filterType, const QString & connect(view, &BaseView::navigateToDetail, this, [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); connect(view, &BaseView::navigateToFolder, this, - [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }); + [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }); + [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); connect(view, &BaseView::navigateToPlayer, this, [this](const QString &id, const QString &title, const QString &url, long long ticks, const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); @@ -998,7 +998,7 @@ void HomeView::setupSidebar() { return; } - pushView(createLibraryView(viewId, cleanName)); + openLibraryRoute(viewId, cleanName); }); m_sidebarAnim = new QPropertyAnimation(m_sidebar, "pos", this); @@ -1301,6 +1301,59 @@ bool HomeView::pushQuickRoute(const QVariantMap &request) return true; } +void HomeView::openCategoryRoute(const QString &categoryId, const QString &title) +{ + const bool favorite = categoryId.startsWith(QLatin1String("Favorite_")); + const QString normalizedId = favorite ? categoryId.mid(9) : categoryId; + const QVariantMap request{ + {QStringLiteral("name"), QStringLiteral("category")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("categoryId"), normalizedId}, + {QStringLiteral("title"), title}, + {QStringLiteral("favorite"), favorite}}}}; + if (!pushQuickRoute(request)) + pushView(createCategoryView(categoryId, title)); +} + +void HomeView::openLibraryRoute(const QString &libraryId, const QString &title) +{ + const QVariantMap request{ + {QStringLiteral("name"), QStringLiteral("library")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("libraryId"), libraryId}, + {QStringLiteral("title"), title}}}}; + if (!pushQuickRoute(request)) + pushView(createLibraryView(libraryId, title)); +} + +void HomeView::openPersonRoute(const QString &personId, const QString &title) +{ + const QVariantMap request{ + {QStringLiteral("name"), QStringLiteral("person")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("personId"), personId}, + {QStringLiteral("title"), title}}}}; + if (!pushQuickRoute(request)) + pushView(createPersonView(personId, title)); +} + +void HomeView::openFilteredRoute(const QString &filterType, + const QString &filterValue) +{ + const QVariantMap request{ + {QStringLiteral("name"), QStringLiteral("filtered")}, + {QStringLiteral("title"), filterValue}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("filterType"), filterType}, + {QStringLiteral("filterValue"), filterValue}, + {QStringLiteral("title"), filterValue}}}}; + if (!pushQuickRoute(request)) + pushView(createFilteredView(filterType, filterValue)); +} + void HomeView::navigateBack() { QWidget *current = m_contentSwitcher->currentWidget(); diff --git a/src/qEmbyApp/views/user/homeview.h b/src/qEmbyApp/views/user/homeview.h index 4dd30d8..5a21808 100644 --- a/src/qEmbyApp/views/user/homeview.h +++ b/src/qEmbyApp/views/user/homeview.h @@ -117,6 +117,10 @@ private: void pushView(QWidget* view); bool pushQuickRoute(const QVariantMap &request); + void openCategoryRoute(const QString &categoryId, const QString &title); + void openLibraryRoute(const QString &libraryId, const QString &title); + void openPersonRoute(const QString &personId, const QString &title); + void openFilteredRoute(const QString &filterType, const QString &filterValue); void resetToView(QWidget* view, const QString &rootRoute = QString()); QWidget* homeRootWidget() const;