From b117bbf99ec0e2fc3e4f832cf1369b6660057ab9 Mon Sep 17 00:00:00 2001 From: dela Date: Wed, 15 Jul 2026 11:57:34 +0800 Subject: [PATCH] feat: checkpoint QML view migration --- CMakeLists.txt | 2 +- ai/qml-migration-status.md | 100 + ai/specs/qml-navigation.md | 404 ++ src/qEmbyApp/CMakeLists.txt | 83 +- src/qEmbyApp/mainwindow.cpp | 2 + src/qEmbyApp/qml/AdminCollectionsPage.qml | 461 ++ src/qEmbyApp/qml/AdminLibrariesPage.qml | 540 +++ src/qEmbyApp/qml/AdminLogsPage.qml | 297 ++ src/qEmbyApp/qml/AdminTasksPage.qml | 379 ++ src/qEmbyApp/qml/AdminTranscodingPage.qml | 373 ++ src/qEmbyApp/qml/AdminUsersPage.qml | 421 ++ src/qEmbyApp/qml/CategoryPage.qml | 194 + src/qEmbyApp/qml/DashboardPage.qml | 310 ++ src/qEmbyApp/qml/DetailPage.qml | 331 ++ src/qEmbyApp/qml/EmptyState.qml | 53 + src/qEmbyApp/qml/FavoritesPage.qml | 306 ++ src/qEmbyApp/qml/LibraryPage.qml | 353 ++ src/qEmbyApp/qml/LoginPage.qml | 216 + src/qEmbyApp/qml/ManageDashboardPage.qml | 459 ++ src/qEmbyApp/qml/Router.qml | 274 ++ src/qEmbyApp/qml/SearchPage.qml | 313 ++ src/qEmbyApp/qml/SeasonPage.qml | 262 ++ src/qEmbyApp/qml/SettingsAboutPage.qml | 214 + src/qEmbyApp/qml/SettingsAppearancePage.qml | 211 + src/qEmbyApp/qml/SettingsGeneralPage.qml | 123 + src/qEmbyApp/qml/SettingsLibraryPage.qml | 475 ++ src/qEmbyApp/qml/SettingsPlayerPage.qml | 918 ++++ .../qml/components/CollapsibleSection.qml | 110 + src/qEmbyApp/qml/components/ErrorState.qml | 99 + src/qEmbyApp/qml/components/LoadingState.qml | 85 + src/qEmbyApp/qml/components/MediaCard.qml | 289 ++ src/qEmbyApp/qml/components/MediaGrid.qml | 140 + src/qEmbyApp/qml/components/MediaList.qml | 125 + src/qEmbyApp/qml/components/MediaSection.qml | 123 + src/qEmbyApp/qml/components/ModernDialog.qml | 150 + src/qEmbyApp/qml/components/SettingsCard.qml | 76 + src/qEmbyApp/qml/components/ThemedButton.qml | 35 + .../qml/components/ThemedComboBox.qml | 87 + src/qEmbyApp/qml/components/ThemedDialog.qml | 157 + .../qml/components/ThemedScrollBar.qml | 25 + src/qEmbyApp/qml/components/ThemedSlider.qml | 41 + src/qEmbyApp/qml/components/ThemedSpinBox.qml | 77 + src/qEmbyApp/qml/components/ThemedSwitch.qml | 55 + .../qml/components/ThemedTextField.qml | 27 + src/qEmbyApp/qml/components/ToastHost.qml | 179 + src/qEmbyApp/qml/theme/Theme.qml | 81 + src/qEmbyApp/quick/aboutviewmodel.cpp | 29 + src/qEmbyApp/quick/aboutviewmodel.h | 28 + .../quick/adminlibrariesviewmodel.cpp | 737 ++++ src/qEmbyApp/quick/adminlibrariesviewmodel.h | 161 + src/qEmbyApp/quick/dashboardviewmodel.cpp | 491 +++ src/qEmbyApp/quick/dashboardviewmodel.h | 181 + src/qEmbyApp/quick/embyimageprovider.cpp | 148 + src/qEmbyApp/quick/embyimageprovider.h | 55 + src/qEmbyApp/quick/loginviewmodel.cpp | 313 ++ src/qEmbyApp/quick/loginviewmodel.h | 124 + src/qEmbyApp/quick/logsviewmodel.cpp | 295 ++ src/qEmbyApp/quick/logsviewmodel.h | 114 + src/qEmbyApp/quick/navigationcontroller.cpp | 315 ++ src/qEmbyApp/quick/navigationcontroller.h | 51 + src/qEmbyApp/quick/navigationroute.cpp | 15 + src/qEmbyApp/quick/navigationroute.h | 18 + src/qEmbyApp/quick/qmltask.cpp | 8 + src/qEmbyApp/quick/qmltask.h | 73 + src/qEmbyApp/quick/quickpagehost.cpp | 73 + src/qEmbyApp/quick/quickpagehost.h | 39 + src/qEmbyApp/quick/quickrouterhost.cpp | 164 + src/qEmbyApp/quick/quickrouterhost.h | 56 + src/qEmbyApp/quick/quicktheme.cpp | 81 + src/qEmbyApp/quick/quicktheme.h | 113 + src/qEmbyApp/quick/settingsviewmodel.cpp | 1081 +++++ src/qEmbyApp/quick/settingsviewmodel.h | 400 ++ src/qEmbyApp/quick/tasksviewmodel.cpp | 329 ++ src/qEmbyApp/quick/tasksviewmodel.h | 116 + src/qEmbyApp/resources/qss/dark-style.qss | 25 +- src/qEmbyApp/resources/qss/light-style.qss | 30 +- .../tests/navigationcontroller_test.cpp | 75 + src/qEmbyApp/tokens/ColorTokens.h | 4 + .../views/admin/collectionsviewmodel.cpp | 383 ++ .../views/admin/collectionsviewmodel.h | 119 + src/qEmbyApp/views/admin/manageview.cpp | 20 +- src/qEmbyApp/views/admin/pagecollections.cpp | 1089 +---- src/qEmbyApp/views/admin/pagecollections.h | 94 +- src/qEmbyApp/views/admin/pagelibraries.cpp | 1187 +---- src/qEmbyApp/views/admin/pagelibraries.h | 114 +- src/qEmbyApp/views/admin/pagelogs.cpp | 702 +-- src/qEmbyApp/views/admin/pagelogs.h | 52 +- src/qEmbyApp/views/admin/pagetasks.cpp | 599 +-- src/qEmbyApp/views/admin/pagetasks.h | 68 +- src/qEmbyApp/views/admin/pagetranscoding.cpp | 2529 +---------- src/qEmbyApp/views/admin/pagetranscoding.h | 187 +- src/qEmbyApp/views/admin/pageusers.cpp | 320 +- src/qEmbyApp/views/admin/pageusers.h | 47 +- .../views/admin/transcodingviewmodel.cpp | 370 ++ .../views/admin/transcodingviewmodel.h | 92 + src/qEmbyApp/views/admin/usersviewmodel.cpp | 303 ++ src/qEmbyApp/views/admin/usersviewmodel.h | 105 + src/qEmbyApp/views/media/detailview.cpp | 3832 +---------------- src/qEmbyApp/views/media/detailview.h | 263 +- src/qEmbyApp/views/media/detailviewmodel.cpp | 493 +++ src/qEmbyApp/views/media/detailviewmodel.h | 165 + src/qEmbyApp/views/media/libraryview.cpp | 1147 +---- src/qEmbyApp/views/media/libraryview.h | 112 +- src/qEmbyApp/views/media/libraryviewmodel.cpp | 692 +++ src/qEmbyApp/views/media/libraryviewmodel.h | 185 + src/qEmbyApp/views/media/seasonview.cpp | 776 +--- src/qEmbyApp/views/media/seasonview.h | 55 +- src/qEmbyApp/views/media/seasonviewmodel.cpp | 492 +++ src/qEmbyApp/views/media/seasonviewmodel.h | 142 + src/qEmbyApp/views/public/loginview.cpp | 1191 +---- src/qEmbyApp/views/public/loginview.h | 110 +- src/qEmbyApp/views/search/searchview.cpp | 328 +- src/qEmbyApp/views/search/searchview.h | 46 +- src/qEmbyApp/views/search/searchviewmodel.cpp | 221 + src/qEmbyApp/views/search/searchviewmodel.h | 100 + src/qEmbyApp/views/settings/settingsview.cpp | 57 +- src/qEmbyApp/views/settings/settingsview.h | 2 + src/qEmbyApp/views/user/categoryview.cpp | 828 +--- src/qEmbyApp/views/user/categoryview.h | 97 +- src/qEmbyApp/views/user/categoryviewmodel.cpp | 600 +++ src/qEmbyApp/views/user/categoryviewmodel.h | 145 + src/qEmbyApp/views/user/dashboardview.cpp | 1689 +------- src/qEmbyApp/views/user/dashboardview.h | 141 +- .../views/user/dashboardviewmodel.cpp | 998 +++++ src/qEmbyApp/views/user/dashboardviewmodel.h | 248 ++ src/qEmbyApp/views/user/favoritesview.cpp | 350 +- src/qEmbyApp/views/user/favoritesview.h | 68 +- .../views/user/favoritesviewmodel.cpp | 399 ++ src/qEmbyApp/views/user/favoritesviewmodel.h | 144 + .../views/user/homequickrouterview.cpp | 374 ++ src/qEmbyApp/views/user/homequickrouterview.h | 55 + src/qEmbyApp/views/user/homeview.cpp | 325 +- src/qEmbyApp/views/user/homeview.h | 11 +- 133 files changed, 23964 insertions(+), 17074 deletions(-) create mode 100644 ai/qml-migration-status.md create mode 100644 ai/specs/qml-navigation.md create mode 100644 src/qEmbyApp/qml/AdminCollectionsPage.qml create mode 100644 src/qEmbyApp/qml/AdminLibrariesPage.qml create mode 100644 src/qEmbyApp/qml/AdminLogsPage.qml create mode 100644 src/qEmbyApp/qml/AdminTasksPage.qml create mode 100644 src/qEmbyApp/qml/AdminTranscodingPage.qml create mode 100644 src/qEmbyApp/qml/AdminUsersPage.qml create mode 100644 src/qEmbyApp/qml/CategoryPage.qml create mode 100644 src/qEmbyApp/qml/DashboardPage.qml create mode 100644 src/qEmbyApp/qml/DetailPage.qml create mode 100644 src/qEmbyApp/qml/EmptyState.qml create mode 100644 src/qEmbyApp/qml/FavoritesPage.qml create mode 100644 src/qEmbyApp/qml/LibraryPage.qml create mode 100644 src/qEmbyApp/qml/LoginPage.qml create mode 100644 src/qEmbyApp/qml/ManageDashboardPage.qml create mode 100644 src/qEmbyApp/qml/Router.qml create mode 100644 src/qEmbyApp/qml/SearchPage.qml create mode 100644 src/qEmbyApp/qml/SeasonPage.qml create mode 100644 src/qEmbyApp/qml/SettingsAboutPage.qml create mode 100644 src/qEmbyApp/qml/SettingsAppearancePage.qml create mode 100644 src/qEmbyApp/qml/SettingsGeneralPage.qml create mode 100644 src/qEmbyApp/qml/SettingsLibraryPage.qml create mode 100644 src/qEmbyApp/qml/SettingsPlayerPage.qml create mode 100644 src/qEmbyApp/qml/components/CollapsibleSection.qml create mode 100644 src/qEmbyApp/qml/components/ErrorState.qml create mode 100644 src/qEmbyApp/qml/components/LoadingState.qml create mode 100644 src/qEmbyApp/qml/components/MediaCard.qml create mode 100644 src/qEmbyApp/qml/components/MediaGrid.qml create mode 100644 src/qEmbyApp/qml/components/MediaList.qml create mode 100644 src/qEmbyApp/qml/components/MediaSection.qml create mode 100644 src/qEmbyApp/qml/components/ModernDialog.qml create mode 100644 src/qEmbyApp/qml/components/SettingsCard.qml create mode 100644 src/qEmbyApp/qml/components/ThemedButton.qml create mode 100644 src/qEmbyApp/qml/components/ThemedComboBox.qml create mode 100644 src/qEmbyApp/qml/components/ThemedDialog.qml create mode 100644 src/qEmbyApp/qml/components/ThemedScrollBar.qml create mode 100644 src/qEmbyApp/qml/components/ThemedSlider.qml create mode 100644 src/qEmbyApp/qml/components/ThemedSpinBox.qml create mode 100644 src/qEmbyApp/qml/components/ThemedSwitch.qml create mode 100644 src/qEmbyApp/qml/components/ThemedTextField.qml create mode 100644 src/qEmbyApp/qml/components/ToastHost.qml create mode 100644 src/qEmbyApp/qml/theme/Theme.qml create mode 100644 src/qEmbyApp/quick/aboutviewmodel.cpp create mode 100644 src/qEmbyApp/quick/aboutviewmodel.h create mode 100644 src/qEmbyApp/quick/adminlibrariesviewmodel.cpp create mode 100644 src/qEmbyApp/quick/adminlibrariesviewmodel.h create mode 100644 src/qEmbyApp/quick/dashboardviewmodel.cpp create mode 100644 src/qEmbyApp/quick/dashboardviewmodel.h create mode 100644 src/qEmbyApp/quick/embyimageprovider.cpp create mode 100644 src/qEmbyApp/quick/embyimageprovider.h create mode 100644 src/qEmbyApp/quick/loginviewmodel.cpp create mode 100644 src/qEmbyApp/quick/loginviewmodel.h create mode 100644 src/qEmbyApp/quick/logsviewmodel.cpp create mode 100644 src/qEmbyApp/quick/logsviewmodel.h create mode 100644 src/qEmbyApp/quick/navigationcontroller.cpp create mode 100644 src/qEmbyApp/quick/navigationcontroller.h create mode 100644 src/qEmbyApp/quick/navigationroute.cpp create mode 100644 src/qEmbyApp/quick/navigationroute.h create mode 100644 src/qEmbyApp/quick/qmltask.cpp create mode 100644 src/qEmbyApp/quick/qmltask.h create mode 100644 src/qEmbyApp/quick/quickpagehost.cpp create mode 100644 src/qEmbyApp/quick/quickpagehost.h create mode 100644 src/qEmbyApp/quick/quickrouterhost.cpp create mode 100644 src/qEmbyApp/quick/quickrouterhost.h create mode 100644 src/qEmbyApp/quick/quicktheme.cpp create mode 100644 src/qEmbyApp/quick/quicktheme.h create mode 100644 src/qEmbyApp/quick/settingsviewmodel.cpp create mode 100644 src/qEmbyApp/quick/settingsviewmodel.h create mode 100644 src/qEmbyApp/quick/tasksviewmodel.cpp create mode 100644 src/qEmbyApp/quick/tasksviewmodel.h create mode 100644 src/qEmbyApp/tests/navigationcontroller_test.cpp create mode 100644 src/qEmbyApp/views/admin/collectionsviewmodel.cpp create mode 100644 src/qEmbyApp/views/admin/collectionsviewmodel.h create mode 100644 src/qEmbyApp/views/admin/transcodingviewmodel.cpp create mode 100644 src/qEmbyApp/views/admin/transcodingviewmodel.h create mode 100644 src/qEmbyApp/views/admin/usersviewmodel.cpp create mode 100644 src/qEmbyApp/views/admin/usersviewmodel.h create mode 100644 src/qEmbyApp/views/media/detailviewmodel.cpp create mode 100644 src/qEmbyApp/views/media/detailviewmodel.h create mode 100644 src/qEmbyApp/views/media/libraryviewmodel.cpp create mode 100644 src/qEmbyApp/views/media/libraryviewmodel.h create mode 100644 src/qEmbyApp/views/media/seasonviewmodel.cpp create mode 100644 src/qEmbyApp/views/media/seasonviewmodel.h create mode 100644 src/qEmbyApp/views/search/searchviewmodel.cpp create mode 100644 src/qEmbyApp/views/search/searchviewmodel.h create mode 100644 src/qEmbyApp/views/user/categoryviewmodel.cpp create mode 100644 src/qEmbyApp/views/user/categoryviewmodel.h create mode 100644 src/qEmbyApp/views/user/dashboardviewmodel.cpp create mode 100644 src/qEmbyApp/views/user/dashboardviewmodel.h create mode 100644 src/qEmbyApp/views/user/favoritesviewmodel.cpp create mode 100644 src/qEmbyApp/views/user/favoritesviewmodel.h create mode 100644 src/qEmbyApp/views/user/homequickrouterview.cpp create mode 100644 src/qEmbyApp/views/user/homequickrouterview.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c3f87dd..53c26db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,7 +87,7 @@ set(QEMBY_QT_ROOT "" CACHE PATH qemby_append_default_qt_prefixes() # 寻找 Qt6 (不再允许降级) -find_package(Qt6 REQUIRED COMPONENTS Widgets Core Network Concurrent OpenGLWidgets LinguistTools WebSockets Svg) +find_package(Qt6 REQUIRED COMPONENTS Widgets Core Network Concurrent OpenGLWidgets LinguistTools WebSockets Svg Quick Qml QuickWidgets QuickControls2) if(UNIX AND NOT APPLE) find_package(Qt6 QUIET COMPONENTS DBus) endif() diff --git a/ai/qml-migration-status.md b/ai/qml-migration-status.md new file mode 100644 index 0000000..b6d6648 --- /dev/null +++ b/ai/qml-migration-status.md @@ -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 路径稳定后进行,便于回退与差异核对。 diff --git a/ai/specs/qml-navigation.md b/ai/specs/qml-navigation.md new file mode 100644 index 0000000..6867e98 --- /dev/null +++ b/ai/specs/qml-navigation.md @@ -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 m_navStack` 管业务页面。 +6. `DashboardView` 与 `FavoritesView` 在 `HomeView::setupUi()` 中创建并长期保留;当前没有给这两个 QWidget 设置 `routeType/routeId/routeTitle/isDynamic`。 + +### 2.2 当前 RouteInfo 与动态属性 + +`RouteInfo` 当前字段为: + +```cpp +QPointer 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/ +qemby://library/ +qemby://season// +qemby://settings/
+qemby://manage/
+qemby://search?q= +``` + +处理规则: + +- 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` 属性的问题。 diff --git a/src/qEmbyApp/CMakeLists.txt b/src/qEmbyApp/CMakeLists.txt index e4e676e..e295e78 100644 --- a/src/qEmbyApp/CMakeLists.txt +++ b/src/qEmbyApp/CMakeLists.txt @@ -10,15 +10,92 @@ set(APP_QRC_FILES resources/resources.qrc) file(GLOB_RECURSE APP_HEADERS CONFIGURE_DEPENDS "*.h") file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "*.cpp") +list(FILTER APP_HEADERS EXCLUDE REGEX "/tests/") +list(FILTER APP_SOURCES EXCLUDE REGEX "/tests/") qt_add_executable(qEmbyApp MANUAL_FINALIZATION - ${APP_HEADERS} + ${APP_HEADERS} ${APP_SOURCES} ${TS_FILES} ${APP_QRC_FILES} ) +# ========================================== +# QML 模块:Widgets 混合迁移的 Quick 页面 +# QML 页面通过 QuickPageHost (QQuickWidget) 嵌入现有页面栈; +# C++ 类型 (Theme 单例等) 通过 QML_ELEMENT 自动注册到 qEmby 模块。 +# ========================================== +if(COMMAND qt_policy) + qt_policy(SET QTP0004 NEW) +endif() + +qt_add_qml_module(qEmbyApp + URI qEmby + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + QML_FILES + qml/Router.qml + qml/ManageDashboardPage.qml + qml/LoginPage.qml + qml/SearchPage.qml + qml/CategoryPage.qml + qml/FavoritesPage.qml + qml/DashboardPage.qml + qml/LibraryPage.qml + qml/DetailPage.qml + qml/SeasonPage.qml + qml/AdminUsersPage.qml + qml/AdminTasksPage.qml + qml/AdminLibrariesPage.qml + qml/AdminLogsPage.qml + qml/AdminCollectionsPage.qml + qml/AdminTranscodingPage.qml + qml/EmptyState.qml + qml/SettingsAboutPage.qml + qml/SettingsGeneralPage.qml + qml/SettingsAppearancePage.qml + qml/SettingsLibraryPage.qml + qml/SettingsPlayerPage.qml + qml/components/SettingsCard.qml + qml/components/CollapsibleSection.qml + qml/components/ThemedButton.qml + qml/components/ThemedSwitch.qml + qml/components/ThemedComboBox.qml + qml/components/ThemedTextField.qml + qml/components/ThemedSpinBox.qml + qml/components/ThemedSlider.qml + qml/components/ThemedScrollBar.qml + qml/components/ThemedDialog.qml + qml/components/ToastHost.qml + qml/components/LoadingState.qml + qml/components/ErrorState.qml + qml/components/MediaCard.qml + qml/components/MediaGrid.qml + qml/components/MediaList.qml + qml/components/MediaSection.qml + SOURCES + quick/quicktheme.h quick/quicktheme.cpp + quick/qmltask.h quick/qmltask.cpp + quick/settingsviewmodel.h quick/settingsviewmodel.cpp + quick/loginviewmodel.h quick/loginviewmodel.cpp +) + +# qmltyperegistrar 生成的注册代码以 形式包含头文件 +target_include_directories(qEmbyApp PRIVATE quick) + +option(QEMBY_BUILD_NAVIGATION_TESTS + "Build the lightweight NavigationController contract test" ON) +if(QEMBY_BUILD_NAVIGATION_TESTS) + add_executable(qEmbyNavigationTest EXCLUDE_FROM_ALL + tests/navigationcontroller_test.cpp + quick/navigationcontroller.h quick/navigationcontroller.cpp + quick/navigationroute.h quick/navigationroute.cpp + ) + target_include_directories(qEmbyNavigationTest PRIVATE quick) + target_link_libraries(qEmbyNavigationTest PRIVATE Qt${QT_VERSION_MAJOR}::Core) +endif() + # ========================================== # 集成 libmpv 播放器核心 # Linux / macOS 优先使用系统安装的 libmpv @@ -65,6 +142,10 @@ target_link_libraries(qEmbyApp PRIVATE Qt${QT_VERSION_MAJOR}::OpenGLWidgets Qt${QT_VERSION_MAJOR}::Svg Qt${QT_VERSION_MAJOR}::Concurrent + Qt${QT_VERSION_MAJOR}::Quick + Qt${QT_VERSION_MAJOR}::Qml + Qt${QT_VERSION_MAJOR}::QuickWidgets + Qt${QT_VERSION_MAJOR}::QuickControls2 qEmbyCore QWKWidgets WidgetFrame diff --git a/src/qEmbyApp/mainwindow.cpp b/src/qEmbyApp/mainwindow.cpp index 5284e24..10b3117 100644 --- a/src/qEmbyApp/mainwindow.cpp +++ b/src/qEmbyApp/mainwindow.cpp @@ -923,6 +923,7 @@ void MainWindow::navigateToHome() return; } m_viewTransitionInProgress = true; + m_homeView->beginAuthenticatedSession(); m_homeView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -979,6 +980,7 @@ void MainWindow::navigateToLogin() { return; } m_viewTransitionInProgress = true; + m_homeView->clearAuthenticatedSession(); diff --git a/src/qEmbyApp/qml/AdminCollectionsPage.qml b/src/qEmbyApp/qml/AdminCollectionsPage.qml new file mode 100644 index 0000000..7aa15c8 --- /dev/null +++ b/src/qEmbyApp/qml/AdminCollectionsPage.qml @@ -0,0 +1,461 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import QtQuick.Effects +import qEmby 1.0 + +FocusScope { + id: page + + property string pendingId: "" + property string pendingName: "" + property int draggedIndex: -1 + readonly property bool hasContent: collectionsPage.collectionCount > 0 + readonly property int cardGap: Theme.spacingMd + readonly property int minimumCardWidth: 250 + readonly property int columnCount: Math.max(1, Math.floor((grid.width + cardGap) / (minimumCardWidth + cardGap))) + + clip: true + Accessible.role: Accessible.Pane + Accessible.name: qsTr("Playlists") + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingXl + anchors.topMargin: Theme.spacingXl + anchors.bottomMargin: Theme.spacingLg + spacing: Theme.spacingLg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + Text { + text: qsTr("Playlists") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + Text { + text: collectionsPage.selectedCount > 0 ? qsTr("%1 playlists · %2 selected").arg(collectionsPage.collectionCount).arg(collectionsPage.selectedCount) : qsTr("%1 playlists").arg(collectionsPage.collectionCount) + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + } + + ThemedButton { + text: collectionsPage.refreshing ? qsTr("Refreshing…") : qsTr("Refresh") + enabled: !collectionsPage.loading && !collectionsPage.refreshing && !collectionsPage.busy + onClicked: collectionsPage.refresh() + } + + ThemedButton { + text: qsTr("Create playlist") + enabled: !collectionsPage.busy + onClicked: { + createName.text = ""; + createDialog.open(); + Qt.callLater(createName.forceActiveFocus); + } + } + + ThemedButton { + text: collectionsPage.allSelected ? qsTr("Clear selection") : qsTr("Select all") + enabled: page.hasContent && !collectionsPage.busy + onClicked: collectionsPage.toggleSelectAll() + } + + ThemedButton { + text: qsTr("Delete selected") + enabled: collectionsPage.selectedCount > 0 && !collectionsPage.busy + onClicked: deleteSelectedDialog.open() + } + + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + GridView { + id: grid + + anchors.fill: parent + visible: page.hasContent && collectionsPage.errorText.length === 0 + clip: true + model: collectionsPage.collectionsModel + cellWidth: width / page.columnCount + cellHeight: 250 + page.cardGap + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + + Basic.ScrollBar.vertical: ThemedScrollBar { + } + + displaced: Transition { + NumberAnimation { + properties: "x,y" + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + + delegate: Item { + id: delegateRoot + + required property int index + required property string itemId + required property string name + required property int childCount + required property string subtitle + required property string imageSource + required property bool selected + property bool held: dragArea.drag.active + + width: grid.cellWidth + height: grid.cellHeight + z: held ? 20 : 1 + Drag.active: held + Drag.source: delegateRoot + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + + DropArea { + anchors.fill: parent + onEntered: function(drag) { + if (drag.source && drag.source !== delegateRoot && page.draggedIndex >= 0) { + collectionsPage.moveCollection(page.draggedIndex, delegateRoot.index); + page.draggedIndex = delegateRoot.index; + } + } + } + + Rectangle { + id: card + + anchors.fill: parent + anchors.margins: page.cardGap / 2 + radius: Theme.radiusXl + color: delegateRoot.selected ? Theme.alpha(Theme.accent, Theme.dark ? 0.22 : 0.12) : Theme.bgSurface + border.width: delegateRoot.selected ? 2 : 1 + border.color: delegateRoot.selected ? Theme.accent : Theme.borderSoft + scale: delegateRoot.held ? 0.96 : 1 + + Behavior on scale { + NumberAnimation { + duration: Theme.animationFast + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 132 + + Image { + id: cover + + anchors.fill: parent + source: delegateRoot.imageSource + sourceSize: Qt.size(560, 280) + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + visible: false + } + + Rectangle { + id: coverMask + + anchors.fill: parent + radius: Theme.radiusLg + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: cover + maskEnabled: true + maskSource: coverMask + } + + Rectangle { + anchors.fill: parent + visible: cover.status !== Image.Ready + radius: Theme.radiusLg + color: Theme.bgRaised + + Text { + anchors.centerIn: parent + text: delegateRoot.name.length > 0 ? delegateRoot.name.charAt(0).toUpperCase() : "?" + color: Theme.textMuted + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Theme.spacingSm + width: 27 + height: 27 + radius: 7 + color: delegateRoot.selected ? Theme.accent : Theme.alpha(Theme.bgSurface, 0.9) + border.color: delegateRoot.selected ? Theme.accent : Theme.borderSoft + + Text { + anchors.centerIn: parent + text: delegateRoot.selected ? "✓" : "" + color: "white" + font.weight: Font.Bold + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: collectionsPage.toggleSelected(delegateRoot.itemId) + } + } + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingSm + width: 30 + height: 30 + radius: 7 + color: Theme.alpha(Theme.bgSurface, 0.9) + border.color: Theme.borderSoft + + Text { + anchors.centerIn: parent + text: "≡" + color: Theme.textMuted + font.pixelSize: Theme.fontHeading + } + + MouseArea { + id: dragArea + + anchors.fill: parent + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + drag.target: delegateRoot + drag.axis: Drag.XAndYAxis + onPressed: page.draggedIndex = delegateRoot.index + onReleased: { + collectionsPage.saveCollectionOrder(); + page.draggedIndex = -1; + } + onCanceled: { + collectionsPage.saveCollectionOrder(); + page.draggedIndex = -1; + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: delegateRoot.name + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: delegateRoot.subtitle + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + } + + ThemedButton { + text: qsTr("Rename") + enabled: !collectionsPage.busy + onClicked: { + page.pendingId = delegateRoot.itemId; + page.pendingName = delegateRoot.name; + renameName.text = delegateRoot.name; + renameDialog.open(); + Qt.callLater(renameName.forceActiveFocus); + } + } + + ThemedButton { + text: qsTr("Delete") + enabled: !collectionsPage.busy + onClicked: { + page.pendingId = delegateRoot.itemId; + page.pendingName = delegateRoot.name; + deleteDialog.open(); + } + } + } + } + } + } + } + + LoadingState { + anchors.centerIn: parent + visible: collectionsPage.loading + running: visible + title: qsTr("Loading playlists") + } + + ErrorState { + anchors.centerIn: parent + width: Math.min(440, parent.width - Theme.spacingXl * 2) + visible: !collectionsPage.loading && collectionsPage.errorText.length > 0 + title: qsTr("Could not load playlists") + message: collectionsPage.errorText + onRetryRequested: collectionsPage.retry() + } + + ColumnLayout { + anchors.centerIn: parent + visible: !collectionsPage.loading && collectionsPage.errorText.length === 0 && !page.hasContent + spacing: Theme.spacingMd + + Text { + text: qsTr("No playlists found") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: qsTr("Create a playlist to organize media for quick access.") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + Layout.alignment: Qt.AlignHCenter + } + + ThemedButton { + text: qsTr("Create playlist") + Layout.alignment: Qt.AlignHCenter + onClicked: createDialog.open() + } + } + + Rectangle { + anchors.fill: parent + visible: collectionsPage.busy + color: Theme.alpha(Theme.bgBase, 0.58) + z: 80 + + LoadingState { + anchors.centerIn: parent + running: parent.visible + title: qsTr("Saving changes") + } + } + } + } + + ThemedDialog { + id: createDialog + + title: qsTr("Create Playlist") + confirmText: qsTr("Create") + confirmEnabled: createName.text.trim().length > 0 + onAccepted: collectionsPage.createCollection(createName.text) + + ThemedTextField { + id: createName + Layout.fillWidth: true + placeholderText: qsTr("Playlist name") + } + } + + ThemedDialog { + id: renameDialog + + title: qsTr("Rename Playlist") + confirmText: qsTr("Save") + confirmEnabled: renameName.text.trim().length > 0 && renameName.text.trim() !== page.pendingName + onAccepted: collectionsPage.renameCollection(page.pendingId, renameName.text) + + ThemedTextField { + id: renameName + Layout.fillWidth: true + placeholderText: qsTr("Playlist name") + } + } + + ThemedDialog { + id: deleteDialog + + title: qsTr("Delete Playlist") + confirmText: qsTr("Delete") + confirmRole: "danger" + accessibleDescription: qsTr("This action cannot be undone") + onAccepted: collectionsPage.deleteCollection(page.pendingId) + + Text { + text: qsTr("Are you sure you want to delete playlist “%1”? This action cannot be undone.").arg(page.pendingName) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + ThemedDialog { + id: deleteSelectedDialog + + title: qsTr("Delete Selected") + confirmText: qsTr("Delete") + confirmRole: "danger" + accessibleDescription: qsTr("This action cannot be undone") + onAccepted: collectionsPage.deleteSelected() + + Text { + text: qsTr("Delete %1 selected playlists? This action cannot be undone.").arg(collectionsPage.selectedCount) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + ToastHost { + id: toasts + anchors.fill: parent + } + + Connections { + target: collectionsPage + function onNotificationRequested(message, kind) { + toasts.show(message, kind); + } + } +} diff --git a/src/qEmbyApp/qml/AdminLibrariesPage.qml b/src/qEmbyApp/qml/AdminLibrariesPage.qml new file mode 100644 index 0000000..ffdcc94 --- /dev/null +++ b/src/qEmbyApp/qml/AdminLibrariesPage.qml @@ -0,0 +1,540 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import QtQuick.Effects +import qEmby 1.0 + +FocusScope { + id: page + + property string pendingDeleteId: "" + property string pendingDeleteName: "" + readonly property bool hasContent: librariesPage.libraryCount > 0 + + clip: true + Accessible.role: Accessible.Pane + Accessible.name: qsTr("Libraries") + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 26 + anchors.bottomMargin: 18 + spacing: Theme.spacingLg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Text { + text: qsTr("Libraries") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.ExtraBold + Layout.rightMargin: Theme.spacingMd + } + + ToolAction { + text: qsTr("Refresh") + symbol: "↻" + enabled: !librariesPage.loading && !librariesPage.busy + onTriggered: librariesPage.refresh() + } + + ToolAction { + text: qsTr("Scan all") + symbol: "▶" + enabled: !librariesPage.scanActive && !librariesPage.busy + onTriggered: librariesPage.scanAll() + } + + ToolAction { + text: qsTr("Add library") + symbol: "+" + enabled: !librariesPage.loading && !librariesPage.busy + onTriggered: librariesPage.createLibrary() + } + + ToolAction { + text: librariesPage.allSelected ? qsTr("Clear selection") : qsTr("Select all") + symbol: librariesPage.allSelected ? "□" : "☑" + enabled: page.hasContent && !librariesPage.busy + onTriggered: librariesPage.toggleSelectAll() + } + + ToolAction { + text: qsTr("Delete selected") + symbol: "×" + tint: Theme.danger + enabled: librariesPage.selectedCount > 0 && !librariesPage.busy + onTriggered: deleteSelectedDialog.open() + } + + Item { + Layout.fillWidth: true + } + + Text { + text: librariesPage.selectedCount > 0 ? qsTr("%1 libraries · %2 selected").arg(librariesPage.libraryCount).arg(librariesPage.selectedCount) : qsTr("%1 libraries").arg(librariesPage.libraryCount) + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + } + + RowLayout { + visible: librariesPage.scanActive + Layout.fillWidth: true + spacing: Theme.spacingMd + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 7 + radius: height / 2 + color: Theme.alpha(Theme.borderSoft, 0.45) + + Rectangle { + width: parent.width * librariesPage.scanProgress / 100 + height: parent.height + radius: height / 2 + color: Theme.accent + + Behavior on width { + NumberAnimation { + duration: 240 + } + + } + + } + + } + + Text { + text: qsTr("Scanning %1%").arg(librariesPage.scanProgress) + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + + ThemedButton { + text: qsTr("Cancel") + enabled: librariesPage.scanActive + onClicked: librariesPage.cancelScan() + } + + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + GridView { + id: grid + + anchors.fill: parent + visible: page.hasContent && librariesPage.errorMessage.length === 0 + clip: true + model: librariesPage.librariesModel + cellWidth: Math.max(300, Math.floor(width / Math.max(1, Math.floor(width / 330)))) + cellHeight: 274 + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + + Basic.ScrollBar.vertical: ThemedScrollBar { + } + + delegate: Item { + id: delegateRoot + + required property int index + required property string itemId + required property string name + required property string collectionTypeName + required property string locationSummary + required property string imageSource + required property bool selected + required property bool scanActive + required property int scanProgress + + width: grid.cellWidth + height: grid.cellHeight + + Rectangle { + anchors.fill: parent + anchors.margins: Theme.spacingSm + radius: Theme.radiusXl + color: delegateRoot.selected ? Theme.alpha(Theme.accent, Theme.dark ? 0.22 : 0.13) : Theme.alpha(Theme.bgSurface, Theme.dark ? 0.72 : 0.94) + border.width: delegateRoot.selected ? 2 : 1 + border.color: delegateRoot.selected ? Theme.accent : Theme.alpha(Theme.borderSoft, 0.65) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 120 + + Image { + id: cover + + anchors.fill: parent + source: delegateRoot.imageSource + sourceSize.width: 640 + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + visible: false + } + + Rectangle { + id: coverMask + + anchors.fill: parent + radius: Theme.radiusLg + visible: false + layer.enabled: true + } + + MultiEffect { + anchors.fill: parent + source: cover + maskEnabled: true + maskSource: coverMask + } + + Rectangle { + anchors.fill: parent + visible: cover.status !== Image.Ready + radius: Theme.radiusLg + color: Theme.bgRaised + + Text { + anchors.centerIn: parent + text: delegateRoot.name.length > 0 ? delegateRoot.name.charAt(0).toUpperCase() : "?" + color: Theme.textMuted + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Theme.spacingSm + width: 25 + height: 25 + radius: 6 + color: delegateRoot.selected ? Theme.accent : Theme.alpha(Theme.bgSurface, 0.88) + border.color: delegateRoot.selected ? Theme.accent : Theme.borderSoft + + Text { + anchors.centerIn: parent + text: delegateRoot.selected ? "✓" : "" + color: "white" + font.weight: Font.Bold + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: librariesPage.toggleSelected(delegateRoot.itemId) + } + + } + + Rectangle { + visible: delegateRoot.scanActive + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 6 + color: Theme.alpha(Theme.bgRaised, 0.8) + + Rectangle { + width: parent.width * delegateRoot.scanProgress / 100 + height: parent.height + color: Theme.accent + } + + } + + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Text { + text: delegateRoot.name + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: delegateRoot.collectionTypeName + color: Theme.accent + font.pixelSize: Theme.fontSmall + } + + } + + Text { + text: delegateRoot.locationSummary + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: 2 + + CardAction { + text: qsTr("Edit") + enabled: !librariesPage.busy + onClicked: librariesPage.editLibrary(delegateRoot.itemId) + } + + CardAction { + text: qsTr("Images") + enabled: !librariesPage.busy + onClicked: librariesPage.editImages(delegateRoot.itemId) + } + + CardAction { + text: qsTr("Scan") + enabled: !delegateRoot.scanActive && !librariesPage.busy + onClicked: librariesPage.scanLibrary(delegateRoot.itemId) + } + + CardAction { + text: qsTr("Metadata") + enabled: !delegateRoot.scanActive && !librariesPage.busy + onClicked: librariesPage.refreshMetadata(delegateRoot.itemId) + } + + Item { + Layout.fillWidth: true + } + + CardAction { + text: "↑" + enabled: delegateRoot.index > 0 && !librariesPage.busy + Accessible.name: qsTr("Move up") + onClicked: librariesPage.moveLibrary(delegateRoot.index, delegateRoot.index - 1) + } + + CardAction { + text: "↓" + enabled: delegateRoot.index + 1 < librariesPage.libraryCount && !librariesPage.busy + Accessible.name: qsTr("Move down") + onClicked: librariesPage.moveLibrary(delegateRoot.index, delegateRoot.index + 1) + } + + CardAction { + text: "×" + enabled: !librariesPage.busy + Accessible.name: qsTr("Delete library") + onClicked: { + page.pendingDeleteId = delegateRoot.itemId; + page.pendingDeleteName = delegateRoot.name; + deleteOneDialog.open(); + } + } + + } + + } + + } + + } + + } + + LoadingState { + anchors.fill: parent + visible: librariesPage.loading && !page.hasContent + title: qsTr("Loading libraries") + message: qsTr("Fetching media libraries from the server…") + } + + ErrorState { + anchors.fill: parent + visible: librariesPage.errorMessage.length > 0 + title: qsTr("Could not load libraries") + message: librariesPage.errorMessage + onRetryRequested: librariesPage.retry() + } + + ColumnLayout { + anchors.centerIn: parent + visible: !librariesPage.loading && librariesPage.errorMessage.length === 0 && !page.hasContent + spacing: Theme.spacingMd + + Text { + text: "▦" + color: Theme.textMuted + font.pixelSize: 44 + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: qsTr("No media libraries found") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + Layout.alignment: Qt.AlignHCenter + } + + ThemedButton { + text: qsTr("Add library") + Layout.alignment: Qt.AlignHCenter + onClicked: librariesPage.createLibrary() + } + + } + + LoadingState { + anchors.fill: parent + visible: librariesPage.busy + title: qsTr("Applying changes") + message: qsTr("Please wait…") + } + + } + + } + + ThemedDialog { + id: deleteOneDialog + + title: qsTr("Delete library") + confirmText: qsTr("Delete") + confirmRole: "danger" + onAccepted: { + librariesPage.deleteLibrary(page.pendingDeleteId); + page.pendingDeleteId = ""; + page.pendingDeleteName = ""; + } + onRejected: { + page.pendingDeleteId = ""; + page.pendingDeleteName = ""; + } + + Text { + text: qsTr("Remove “%1” from the server? Media files will not be deleted.").arg(page.pendingDeleteName) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + } + + ThemedDialog { + id: deleteSelectedDialog + + title: qsTr("Delete selected libraries") + confirmText: qsTr("Delete") + confirmRole: "danger" + onAccepted: librariesPage.deleteSelected() + + Text { + text: qsTr("Remove %1 selected libraries from the server? Media files will not be deleted.").arg(librariesPage.selectedCount) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + } + + component ToolAction: Rectangle { + id: action + + property string text: "" + property string symbol: "" + property color tint: Theme.accent + + signal triggered() + + implicitWidth: labelRow.implicitWidth + Theme.spacingLg * 2 + implicitHeight: 34 + radius: Theme.radiusMd + color: !enabled ? Theme.alpha(Theme.bgRaised, 0.35) : mouse.pressed ? Theme.alpha(tint, 0.28) : mouse.containsMouse ? Theme.alpha(tint, 0.18) : Theme.bgRaised + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? tint : Theme.alpha(Theme.borderSoft, 0.7) + Accessible.role: Accessible.Button + Accessible.name: text + Keys.onEnterPressed: triggered() + Keys.onReturnPressed: triggered() + Keys.onSpacePressed: triggered() + + Row { + id: labelRow + + anchors.centerIn: parent + spacing: Theme.spacingSm + + Text { + text: action.symbol + color: action.enabled ? action.tint : Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + font.weight: Font.DemiBold + } + + Text { + text: action.text + color: action.enabled ? Theme.textPrimary : Theme.textMuted + font.pixelSize: Theme.fontBody + font.weight: Font.Medium + } + + } + + MouseArea { + id: mouse + + anchors.fill: parent + enabled: action.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: action.triggered() + } + + } + + component CardAction: Basic.Button { + id: button + + implicitWidth: Math.max(32, contentItem.implicitWidth + Theme.spacingMd * 2) + implicitHeight: 30 + font.pixelSize: Theme.fontSmall + + contentItem: Text { + text: button.text + font: button.font + color: button.enabled ? Theme.textBody : Theme.textMuted + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: Theme.radiusMd + color: button.down ? Theme.alpha(Theme.accent, 0.24) : button.hovered ? Theme.alpha(Theme.accent, 0.12) : "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.borderSoft, 0.65) + } + + } + +} diff --git a/src/qEmbyApp/qml/AdminLogsPage.qml b/src/qEmbyApp/qml/AdminLogsPage.qml new file mode 100644 index 0000000..053cdba --- /dev/null +++ b/src/qEmbyApp/qml/AdminLogsPage.qml @@ -0,0 +1,297 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby + +Item { + id: page + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingLg + anchors.rightMargin: Theme.spacingLg + anchors.topMargin: Theme.spacingLg + anchors.bottomMargin: Theme.spacingXl + spacing: Theme.spacingLg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + Text { + text: qsTr("Logs") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.ExtraBold + } + + Item { + Layout.fillWidth: true + } + + Text { + text: logsPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + ThemedButton { + text: logsPage.refreshing ? qsTr("Refreshing…") : qsTr("Refresh") + enabled: !logsPage.refreshing + Accessible.name: qsTr("Refresh log files") + onClicked: logsPage.refresh() + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(190, Math.max(118, page.height * 0.25)) + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.64 : 0.94) + border.width: 1 + border.color: Theme.alpha(Theme.borderSoft, 0.7) + + ListView { + id: fileList + + anchors.fill: parent + anchors.margins: Theme.spacingXs + clip: true + model: logsPage.model + spacing: 2 + boundsBehavior: Flickable.StopAtBounds + currentIndex: -1 + + ScrollBar.vertical: ThemedScrollBar { + } + + delegate: Rectangle { + id: logRow + + required property string name + required property string modified + required property string modifiedCompact + required property string sizeText + + width: fileList.width + height: 42 + radius: Theme.radiusMd + color: logsPage.selectedName === name ? Theme.alpha(Theme.accent, Theme.dark ? 0.2 : 0.12) : rowMouse.containsMouse ? Theme.alpha(Theme.bgRaised, 0.76) : "transparent" + border.width: logsPage.selectedName === name ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.42) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingMd + anchors.rightMargin: Theme.spacingSm + spacing: Theme.spacingMd + + Text { + text: logRow.name + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: logRow.sizeText + " · " + logRow.modifiedCompact + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + + ThemedButton { + implicitWidth: 74 + text: qsTr("Save…") + enabled: !logsPage.saving + Accessible.name: qsTr("Save %1").arg(logRow.name) + onClicked: logsPage.saveLog(logRow.name) + } + + } + + MouseArea { + id: rowMouse + + anchors.fill: parent + anchors.rightMargin: 80 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + Accessible.role: Accessible.ListItem + Accessible.name: logRow.name + onClicked: logsPage.selectLog(logRow.name) + } + + } + + } + + EmptyState { + anchors.centerIn: parent + visible: logsPage.hasLoaded && !logsPage.loading && logsPage.totalCount === 0 + text: qsTr("No log files found") + hint: qsTr("The server did not report any downloadable logs.") + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.64 : 0.94) + border.width: 1 + border.color: Theme.alpha(Theme.borderSoft, 0.7) + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingLg + Layout.rightMargin: Theme.spacingMd + Layout.topMargin: Theme.spacingMd + Layout.bottomMargin: Theme.spacingMd + spacing: Theme.spacingMd + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + Text { + text: logsPage.selectedName.length > 0 ? logsPage.selectedName : qsTr("No log file selected") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + Text { + text: logsPage.selectedMeta + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + Layout.fillWidth: true + } + + } + + ThemedButton { + visible: logsPage.selectedName.length > 0 + text: logsPage.saving ? qsTr("Saving…") : qsTr("Save as…") + enabled: !logsPage.saving && !logsPage.contentLoading + onClicked: logsPage.saveLog(logsPage.selectedName) + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + ScrollView { + id: contentScroll + + visible: logsPage.selectedName.length > 0 && !logsPage.contentLoading && logsPage.contentError.length === 0 + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + TextArea { + width: contentScroll.availableWidth + text: logsPage.content + readOnly: true + selectByMouse: true + wrapMode: TextEdit.WrapAtWordBoundaryOrAnywhere + color: Theme.textBody + selectionColor: Theme.accent + selectedTextColor: "white" + font.family: "monospace" + font.pixelSize: Theme.fontSmall + padding: Theme.spacingLg + Accessible.name: qsTr("Log contents") + + background: Rectangle { + color: "transparent" + } + + } + + ScrollBar.vertical: ThemedScrollBar { + } + + } + + LoadingState { + visible: logsPage.contentLoading + Layout.fillWidth: true + Layout.fillHeight: true + title: qsTr("Loading log") + message: logsPage.selectedName + } + + ErrorState { + visible: !logsPage.contentLoading && logsPage.contentError.length > 0 + Layout.fillWidth: true + Layout.fillHeight: true + title: qsTr("Could not load log") + message: logsPage.contentError + retryText: qsTr("Try again") + onRetryRequested: logsPage.selectLog(logsPage.selectedName) + } + + EmptyState { + visible: logsPage.selectedName.length === 0 && logsPage.totalCount > 0 + Layout.fillWidth: true + Layout.fillHeight: true + text: qsTr("Select a log file") + hint: qsTr("Choose a file above to inspect its contents.") + } + + } + + } + + } + + LoadingState { + anchors.fill: parent + visible: logsPage.loading && !logsPage.hasLoaded + title: qsTr("Loading logs") + message: qsTr("Fetching available server log files…") + } + + ErrorState { + anchors.fill: parent + visible: !logsPage.loading && !logsPage.hasLoaded && logsPage.errorMessage.length > 0 + title: qsTr("Could not load logs") + message: logsPage.errorMessage + onRetryRequested: logsPage.refresh() + } + + ToastHost { + id: toastHost + + anchors.fill: parent + } + + Connections { + function onToastRequested(message, kind) { + toastHost.show(message, kind); + } + + target: logsPage + } + +} diff --git a/src/qEmbyApp/qml/AdminTasksPage.qml b/src/qEmbyApp/qml/AdminTasksPage.qml new file mode 100644 index 0000000..441ea6b --- /dev/null +++ b/src/qEmbyApp/qml/AdminTasksPage.qml @@ -0,0 +1,379 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby + +Item { + id: page + + ListView { + id: taskList + + anchors.fill: parent + clip: true + model: tasksPage.model + spacing: Theme.spacingSm + boundsBehavior: Flickable.StopAtBounds + leftMargin: 16 + rightMargin: 16 + bottomMargin: 40 + section.property: "section" + section.criteria: ViewSection.FullString + + ScrollBar.vertical: ThemedScrollBar { + } + + header: ColumnLayout { + width: taskList.width - taskList.leftMargin - taskList.rightMargin + spacing: Theme.spacingLg + + Item { + Layout.preferredHeight: 14 + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + Text { + text: qsTr("Tasks") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.ExtraBold + } + + Item { + Layout.fillWidth: true + } + + Text { + text: tasksPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + ThemedButton { + text: tasksPage.refreshing ? qsTr("Refreshing…") : qsTr("Refresh") + enabled: !tasksPage.refreshing + Accessible.name: qsTr("Refresh tasks") + onClicked: tasksPage.refresh() + } + + } + + GridLayout { + Layout.fillWidth: true + columns: width >= 720 ? 4 : 2 + columnSpacing: Theme.spacingMd + rowSpacing: Theme.spacingMd + + SummaryCard { + caption: qsTr("All Tasks") + value: tasksPage.totalCount + Layout.fillWidth: true + } + + SummaryCard { + caption: qsTr("Scheduled") + value: tasksPage.scheduledCount + Layout.fillWidth: true + } + + SummaryCard { + caption: qsTr("Running") + value: tasksPage.runningCount + Layout.fillWidth: true + } + + SummaryCard { + caption: qsTr("Manual") + value: tasksPage.manualCount + Layout.fillWidth: true + } + + } + + Item { + Layout.preferredHeight: Theme.spacingXs + } + + } + + section.delegate: ColumnLayout { + required property string section + + width: taskList.width - taskList.leftMargin - taskList.rightMargin + spacing: Theme.spacingXs + + Item { + Layout.preferredHeight: Theme.spacingMd + } + + Text { + text: section + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: Font.Bold + Layout.fillWidth: true + } + + Text { + // All rows in a section expose the same explanatory subtitle. + text: section === qsTr("Running Now") ? qsTr("Tasks that are currently executing. Progress refreshes automatically.") : section === qsTr("Scheduled Tasks") ? qsTr("Tasks with one or more configured triggers.") : section === qsTr("Manual Tasks") ? qsTr("Tasks that only run when started manually.") : qsTr("Hidden or built-in tasks reported by the server.") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.bottomMargin: Theme.spacingSm + } + + } + + delegate: Rectangle { + id: taskCard + + required property string taskId + required property string name + required property string description + required property string category + required property string mode + required property string state + required property string schedule + required property string status + required property bool running + required property real progress + required property bool actionBusy + + width: taskList.width - taskList.leftMargin - taskList.rightMargin + implicitHeight: cardLayout.implicitHeight + Theme.spacingLg * 2 + radius: Theme.radiusXl + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.58 : 0.92) + border.width: 1 + border.color: Theme.alpha(Theme.borderSoft, 0.55) + + ColumnLayout { + id: cardLayout + + anchors.fill: parent + anchors.margins: Theme.spacingLg + spacing: Theme.spacingSm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + Text { + text: taskCard.name + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + spacing: Theme.spacingXs + + Badge { + label: taskCard.category + } + + Badge { + label: taskCard.mode + muted: taskCard.mode === qsTr("System") + } + + Item { + Layout.fillWidth: true + } + + } + + } + + ThemedButton { + text: qsTr("Edit") + Accessible.name: qsTr("Edit %1").arg(taskCard.name) + onClicked: tasksPage.editTask(taskCard.taskId) + } + + ThemedButton { + text: taskCard.actionBusy ? qsTr("Working…") : taskCard.running ? qsTr("Stop") : qsTr("Run") + enabled: !taskCard.actionBusy + Accessible.name: taskCard.running ? qsTr("Stop %1").arg(taskCard.name) : qsTr("Run %1").arg(taskCard.name) + onClicked: { + if (taskCard.running) + tasksPage.stopTask(taskCard.taskId); + else + tasksPage.runTask(taskCard.taskId); + } + } + + } + + Text { + visible: text.length > 0 + text: taskCard.description + color: Theme.textMuted + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + text: qsTr("Schedule: %1").arg(taskCard.schedule) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + text: taskCard.status + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Rectangle { + visible: taskCard.running + Layout.fillWidth: true + Layout.preferredHeight: 5 + radius: height / 2 + color: Theme.alpha(Theme.borderSoft, 0.55) + + Rectangle { + width: parent.width * Math.max(0, Math.min(100, taskCard.progress)) / 100 + height: parent.height + radius: height / 2 + color: Theme.accent + + Behavior on width { + NumberAnimation { + duration: Theme.animationNormal + } + + } + + } + + } + + Text { + visible: taskCard.running + text: qsTr("%1 · %2%").arg(taskCard.state).arg(taskCard.progress.toFixed(1)) + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + + } + + } + + footer: Item { + width: 1 + height: Theme.spacingXl + } + + } + + LoadingState { + anchors.fill: parent + visible: tasksPage.loading && !tasksPage.hasLoaded + title: qsTr("Loading tasks") + message: qsTr("Fetching scheduled tasks from the server…") + } + + ErrorState { + anchors.fill: parent + visible: !tasksPage.loading && !tasksPage.hasLoaded && tasksPage.errorMessage.length > 0 + title: qsTr("Could not load tasks") + message: tasksPage.errorMessage + onRetryRequested: tasksPage.refresh() + } + + EmptyState { + anchors.centerIn: parent + width: Math.min(420, page.width - Theme.spacingXl * 2) + visible: tasksPage.hasLoaded && !tasksPage.loading && tasksPage.totalCount === 0 + icon: "/svg/dark/manage-tasks.svg" + text: qsTr("No tasks found") + hint: qsTr("The server did not report any scheduled tasks.") + } + + ToastHost { + id: toastHost + + anchors.fill: parent + } + + Connections { + function onToastRequested(message, kind) { + toastHost.show(message, kind); + } + + target: tasksPage + } + + component SummaryCard: Rectangle { + id: summaryCard + + required property string caption + required property int value + + implicitHeight: 82 + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.6 : 0.94) + border.width: 1 + border.color: Theme.alpha(Theme.borderSoft, 0.55) + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingLg + spacing: Theme.spacingXs + + Text { + text: summaryCard.value + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + Text { + text: summaryCard.caption + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + } + + } + + component Badge: Rectangle { + required property string label + property bool muted: false + + implicitWidth: badgeText.implicitWidth + Theme.spacingMd * 2 + implicitHeight: 24 + radius: Theme.radiusMd + color: Theme.alpha(muted ? Theme.textMuted : Theme.accent, 0.12) + border.color: Theme.alpha(muted ? Theme.textMuted : Theme.accent, 0.3) + + Text { + id: badgeText + + anchors.centerIn: parent + text: parent.label + color: parent.muted ? Theme.textMuted : Theme.accent + font.pixelSize: Theme.fontSmall + font.weight: Font.Medium + } + + } + +} diff --git a/src/qEmbyApp/qml/AdminTranscodingPage.qml b/src/qEmbyApp/qml/AdminTranscodingPage.qml new file mode 100644 index 0000000..f21afbf --- /dev/null +++ b/src/qEmbyApp/qml/AdminTranscodingPage.qml @@ -0,0 +1,373 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property var hardwareOptions: [ + { text: qsTr("None"), value: "none" }, { text: "AMF", value: "amf" }, + { text: "NVENC", value: "nvenc" }, { text: "QSV", value: "qsv" }, + { text: "VAAPI", value: "vaapi" }, { text: "RKMPP", value: "rkmpp" }, + { text: "VideoToolbox", value: "videotoolbox" }, { text: "V4L2 M2M", value: "v4l2m2m" } + ] + readonly property var encoderPresetOptions: [ + { text: qsTr("Auto"), value: "auto" }, { text: qsTr("Ultra fast"), value: "ultrafast" }, + { text: qsTr("Super fast"), value: "superfast" }, { text: qsTr("Very fast"), value: "veryfast" }, + { text: qsTr("Faster"), value: "faster" }, { text: qsTr("Fast"), value: "fast" }, + { text: qsTr("Medium"), value: "medium" }, { text: qsTr("Slow"), value: "slow" }, + { text: qsTr("Slower"), value: "slower" }, { text: qsTr("Very slow"), value: "veryslow" } + ] + readonly property var toneOptions: [ + { text: qsTr("Disabled"), value: "disabled" }, { text: qsTr("Hardware"), value: "hardware" }, + { text: qsTr("Software"), value: "software" }, { text: qsTr("Hardware + Software"), value: "mixed" } + ] + readonly property var toneAlgorithmOptions: [ + { text: qsTr("None"), value: "none" }, { text: "BT.2390", value: "bt2390" }, + { text: "Hable", value: "hable" }, { text: "Reinhard", value: "reinhard" }, + { text: "Mobius", value: "mobius" }, { text: "Clip", value: "clip" }, + { text: "Linear", value: "linear" }, { text: "Gamma", value: "gamma" } + ] + readonly property var jellyCodecs: ["h264", "hevc", "mpeg2video", "mpeg4", "vc1", "vp8", "vp9", "av1"] + + function codecChecked(codec) { + const values = transcodingPage.form.HardwareDecodingCodecs || []; + return values.indexOf(codec) >= 0; + } + + function setCodecChecked(codec, checked) { + const values = (transcodingPage.form.HardwareDecodingCodecs || []).slice(); + const index = values.indexOf(codec); + if (checked && index < 0) + values.push(codec); + else if (!checked && index >= 0) + values.splice(index, 1); + transcodingPage.setValue("HardwareDecodingCodecs", values); + } + + Rectangle { anchors.fill: parent; color: Theme.bgBase } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingXl + spacing: Theme.spacingLg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + Text { text: qsTr("Transcoding"); color: Theme.textPrimary; font.pixelSize: Theme.fontTitle; font.weight: Font.Bold } + Text { text: transcodingPage.statusText; color: Theme.textMuted; font.pixelSize: Theme.fontBody } + } + ThemedButton { + text: transcodingPage.refreshing ? qsTr("Refreshing…") : qsTr("Refresh") + enabled: !transcodingPage.loading && !transcodingPage.refreshing && !transcodingPage.saving + onClicked: transcodingPage.refresh() + } + ThemedButton { text: qsTr("Reset"); enabled: transcodingPage.dirty && !transcodingPage.saving; onClicked: resetDialog.open() } + ThemedButton { + text: transcodingPage.saving ? qsTr("Saving…") : qsTr("Save") + enabled: transcodingPage.canSave + onClicked: transcodingPage.save() + } + } + + Rectangle { + visible: transcodingPage.errorText.length > 0 && transcodingPage.loaded + Layout.fillWidth: true + implicitHeight: warningRow.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd + color: Theme.alpha(Theme.danger, 0.1) + border.width: 1; border.color: Theme.alpha(Theme.danger, 0.45) + RowLayout { + id: warningRow + anchors.fill: parent; anchors.margins: Theme.spacingMd + Text { text: transcodingPage.errorText; color: Theme.textBody; wrapMode: Text.WordWrap; Layout.fillWidth: true } + ThemedButton { text: qsTr("Try again"); onClicked: transcodingPage.retry() } + } + } + + Basic.ScrollView { + id: scroll + Layout.fillWidth: true + Layout.fillHeight: true + visible: transcodingPage.loaded + clip: true + contentWidth: availableWidth + Basic.ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + width: scroll.availableWidth + spacing: Theme.spacingMd + + CollapsibleSection { + visible: transcodingPage.jellyfin + Layout.fillWidth: true + title: qsTr("Hardware acceleration") + ComboField { keyName: "HardwareAccelerationType"; label: qsTr("Hardware acceleration type"); choices: page.hardwareOptions } + TextField { keyName: "VaapiDevice"; label: qsTr("VAAPI device"); browse: true } + TextField { keyName: "QsvDevice"; label: qsTr("QSV device"); browse: true } + FieldCard { + visible: transcodingPage.supports("HardwareDecodingCodecs") + label: qsTr("Hardware decoding codecs") + Flow { + Layout.fillWidth: true + Layout.preferredHeight: childrenRect.height + spacing: Theme.spacingMd + Repeater { + model: page.jellyCodecs + ThemedSwitch { + required property string modelData + text: modelData.toUpperCase() + checked: page.codecChecked(modelData) + onToggled: page.setCodecChecked(modelData, checked) + } + } + } + } + SwitchField { keyName: "PreferSystemNativeHwDecoder"; label: qsTr("Prefer system native decoder") } + SwitchField { keyName: "EnableEnhancedNvdecDecoder"; label: qsTr("Enhanced NVDEC") } + SwitchField { keyName: "EnableHardwareEncoding"; label: qsTr("Enable hardware encoding") } + SwitchField { keyName: "EnableIntelLowPowerH264HwEncoder"; label: qsTr("Intel low power H.264") } + SwitchField { keyName: "EnableIntelLowPowerHevcHwEncoder"; label: qsTr("Intel low power HEVC") } + SwitchField { keyName: "AllowHevcEncoding"; label: qsTr("Allow HEVC encoding") } + SwitchField { keyName: "AllowAv1Encoding"; label: qsTr("Allow AV1 encoding") } + } + + CollapsibleSection { + visible: !transcodingPage.jellyfin && transcodingPage.supports("HardwareAccelerationMode") + Layout.fillWidth: true + title: qsTr("Hardware acceleration") + ComboField { + keyName: "HardwareAccelerationMode"; label: qsTr("Hardware acceleration mode") + choices: [{ text: qsTr("Yes"), value: 1 }, { text: qsTr("No"), value: 0 }, { text: qsTr("Advanced"), value: 2 }] + } + Text { text: qsTr("Advanced mode exposes the server codec configuration below."); color: Theme.textMuted; wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + + CollapsibleSection { + visible: !transcodingPage.jellyfin && transcodingPage.codecRows.length > 0 + && Number(transcodingPage.form.HardwareAccelerationMode) === 2 + Layout.fillWidth: true + title: qsTr("Codecs") + Repeater { + model: transcodingPage.codecRows + Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: codecRow.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd; color: Theme.bgSurface; border.width: 1; border.color: Theme.borderSoft + RowLayout { + id: codecRow + anchors.fill: parent; anchors.margins: Theme.spacingMd; spacing: Theme.spacingMd + ColumnLayout { + Layout.fillWidth: true; spacing: Theme.spacingXs + Text { text: modelData.title; color: Theme.textPrimary; font.weight: Font.DemiBold; Layout.fillWidth: true } + Text { text: [modelData.direction, modelData.mediaType, modelData.description].filter(Boolean).join(" · "); color: Theme.textMuted; wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + ThemedSwitch { + visible: modelData.hardware + enabled: !transcodingPage.saving && !transcodingPage.loading + checked: modelData.enabled + onToggled: transcodingPage.setCodecEnabled(modelData.codecId, checked) + } + ThemedButton { text: qsTr("Info"); onClicked: transcodingPage.requestCodecInfo(modelData.codecId) } + ThemedButton { + visible: modelData.supportsParameters + text: qsTr("Settings") + onClicked: transcodingPage.requestCodecParameters(modelData.codecId) + } + } + } + } + } + + CollapsibleSection { + Layout.fillWidth: true + title: qsTr("Paths and threads") + SpinField { keyName: "EncodingThreadCount"; label: qsTr("Encoding threads"); fromValue: -1; toValue: 256 } + SpinField { keyName: "ExtractionThreadCount"; label: qsTr("Extraction threads"); fromValue: -1; toValue: 256 } + TextField { keyName: "TranscodingTempPath"; label: qsTr("Transcoding temporary path"); browse: true } + TextField { keyName: "EncoderAppPathDisplay"; label: qsTr("FFmpeg path"); readOnly: true } + TextField { keyName: "FallbackFontPath"; label: qsTr("Fallback font path"); browse: true; directoryPicker: false } + SwitchField { keyName: "EnableFallbackFont"; label: qsTr("Enable fallback font") } + SpinField { keyName: "MaxMuxingQueueSize"; label: qsTr("Maximum muxing queue size"); fromValue: 0; toValue: 1000000 } + } + + CollapsibleSection { + Layout.fillWidth: true + title: qsTr("Quality and processing") + ComboField { keyName: "EncoderPreset"; label: qsTr("Encoder preset"); choices: page.encoderPresetOptions } + SpinField { keyName: "H264Crf"; label: qsTr("H.264 CRF"); fromValue: 0; toValue: 51 } + SpinField { keyName: "H265Crf"; label: qsTr("H.265 CRF"); fromValue: 0; toValue: 51 } + DoubleField { keyName: "DownMixAudioBoost"; label: qsTr("Audio downmix boost"); minValue: 0.5; maxValue: 3.0 } + SwitchField { keyName: "EnableAudioVbr"; label: qsTr("Enable audio VBR") } + ComboField { keyName: "DeinterlaceMethod"; label: qsTr("Deinterlace method"); choices: [{text: "YADIF", value: "yadif"}, {text: "BWDIF", value: "bwdif"}] } + SwitchField { keyName: "DeinterlaceDoubleRate"; label: qsTr("Double-rate deinterlacing") } + SwitchField { keyName: "EnableSubtitleExtraction"; label: qsTr("Enable subtitle extraction") } + } + + CollapsibleSection { + Layout.fillWidth: true + title: qsTr("Tone mapping") + ComboField { visible: !transcodingPage.jellyfin; keyName: "EmbyToneMappingMode"; label: qsTr("Tone mapping mode"); choices: page.toneOptions } + SwitchField { keyName: "EnableTonemapping"; label: qsTr("Enable tone mapping") } + SwitchField { keyName: "EnableVppTonemapping"; label: qsTr("Enable VPP tone mapping") } + SwitchField { keyName: "EnableVideoToolboxTonemapping"; label: qsTr("Enable VideoToolbox tone mapping") } + ComboField { keyName: "TonemappingAlgorithm"; label: qsTr("Algorithm"); choices: page.toneAlgorithmOptions } + ComboField { keyName: "TonemappingMode"; label: qsTr("Mode"); choices: [{text: qsTr("Auto"), value: "auto"}, {text: "MAX", value: "max"}, {text: "RGB", value: "rgb"}, {text: "LUM", value: "lum"}, {text: "ITP", value: "itp"}] } + ComboField { keyName: "TonemappingRange"; label: qsTr("Range"); choices: [{text: qsTr("Auto"), value: "auto"}, {text: "TV", value: "tv"}, {text: "PC", value: "pc"}] } + DoubleField { keyName: "TonemappingDesat"; label: qsTr("Desaturation"); minValue: 0; maxValue: 1000000 } + DoubleField { keyName: "TonemappingPeak"; label: qsTr("Peak"); minValue: 0; maxValue: 1000000 } + DoubleField { keyName: "TonemappingParam"; label: qsTr("Parameter"); minValue: -1000000; maxValue: 1000000 } + DoubleField { keyName: "VppTonemappingBrightness"; label: qsTr("VPP brightness"); minValue: 0; maxValue: 100 } + DoubleField { keyName: "VppTonemappingContrast"; label: qsTr("VPP contrast"); minValue: 1; maxValue: 2 } + } + + CollapsibleSection { + Layout.fillWidth: true + title: qsTr("Throttling and segments") + SwitchField { keyName: "EnableThrottling"; label: qsTr("Enable throttling") } + SpinField { keyName: "ThrottleDelaySeconds"; label: qsTr("Throttle delay (seconds)"); fromValue: 0; toValue: 100000 } + SpinField { keyName: "ThrottleBufferSize"; label: qsTr("Throttle buffer size"); fromValue: 0; toValue: 100000 } + SpinField { keyName: "ThrottleHysteresis"; label: qsTr("Throttle hysteresis"); fromValue: 0; toValue: 100000 } + SwitchField { keyName: "EnableSegmentDeletion"; label: qsTr("Delete old segments") } + SpinField { keyName: "SegmentKeepSeconds"; label: qsTr("Segment retention (seconds)"); fromValue: 0; toValue: 1000000 } + } + + Item { Layout.fillWidth: true; Layout.preferredHeight: Theme.spacingXl } + } + } + + LoadingState { visible: transcodingPage.loading && !transcodingPage.loaded; Layout.fillWidth: true; Layout.fillHeight: true; title: qsTr("Loading transcoding settings") } + ErrorState { + visible: !transcodingPage.loading && !transcodingPage.loaded + Layout.fillWidth: true; Layout.fillHeight: true + title: qsTr("Transcoding settings unavailable") + message: transcodingPage.errorText.length ? transcodingPage.errorText : qsTr("No transcoding configuration is available for this server.") + onRetryRequested: transcodingPage.retry() + } + } + + ThemedDialog { + id: resetDialog + title: qsTr("Discard changes?") + confirmText: qsTr("Discard") + cancelText: qsTr("Cancel") + confirmRole: "danger" + Text { + text: qsTr("Restore all fields to the last values loaded from the server?") + color: Theme.textBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + onAccepted: transcodingPage.reset() + } + ToastHost { id: toasts; anchors.fill: parent } + Connections { + target: transcodingPage + function onNotificationRequested(message, kind) { toasts.show(message, kind) } + } + + component FieldCard: Rectangle { + property string label: "" + default property alias fieldContent: fieldLayout.data + Layout.fillWidth: true + implicitHeight: fieldLayout.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd; color: Theme.bgSurface; border.width: 1; border.color: Theme.borderSoft + ColumnLayout { + id: fieldLayout + anchors.fill: parent; anchors.margins: Theme.spacingMd; spacing: Theme.spacingSm + Text { visible: label.length > 0; text: label; color: Theme.textPrimary; font.pixelSize: Theme.fontBody; font.weight: Font.DemiBold; wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + } + + component SwitchField: FieldCard { + property string keyName + visible: transcodingPage.supports(keyName) + ThemedSwitch { + checked: Boolean(transcodingPage.form[keyName]) + enabled: !transcodingPage.saving + onToggled: transcodingPage.setValue(keyName, checked) + } + } + + component TextField: FieldCard { + id: textFieldRoot + property string keyName + property bool browse: false + property bool directoryPicker: true + property bool readOnly: false + visible: transcodingPage.supports(keyName) + RowLayout { + Layout.fillWidth: true + ThemedTextField { + Layout.fillWidth: true + text: String(transcodingPage.form[keyName] ?? "") + readOnly: textFieldRoot.readOnly + enabled: !transcodingPage.saving + onEditingFinished: transcodingPage.setValue(keyName, text.trim()) + } + ThemedButton { + visible: textFieldRoot.browse + text: qsTr("Browse…") + enabled: !transcodingPage.saving + onClicked: transcodingPage.requestPath(keyName, textFieldRoot.directoryPicker) + } + } + } + + component SpinField: FieldCard { + id: spinFieldRoot + property string keyName + property int fromValue: 0 + property int toValue: 100 + visible: transcodingPage.supports(keyName) + ThemedSpinBox { + from: spinFieldRoot.fromValue; to: spinFieldRoot.toValue + value: Number(transcodingPage.form[keyName] ?? from) + enabled: !transcodingPage.saving + onValueModified: transcodingPage.setValue(keyName, value) + } + } + + component DoubleField: FieldCard { + id: doubleFieldRoot + property string keyName + property real minValue: 0 + property real maxValue: 100 + visible: transcodingPage.supports(keyName) + ThemedTextField { + Layout.preferredWidth: 180 + text: String(transcodingPage.form[keyName] ?? "") + enabled: !transcodingPage.saving + validator: DoubleValidator { bottom: doubleFieldRoot.minValue; top: doubleFieldRoot.maxValue; notation: DoubleValidator.StandardNotation } + onEditingFinished: if (acceptableInput) transcodingPage.setValue(keyName, Number(text)) + } + } + + component ComboField: FieldCard { + id: comboFieldRoot + property string keyName + property var choices: [] + function findIndex() { + const current = transcodingPage.form[keyName]; + for (let i = 0; i < choices.length; ++i) + if (choices[i].value === current) return i; + return choices.length ? 0 : -1; + } + visible: transcodingPage.supports(keyName) + ThemedComboBox { + Layout.preferredWidth: 240 + model: comboFieldRoot.choices + textRole: "text" + currentIndex: comboFieldRoot.findIndex() + enabled: !transcodingPage.saving + onActivated: transcodingPage.setValue(keyName, comboFieldRoot.choices[index].value) + } + } +} diff --git a/src/qEmbyApp/qml/AdminUsersPage.qml b/src/qEmbyApp/qml/AdminUsersPage.qml new file mode 100644 index 0000000..9ca21a0 --- /dev/null +++ b/src/qEmbyApp/qml/AdminUsersPage.qml @@ -0,0 +1,421 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + property string deleteUserId: "" + property string deleteName: "" + readonly property int pageMargin: Theme.spacingXl + readonly property int cardGap: Theme.spacingMd + readonly property int cardMinimumWidth: 250 + readonly property int columnCount: Math.max(1, Math.floor((grid.width + cardGap) / (cardMinimumWidth + cardGap))) + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: page.pageMargin + spacing: Theme.spacingLg + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + Text { + text: qsTr("Users") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + Text { + text: usersPage.userCount === 1 ? qsTr("1 user") : qsTr("%1 users").arg(usersPage.userCount) + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + } + + ThemedButton { + text: usersPage.refreshing ? qsTr("Refreshing…") : qsTr("Refresh") + enabled: !usersPage.loading && !usersPage.refreshing + Accessible.name: qsTr("Refresh users") + onClicked: usersPage.refresh() + } + + ThemedButton { + text: qsTr("Add user") + Accessible.name: text + onClicked: usersPage.requestAddUser() + } + + } + + Rectangle { + visible: usersPage.errorText.length > 0 && usersPage.hasUsers + Layout.fillWidth: true + implicitHeight: warningRow.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd + color: Theme.alpha(Theme.danger, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.danger, 0.45) + + RowLayout { + id: warningRow + + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingMd + + Text { + text: usersPage.errorText + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + ThemedButton { + text: qsTr("Try again") + onClicked: usersPage.retry() + } + + } + + } + + GridView { + id: grid + + Layout.fillWidth: true + Layout.fillHeight: true + visible: usersPage.hasUsers + clip: true + boundsBehavior: Flickable.StopAtBounds + model: usersPage.usersModel + cellWidth: width / page.columnCount + cellHeight: 214 + page.cardGap + + delegate: Rectangle { + id: card + + required property int index + required property string userId + required property string displayName + required property string initial + required property bool administrator + required property bool disabled + required property bool hasPassword + required property string lastLogin + required property string lastActivity + required property string imageSource + required property bool currentUser + required property bool canModify + required property bool busy + + width: grid.cellWidth - page.cardGap + height: 214 + radius: Theme.radiusLg + color: cardMouse.containsMouse ? Theme.alpha(Theme.accent, Theme.dark ? 0.11 : 0.07) : Theme.bgSurface + border.width: 1 + border.color: cardMouse.containsMouse ? Theme.alpha(Theme.accent, 0.58) : Theme.borderSoft + + MouseArea { + id: cardMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + Accessible.role: Accessible.Button + Accessible.name: qsTr("Edit user %1").arg(card.displayName) + onClicked: usersPage.requestEditUser(card.userId) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + Rectangle { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + radius: 24 + color: Theme.alpha(Theme.accent, 0.18) + clip: true + + Text { + anchors.centerIn: parent + visible: avatar.status !== Image.Ready + text: card.initial + color: Theme.accent + font.pixelSize: Theme.fontSubtitle + font.weight: Font.Bold + } + + Image { + id: avatar + + anchors.fill: parent + visible: status === Image.Ready + source: card.imageSource + sourceSize: Qt.size(96, 96) + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacingXs + + Text { + text: card.displayName + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + + RowLayout { + spacing: Theme.spacingXs + + Badge { + visible: card.administrator + label: qsTr("Admin") + badgeColor: Theme.accent + } + + Badge { + visible: card.disabled + label: qsTr("Disabled") + badgeColor: Theme.warning + } + + Badge { + visible: card.currentUser + label: qsTr("You") + badgeColor: Theme.success + } + + } + + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + InfoLine { + label: qsTr("Last login") + value: card.lastLogin + } + + InfoLine { + label: qsTr("Last active") + value: card.lastActivity + } + + InfoLine { + label: qsTr("Password") + value: card.hasPassword ? qsTr("Set") : qsTr("Not set") + } + + Item { + Layout.fillHeight: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Text { + visible: card.currentUser + text: qsTr("You cannot disable or delete yourself") + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + elide: Text.ElideRight + Layout.fillWidth: true + } + + Item { + visible: !card.currentUser + Layout.fillWidth: true + } + + ThemedSwitch { + checked: !card.disabled + enabled: card.canModify && !card.busy + Accessible.name: checked ? qsTr("Disable %1").arg(card.displayName) : qsTr("Enable %1").arg(card.displayName) + onToggled: usersPage.setUserEnabled(card.userId, checked) + } + + ThemedButton { + text: card.busy ? qsTr("Working…") : qsTr("Delete") + enabled: card.canModify && !card.busy + implicitWidth: 72 + leftPadding: Theme.spacingSm + rightPadding: Theme.spacingSm + Accessible.name: qsTr("Delete user %1").arg(card.displayName) + onClicked: { + page.deleteUserId = card.userId; + page.deleteName = card.displayName; + deleteDialog.open(); + } + } + + } + + } + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + + } + + } + + Basic.ScrollBar.vertical: ThemedScrollBar { + } + + } + + EmptyState { + visible: !usersPage.loading && usersPage.errorText.length === 0 && !usersPage.hasUsers + text: qsTr("No users found") + hint: qsTr("Add a user to grant access to this server.") + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + } + + } + + LoadingState { + anchors.centerIn: parent + visible: (usersPage.loading || usersPage.refreshing) && !usersPage.hasUsers + title: qsTr("Loading users…") + running: visible + } + + ErrorState { + anchors.centerIn: parent + width: Math.min(parent.width - Theme.spacingXl * 2, 480) + visible: usersPage.errorText.length > 0 && !usersPage.hasUsers && !usersPage.loading + title: qsTr("Unable to load users") + message: usersPage.errorText + onRetryRequested: usersPage.retry() + } + + ThemedDialog { + id: deleteDialog + + title: qsTr("Delete user") + accessibleDescription: qsTr("Delete user %1 permanently").arg(page.deleteName) + confirmText: qsTr("Delete") + cancelText: qsTr("Cancel") + confirmRole: "danger" + onAccepted: { + if (page.deleteUserId.length > 0) + usersPage.deleteUser(page.deleteUserId); + + page.deleteUserId = ""; + page.deleteName = ""; + } + onRejected: { + page.deleteUserId = ""; + page.deleteName = ""; + } + + Text { + text: qsTr("Are you sure you want to delete user \"%1\"? This action cannot be undone.").arg(page.deleteName) + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + } + + ToastHost { + id: toastHost + + anchors.fill: parent + } + + Connections { + function onNotificationRequested(message, kind) { + toastHost.show(message, kind); + } + + target: usersPage + } + + component Badge: Rectangle { + required property string label + required property color badgeColor + + implicitWidth: badgeText.implicitWidth + Theme.spacingSm * 2 + implicitHeight: badgeText.implicitHeight + 4 + radius: Theme.radiusSm + color: Theme.alpha(badgeColor, 0.15) + border.width: 1 + border.color: Theme.alpha(badgeColor, 0.5) + + Text { + id: badgeText + + anchors.centerIn: parent + text: parent.label + color: parent.badgeColor + font.pixelSize: Theme.fontCaption + font.weight: Font.DemiBold + } + + } + + component InfoLine: RowLayout { + required property string label + required property string value + + Layout.fillWidth: true + spacing: Theme.spacingSm + + Text { + text: parent.label + ":" + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + + Text { + text: parent.value + color: Theme.textBody + font.pixelSize: Theme.fontSmall + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + } + + } + +} diff --git a/src/qEmbyApp/qml/CategoryPage.qml b/src/qEmbyApp/qml/CategoryPage.qml new file mode 100644 index 0000000..7f01743 --- /dev/null +++ b/src/qEmbyApp/qml/CategoryPage.qml @@ -0,0 +1,194 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property var sortLabels: [qsTr("Date Added"), qsTr("Date Played"), qsTr("Title"), qsTr("Runtime"), qsTr("Premiere Date")] + + function scrollToTop() { + const grid = categoryPage.tileView ? tileGrid : posterGrid; + if (grid.children.length > 0 && grid.children[0].positionViewAtBeginning) + grid.children[0].positionViewAtBeginning(); + + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.topMargin: Theme.spacingLg + Layout.bottomMargin: Theme.spacingMd + spacing: Theme.spacingMd + + Text { + Layout.fillWidth: true + Layout.maximumWidth: 480 + text: categoryPage.title + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + elide: Text.ElideRight + } + + Item { + Layout.fillWidth: true + } + + ThemedButton { + visible: categoryPage.refreshVisible + enabled: !categoryPage.loading + text: categoryPage.loading ? qsTr("Refreshing…") : qsTr("Refresh") + onClicked: categoryPage.refresh() + } + + ThemedComboBox { + id: sortBox + + model: page.sortLabels + currentIndex: categoryPage.sortIndex + enabled: categoryPage.sortEnabled && !categoryPage.loading + onActivated: function(index) { + categoryPage.sortIndex = index; + } + } + + ThemedButton { + enabled: categoryPage.sortEnabled && !categoryPage.loading + text: categoryPage.descending ? qsTr("Descending") : qsTr("Ascending") + onClicked: categoryPage.descending = !categoryPage.descending + } + + ThemedButton { + visible: categoryPage.viewSwitchVisible + text: categoryPage.tileView ? qsTr("Poster view") : qsTr("Wide view") + onClicked: categoryPage.tileView = !categoryPage.tileView + } + + Text { + text: categoryPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignRight + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + MediaGrid { + id: posterGrid + + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingMd + anchors.topMargin: Theme.spacingLg + anchors.bottomMargin: Theme.spacingMd + visible: !categoryPage.tileView || categoryPage.castView + model: categoryPage.itemsModel + loading: categoryPage.loading + errorText: categoryPage.errorText + imageAspect: "poster" + minimumCardWidth: categoryPage.castView ? 140 : 150 + emptyTitle: qsTr("No items in this category") + emptyMessage: qsTr("Try refreshing or choosing another category.") + onActivated: function(itemId, modelIndex) { + categoryPage.activate(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + categoryPage.requestPlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + categoryPage.requestFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + categoryPage.requestMore(modelIndex); + } + onRetryRequested: categoryPage.retry() + } + + MediaGrid { + id: tileGrid + + anchors.fill: posterGrid + visible: categoryPage.tileView && !categoryPage.castView + model: categoryPage.itemsModel + loading: categoryPage.loading + errorText: categoryPage.errorText + imageAspect: "thumb" + minimumCardWidth: 250 + emptyTitle: qsTr("No items in this category") + emptyMessage: qsTr("Try refreshing or choosing another category.") + onActivated: function(itemId, modelIndex) { + categoryPage.activate(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + categoryPage.requestPlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + categoryPage.requestFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + categoryPage.requestMore(modelIndex); + } + onRetryRequested: categoryPage.retry() + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingLg + visible: categoryPage.loadingMore + width: loadingMoreRow.implicitWidth + Theme.spacingXl * 2 + height: 36 + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgRaised, 0.94) + border.color: Theme.borderSoft + + Row { + id: loadingMoreRow + + anchors.centerIn: parent + spacing: Theme.spacingSm + + BusyIndicator { + width: 20 + height: 20 + running: parent.parent.visible + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Loading more…") + color: Theme.textBody + font.pixelSize: Theme.fontSmall + } + + } + + } + + } + + } + +} diff --git a/src/qEmbyApp/qml/DashboardPage.qml b/src/qEmbyApp/qml/DashboardPage.qml new file mode 100644 index 0000000..3e456f0 --- /dev/null +++ b/src/qEmbyApp/qml/DashboardPage.qml @@ -0,0 +1,310 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property real pageMargin: 20 + readonly property real contentWidth: Math.max(0, width - pageMargin * 2) + readonly property real libraryGap: Theme.spacingLg + readonly property int libraryColumns: Math.max(1, Math.floor((contentWidth + libraryGap) / (220 + libraryGap))) + readonly property real libraryCellWidth: contentWidth / libraryColumns + readonly property real libraryCardWidth: Math.max(96, libraryCellWidth - libraryGap) + readonly property real libraryCellHeight: libraryCardWidth / (16 / 9) + 62 + libraryGap + readonly property int libraryRows: Math.ceil(dashboardPage.libraryCount / libraryColumns) + + function sectionVisible(sectionId) { + switch (sectionId) { + case "continue_watching": + return dashboardPage.showResume; + case "latest_media": + return dashboardPage.showLatest; + case "recommended_media": + return dashboardPage.showRecommended; + case "completed_watching": + return dashboardPage.showCompleted; + case "all_libraries": + return dashboardPage.showLibraries; + case "each_library_sections": + return dashboardPage.showEachLibrary; + default: + return false; + } + } + + function sectionComponent(sectionId) { + switch (sectionId) { + case "continue_watching": + return resumeSection; + case "latest_media": + return latestSection; + case "recommended_media": + return recommendedSection; + case "completed_watching": + return completedSection; + case "all_libraries": + return librariesSection; + case "each_library_sections": + return eachLibrarySections; + default: + return null; + } + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + Flickable { + id: scrollView + + anchors.fill: parent + contentWidth: width + contentHeight: contentColumn.implicitHeight + page.pageMargin * 2 + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: contentColumn + + x: page.pageMargin + y: page.pageMargin + width: page.contentWidth + spacing: Theme.spacingXl + + Repeater { + model: dashboardPage.sectionOrder + + delegate: Loader { + required property string modelData + + width: contentColumn.width + active: page.sectionVisible(modelData) + visible: active + sourceComponent: page.sectionComponent(modelData) + } + + } + + } + + ScrollBar.vertical: ThemedScrollBar { + } + + } + + LoadingState { + anchors.centerIn: parent + visible: dashboardPage.loading && !dashboardPage.hasContent + title: qsTr("Loading your home page…") + running: visible + } + + ErrorState { + anchors.centerIn: parent + width: Math.min(parent.width - 40, 480) + visible: dashboardPage.errorText.length > 0 && !dashboardPage.hasContent + title: qsTr("Unable to load the home page") + message: dashboardPage.errorText + onRetryRequested: dashboardPage.retry() + } + + Connections { + function onScrollResetRequested() { + scrollView.contentY = 0; + } + + target: dashboardPage + } + + Component { + id: resumeSection + + MediaSection { + width: contentColumn.width + title: qsTr("Continue Watching") + model: dashboardPage.resumeModel + imageAspect: dashboardPage.tileView ? "thumb" : "poster" + onSeeAllRequested: dashboardPage.requestCategory("continue_watching") + onActivated: function(itemId, modelIndex) { + dashboardPage.activate("continue_watching", modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestPlay("continue_watching", modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestFavorite("continue_watching", modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestMore("continue_watching", modelIndex, point.x, point.y); + } + } + + } + + Component { + id: latestSection + + MediaSection { + width: contentColumn.width + title: qsTr("Latest Media") + model: dashboardPage.latestModel + imageAspect: dashboardPage.tileView ? "thumb" : "poster" + onSeeAllRequested: dashboardPage.requestCategory("latest_media") + onActivated: function(itemId, modelIndex) { + dashboardPage.activate("latest_media", modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestPlay("latest_media", modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestFavorite("latest_media", modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestMore("latest_media", modelIndex, point.x, point.y); + } + } + + } + + Component { + id: recommendedSection + + MediaSection { + width: contentColumn.width + title: qsTr("Recommended") + model: dashboardPage.recommendedModel + imageAspect: dashboardPage.tileView ? "thumb" : "poster" + onSeeAllRequested: dashboardPage.requestCategory("recommended_media") + onActivated: function(itemId, modelIndex) { + dashboardPage.activate("recommended_media", modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestPlay("recommended_media", modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestFavorite("recommended_media", modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestMore("recommended_media", modelIndex, point.x, point.y); + } + } + + } + + Component { + id: completedSection + + MediaSection { + width: contentColumn.width + title: qsTr("Completed Watching") + model: dashboardPage.completedModel + imageAspect: dashboardPage.tileView ? "thumb" : "poster" + onSeeAllRequested: dashboardPage.requestCategory("completed_watching") + onActivated: function(itemId, modelIndex) { + dashboardPage.activate("completed_watching", modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestPlay("completed_watching", modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestFavorite("completed_watching", modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestMore("completed_watching", modelIndex, point.x, point.y); + } + } + + } + + Component { + id: librariesSection + + Column { + width: contentColumn.width + spacing: Theme.spacingMd + + Text { + width: parent.width + text: qsTr("All Libraries") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + MediaGrid { + width: parent.width + height: page.libraryRows * page.libraryCellHeight + model: dashboardPage.librariesModel + imageAspect: "thumb" + minimumCardWidth: 220 + onActivated: function(itemId, modelIndex) { + dashboardPage.activateLibrary(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestPlay("all_libraries", modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestFavorite("all_libraries", modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestLibraryMore(modelIndex, point.x, point.y); + } + } + + } + + } + + Component { + id: eachLibrarySections + + Column { + width: contentColumn.width + spacing: Theme.spacingXl + + Repeater { + model: dashboardPage.librarySectionsModel + + delegate: MediaSection { + required property int index + required property string libraryId + required property string libraryTitle + required property var mediaModel + + width: contentColumn.width + title: libraryTitle + model: mediaModel + imageAspect: dashboardPage.tileView ? "thumb" : "poster" + onSeeAllRequested: dashboardPage.requestLibrary(libraryId, libraryTitle) + onActivated: function(itemId, modelIndex) { + dashboardPage.activateLibrarySection(index, modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + dashboardPage.requestLibrarySectionPlay(index, modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + dashboardPage.requestLibrarySectionFavorite(index, modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + const point = mapToItem(page, width / 2, height / 2); + dashboardPage.requestLibrarySectionMore(index, modelIndex, point.x, point.y); + } + } + + } + + } + + } + +} diff --git a/src/qEmbyApp/qml/DetailPage.qml b/src/qEmbyApp/qml/DetailPage.qml new file mode 100644 index 0000000..6302347 --- /dev/null +++ b/src/qEmbyApp/qml/DetailPage.qml @@ -0,0 +1,331 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +FocusScope { + id: root + + function scrollToTop() { + pageScroll.contentY = 0; + } + + function wireSection(section, key) { + section.activated.connect(function(itemId, row) { + detailPage.activateSectionItem(key, row); + }); + section.playRequested.connect(function(itemId, row) { + detailPage.playSectionItem(key, row); + }); + section.favoriteRequested.connect(function(itemId, row) { + detailPage.favoriteSectionItem(key, row); + }); + section.moreRequested.connect(function(itemId, row) { + detailPage.moreSectionItem(key, row); + }); + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + Flickable { + id: pageScroll + + anchors.fill: parent + contentWidth: width + contentHeight: contentColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + clip: true + + ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + id: contentColumn + + width: pageScroll.width + spacing: 0 + + Item { + id: hero + + Layout.fillWidth: true + Layout.preferredHeight: Math.max(430, heroContent.implicitHeight + Theme.spacingXl * 3) + + Image { + anchors.fill: parent + source: detailPage.backdropSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + opacity: status === Image.Ready ? 0.48 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.animationNormal } + } + } + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0; color: Theme.alpha(Theme.bgBase, 0.18) } + GradientStop { position: 0.72; color: Theme.alpha(Theme.bgBase, 0.9) } + GradientStop { position: 1; color: Theme.bgBase } + } + } + + RowLayout { + id: heroContent + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Theme.spacingXl + spacing: Theme.spacingXl + + Rectangle { + Layout.preferredWidth: 220 + Layout.preferredHeight: 330 + Layout.alignment: Qt.AlignBottom + radius: Theme.radiusLg + color: Theme.bgRaised + border.width: 1 + border.color: Theme.borderSoft + clip: true + + Image { + anchors.fill: parent + source: detailPage.posterSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignBottom + Layout.maximumWidth: 880 + spacing: Theme.spacingMd + + Text { + text: detailPage.title + color: Theme.textPrimary + font.pixelSize: Theme.fontDisplay + font.weight: Font.Bold + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + visible: text.length > 0 + text: detailPage.metaText + color: Theme.textBody + font.pixelSize: Theme.fontBody + Layout.fillWidth: true + } + + Text { + visible: text.length > 0 + text: detailPage.tagline + color: Theme.textBody + font.pixelSize: Theme.fontHeading + font.italic: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Flow { + visible: detailPage.genres.length > 0 + Layout.fillWidth: true + spacing: Theme.spacingSm + + Repeater { + model: detailPage.genres + + ThemedButton { + required property int index + required property string modelData + + text: modelData + implicitWidth: contentItem.implicitWidth + Theme.spacingLg * 2 + onClicked: detailPage.activateGenre(index) + } + } + } + + RowLayout { + spacing: Theme.spacingSm + + ThemedButton { + visible: detailPage.playable + enabled: !detailPage.actionBusy + text: detailPage.resumable ? qsTr("Resume") : qsTr("Play") + onClicked: detailPage.requestPlay(detailPage.resumable) + } + + ThemedButton { + visible: detailPage.playable && detailPage.resumable + enabled: !detailPage.actionBusy + text: qsTr("Play from beginning") + onClicked: detailPage.requestPlay(false) + } + + ThemedButton { + enabled: !detailPage.actionBusy + text: detailPage.favorite ? qsTr("Favorited") : qsTr("Favorite") + onClicked: detailPage.requestFavorite() + } + + ThemedButton { + enabled: !detailPage.actionBusy + text: detailPage.played ? qsTr("Mark unplayed") : qsTr("Mark played") + onClicked: detailPage.requestPlayed() + } + + ThemedButton { + text: qsTr("More") + onClicked: detailPage.requestMore() + } + } + + RowLayout { + visible: detailPage.sourceNames.length > 1 + spacing: Theme.spacingMd + + Text { + text: qsTr("Version") + color: Theme.textBody + font.pixelSize: Theme.fontBody + } + + ThemedComboBox { + Layout.preferredWidth: 260 + model: detailPage.sourceNames + currentIndex: detailPage.selectedSource + onActivated: function(index) { + detailPage.setSelectedSource(index); + } + } + } + + Text { + visible: text.length > 0 + text: detailPage.overview + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + lineHeight: 1.25 + wrapMode: Text.WordWrap + maximumLineCount: 8 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + Item { Layout.fillWidth: true } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.bottomMargin: Theme.spacingXl * 2 + spacing: Theme.spacingXl + + MediaSection { + id: seasonsSection + visible: detailPage.hasSeasons + title: qsTr("Seasons") + model: detailPage.seasonsModel + seeAllVisible: false + Component.onCompleted: root.wireSection(seasonsSection, "seasons") + } + + MediaSection { + id: episodesSection + visible: detailPage.hasEpisodes + title: qsTr("Episodes") + model: detailPage.episodesModel + imageAspect: "thumb" + seeAllVisible: false + Component.onCompleted: root.wireSection(episodesSection, "episodes") + } + + MediaSection { + id: castSection + visible: detailPage.hasCast + title: qsTr("Cast & Crew") + model: detailPage.castModel + seeAllVisible: false + Component.onCompleted: root.wireSection(castSection, "cast") + } + + MediaSection { + id: additionalSection + visible: detailPage.hasAdditional + title: qsTr("Additional Parts") + model: detailPage.additionalModel + imageAspect: "thumb" + seeAllVisible: false + Component.onCompleted: root.wireSection(additionalSection, "additional") + } + + MediaSection { + id: collectionsSection + visible: detailPage.hasCollections + title: qsTr("Collection") + model: detailPage.collectionsModel + seeAllVisible: false + Component.onCompleted: root.wireSection(collectionsSection, "collections") + } + + MediaSection { + id: similarSection + visible: detailPage.hasSimilar + title: qsTr("More Like This") + model: detailPage.similarModel + seeAllVisible: false + Component.onCompleted: root.wireSection(similarSection, "similar") + } + } + } + } + + Rectangle { + anchors.fill: parent + visible: detailPage.loading && detailPage.title.length === 0 + color: Theme.bgBase + + LoadingState { + anchors.centerIn: parent + title: qsTr("Loading details") + running: parent.visible + } + } + + Rectangle { + anchors.fill: parent + visible: detailPage.errorText.length > 0 && detailPage.title.length === 0 + color: Theme.bgBase + + ErrorState { + anchors.centerIn: parent + title: qsTr("Unable to load details") + message: detailPage.errorText + onRetryRequested: detailPage.retry() + } + } + + ToastHost { + id: toast + anchors.fill: parent + } + + Connections { + target: detailPage + function onStateChanged() { + if (detailPage.errorText.length > 0 && detailPage.title.length > 0) + toast.show(detailPage.errorText, "error"); + } + } +} diff --git a/src/qEmbyApp/qml/EmptyState.qml b/src/qEmbyApp/qml/EmptyState.qml new file mode 100644 index 0000000..84f29d3 --- /dev/null +++ b/src/qEmbyApp/qml/EmptyState.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Layouts +import qEmby + +// 共享空状态占位:与 QSS 的 QLabel#ManageEmptyLabel 同一视觉语义 +// (textMuted + 斜体),可选 adaptiveicon 图标与副标题。 +// 用法: EmptyState { icon: "/svg/dark/now-playing.svg"; text: qsTr("...") } +ColumnLayout { + id: root + + // /svg/dark/xxx.svg 路径,留空则不显示图标(经 image://adaptiveicon 随主题换色) + property string icon: "" + property alias text: titleText.text + property string hint: "" + property int iconSize: 28 + + spacing: Theme.spacingSm + Layout.fillWidth: true + Layout.topMargin: Theme.spacingSm + Layout.bottomMargin: Theme.spacingSm + + Image { + visible: root.icon.length > 0 + opacity: 0.55 + sourceSize: Qt.size(root.iconSize * 2, root.iconSize * 2) + source: root.icon.length > 0 + ? "image://adaptiveicon/" + root.icon + "?d=" + (Theme.dark ? 1 : 0) + : "" + Layout.preferredWidth: root.iconSize + Layout.preferredHeight: root.iconSize + Layout.alignment: Qt.AlignHCenter + } + + Text { + id: titleText + color: Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + font.italic: true + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + visible: root.hint.length > 0 + text: root.hint + color: Theme.alpha(Theme.textMuted, 0.75) + font.pixelSize: Theme.fontSmall + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } +} diff --git a/src/qEmbyApp/qml/FavoritesPage.qml b/src/qEmbyApp/qml/FavoritesPage.qml new file mode 100644 index 0000000..b96d144 --- /dev/null +++ b/src/qEmbyApp/qml/FavoritesPage.qml @@ -0,0 +1,306 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property string mediaAspect: favoritesPage.tileView ? "thumb" : "poster" + readonly property real mediaCardWidth: favoritesPage.tileView ? 240 : 160 + + function activate(section, itemId, index) { + favoritesPage.activate(section, index); + } + + function play(section, itemId, index) { + favoritesPage.requestPlay(section, index); + } + + function favorite(section, itemId, index) { + favoritesPage.requestFavorite(section, index); + } + + function more(section, itemId, index) { + favoritesPage.requestMore(section, index); + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.topMargin: Theme.spacingLg + Layout.bottomMargin: Theme.spacingMd + spacing: Theme.spacingMd + + Text { + text: qsTr("Favorites") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + Layout.fillWidth: true + } + + Text { + visible: favoritesPage.refreshing + text: qsTr("Refreshing…") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + + ThemedButton { + text: qsTr("Refresh") + enabled: !favoritesPage.loading && !favoritesPage.refreshing + onClicked: favoritesPage.refresh() + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Flickable { + id: scroller + + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingMd + clip: true + contentWidth: width + contentHeight: sections.implicitHeight + Theme.spacingXl * 2 + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: sections + + width: scroller.width - Theme.spacingMd + y: Theme.spacingXl + spacing: Theme.spacingXl + + Rectangle { + visible: favoritesPage.errorText.length > 0 && favoritesPage.hasContent + Layout.fillWidth: true + Layout.preferredHeight: partialErrorRow.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd + color: Theme.alpha(Theme.danger, 0.1) + border.color: Theme.alpha(Theme.danger, 0.35) + + RowLayout { + id: partialErrorRow + + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingMd + + Text { + text: favoritesPage.errorText + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + ThemedButton { + text: qsTr("Try again") + enabled: !favoritesPage.refreshing + onClicked: favoritesPage.retry() + } + + } + + } + + MediaSection { + visible: favoritesPage.moviesCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite Movies") + model: favoritesPage.moviesModel + imageAspect: page.mediaAspect + cardWidth: page.mediaCardWidth + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.Movies) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.Movies, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.Movies, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.Movies, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.Movies, itemId, index); + } + } + + MediaSection { + visible: favoritesPage.seriesCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite Shows") + model: favoritesPage.seriesModel + imageAspect: page.mediaAspect + cardWidth: page.mediaCardWidth + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.Series) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.Series, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.Series, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.Series, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.Series, itemId, index); + } + } + + MediaSection { + visible: favoritesPage.collectionsCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite Collections") + model: favoritesPage.collectionsModel + imageAspect: page.mediaAspect + cardWidth: page.mediaCardWidth + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.Collections) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.Collections, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.Collections, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.Collections, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.Collections, itemId, index); + } + } + + MediaSection { + visible: favoritesPage.playlistsCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite Playlists") + model: favoritesPage.playlistsModel + imageAspect: page.mediaAspect + cardWidth: page.mediaCardWidth + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.Playlists) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.Playlists, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.Playlists, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.Playlists, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.Playlists, itemId, index); + } + } + + MediaSection { + visible: favoritesPage.peopleCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite People") + model: favoritesPage.peopleModel + imageAspect: "poster" + cardWidth: 150 + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.People) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.People, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.People, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.People, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.People, itemId, index); + } + } + + MediaSection { + visible: favoritesPage.showFolders && favoritesPage.foldersCount > 0 + Layout.fillWidth: true + title: qsTr("Favorite Folders") + model: favoritesPage.foldersModel + imageAspect: page.mediaAspect + cardWidth: page.mediaCardWidth + onSeeAllRequested: favoritesPage.requestCategory(favoritesPage.Folders) + onActivated: (itemId, index) => { + return page.activate(favoritesPage.Folders, itemId, index); + } + onPlayRequested: (itemId, index) => { + return page.play(favoritesPage.Folders, itemId, index); + } + onFavoriteRequested: (itemId, index) => { + return page.favorite(favoritesPage.Folders, itemId, index); + } + onMoreRequested: (itemId, index) => { + return page.more(favoritesPage.Folders, itemId, index); + } + } + + } + + ScrollBar.vertical: ThemedScrollBar { + } + + } + + LoadingState { + anchors.centerIn: parent + width: Math.min(parent.width, 340) + visible: favoritesPage.loading + running: visible + title: qsTr("Loading favorites…") + } + + ErrorState { + anchors.centerIn: parent + width: Math.min(parent.width, 380) + visible: !favoritesPage.loading && !favoritesPage.hasContent && favoritesPage.errorText.length > 0 + title: qsTr("Unable to load favorites") + message: favoritesPage.errorText + onRetryRequested: favoritesPage.retry() + } + + EmptyState { + anchors.centerIn: parent + width: Math.min(parent.width, 380) + visible: !favoritesPage.loading && !favoritesPage.hasContent && favoritesPage.errorText.length === 0 + icon: "/svg/dark/heart-outline.svg" + text: qsTr("No favorites yet") + hint: qsTr("Items you mark as favorites will appear here.") + } + + } + + } + + Connections { + function onScrollToTopRequested() { + scroller.contentY = 0; + } + + target: favoritesPage + } + +} diff --git a/src/qEmbyApp/qml/LibraryPage.qml b/src/qEmbyApp/qml/LibraryPage.qml new file mode 100644 index 0000000..1a25dd4 --- /dev/null +++ b/src/qEmbyApp/qml/LibraryPage.qml @@ -0,0 +1,353 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property var sortLabels: [qsTr("Date Added"), qsTr("Date Played"), qsTr("Title"), qsTr("Runtime"), qsTr("Premiere Date")] + readonly property var tabLabels: [libraryPage.firstTabText, qsTr("Recent"), qsTr("Playlists"), qsTr("Collections"), qsTr("Favorites"), qsTr("Folders")] + property real posterScrollPosition: 0 + property real tileScrollPosition: 0 + property real listScrollPosition: 0 + + function viewFlickable(view) { + return view.children.length > 0 ? view.children[0] : null; + } + + function currentFlickable() { + if (libraryPage.viewStyle === 1) + return viewFlickable(tileGrid); + + if (libraryPage.viewStyle === 2) + return viewFlickable(mediaList); + + return viewFlickable(posterGrid); + } + + function scrollToTop() { + const flickable = currentFlickable(); + if (flickable && flickable.positionViewAtBeginning) + flickable.positionViewAtBeginning(); + + } + + function maybeLoadMore(flickable) { + if (!flickable || !libraryPage.hasMore || libraryPage.loading || libraryPage.loadingMore) + return ; + + if (flickable.contentY + flickable.height >= flickable.contentHeight - Math.max(320, flickable.height * 0.5)) + libraryPage.loadMore(); + + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.topMargin: Theme.spacingLg + Layout.bottomMargin: Theme.spacingMd + spacing: Theme.spacingMd + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + Text { + Layout.fillWidth: true + Layout.maximumWidth: 460 + text: libraryPage.title + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + elide: Text.ElideRight + } + + ThemedButton { + visible: libraryPage.favoriteVisible + text: libraryPage.favorite ? qsTr("♥ Favorite") : qsTr("♡ Favorite") + onClicked: libraryPage.requestCurrentFavorite() + } + + Item { + Layout.fillWidth: true + } + + ThemedButton { + enabled: !libraryPage.loading + text: qsTr("Refresh") + onClicked: libraryPage.refresh() + } + + ThemedComboBox { + model: page.sortLabels + currentIndex: libraryPage.sortIndex + enabled: !libraryPage.loading + onActivated: function(index) { + libraryPage.sortIndex = index; + } + } + + ThemedButton { + enabled: !libraryPage.loading + text: libraryPage.descending ? qsTr("Descending") : qsTr("Ascending") + onClicked: libraryPage.descending = !libraryPage.descending + } + + ThemedComboBox { + model: [qsTr("Poster"), qsTr("Tile"), qsTr("List")] + currentIndex: libraryPage.viewStyle + onActivated: function(index) { + libraryPage.viewStyle = index; + } + } + + Text { + text: libraryPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignRight + } + + } + + Flickable { + Layout.fillWidth: true + Layout.preferredHeight: libraryPage.tabsVisible ? 38 : 0 + visible: libraryPage.tabsVisible + contentWidth: tabRow.implicitWidth + contentHeight: height + clip: true + boundsBehavior: Flickable.StopAtBounds + + Row { + id: tabRow + + height: parent.height + spacing: Theme.spacingSm + + Repeater { + model: page.tabLabels + + ThemedButton { + required property int index + required property string modelData + + height: 34 + text: modelData + checkable: true + checked: libraryPage.tabIndex === index + onClicked: libraryPage.tabIndex = index + } + + } + + } + + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + MediaGrid { + id: posterGrid + + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingMd + anchors.topMargin: Theme.spacingLg + anchors.bottomMargin: Theme.spacingMd + visible: libraryPage.viewStyle === 0 + model: libraryPage.itemsModel + loading: libraryPage.loading + errorText: libraryPage.errorText + imageAspect: "poster" + minimumCardWidth: 150 + emptyTitle: qsTr("This library is empty") + emptyMessage: qsTr("Try another filter or refresh the library.") + onActivated: function(itemId, modelIndex) { + libraryPage.activate(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + libraryPage.requestPlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + libraryPage.requestFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + libraryPage.requestMore(modelIndex); + } + onRetryRequested: libraryPage.retry() + } + + MediaGrid { + id: tileGrid + + anchors.fill: posterGrid + visible: libraryPage.viewStyle === 1 + model: libraryPage.itemsModel + loading: libraryPage.loading + errorText: libraryPage.errorText + imageAspect: "thumb" + minimumCardWidth: 250 + emptyTitle: qsTr("This library is empty") + emptyMessage: qsTr("Try another filter or refresh the library.") + onActivated: function(itemId, modelIndex) { + libraryPage.activate(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + libraryPage.requestPlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + libraryPage.requestFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + libraryPage.requestMore(modelIndex); + } + onRetryRequested: libraryPage.retry() + } + + MediaList { + id: mediaList + + anchors.fill: posterGrid + visible: libraryPage.viewStyle === 2 + model: libraryPage.itemsModel + loading: libraryPage.loading + errorText: libraryPage.errorText + emptyTitle: qsTr("This library is empty") + emptyMessage: qsTr("Try another filter or refresh the library.") + onActivated: function(itemId, modelIndex) { + libraryPage.activate(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + libraryPage.requestPlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + libraryPage.requestFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + libraryPage.requestMore(modelIndex); + } + onRetryRequested: libraryPage.retry() + } + + Connections { + function onContentYChanged() { + const flickable = page.viewFlickable(posterGrid); + page.posterScrollPosition = flickable.contentY; + page.maybeLoadMore(flickable); + } + + target: page.viewFlickable(posterGrid) + ignoreUnknownSignals: true + } + + Connections { + function onContentYChanged() { + const flickable = page.viewFlickable(tileGrid); + page.tileScrollPosition = flickable.contentY; + page.maybeLoadMore(flickable); + } + + target: page.viewFlickable(tileGrid) + ignoreUnknownSignals: true + } + + Connections { + function onContentYChanged() { + const flickable = page.viewFlickable(mediaList); + page.listScrollPosition = flickable.contentY; + page.maybeLoadMore(flickable); + } + + target: page.viewFlickable(mediaList) + ignoreUnknownSignals: true + } + + Connections { + function onControlsChanged() { + Qt.callLater(function() { + const flickable = page.currentFlickable(); + if (!flickable) + return ; + + if (libraryPage.viewStyle === 1) + flickable.contentY = page.tileScrollPosition; + else if (libraryPage.viewStyle === 2) + flickable.contentY = page.listScrollPosition; + else + flickable.contentY = page.posterScrollPosition; + }); + } + + target: libraryPage + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingLg + visible: libraryPage.loadingMore && !libraryPage.loading + width: loadingMoreRow.implicitWidth + Theme.spacingXl * 2 + height: 38 + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgRaised, 0.94) + border.color: Theme.borderSoft + + Row { + id: loadingMoreRow + + anchors.centerIn: parent + spacing: Theme.spacingSm + + BusyIndicator { + width: 20 + height: 20 + running: parent.parent.visible + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Loading more…") + color: Theme.textBody + font.pixelSize: Theme.fontSmall + } + + } + + } + + ThemedButton { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.spacingLg + visible: libraryPage.hasMore && !libraryPage.loading && !libraryPage.loadingMore + text: qsTr("Load more") + onClicked: libraryPage.loadMore() + } + + } + + } + +} diff --git a/src/qEmbyApp/qml/LoginPage.qml b/src/qEmbyApp/qml/LoginPage.qml new file mode 100644 index 0000000..8120741 --- /dev/null +++ b/src/qEmbyApp/qml/LoginPage.qml @@ -0,0 +1,216 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby 1.0 +import "components" + +Item { + id: page + + Connections { + target: login + function onFormChanged() { + protocolBox.currentIndex = login.protocol === "https://" ? 1 : 0 + if (!addressField.activeFocus) addressField.text = login.address + if (!portField.activeFocus) portField.text = login.port + if (!usernameField.activeFocus) usernameField.text = login.username + if (!password.activeFocus) password.text = login.password + sslSwitch.checked = login.ignoreSsl + } + } + + Rectangle { anchors.fill: parent; color: Theme.bgBase } + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(560, Math.max(320, page.width - 48)) + spacing: Theme.spacingLg + + Text { + Layout.alignment: Qt.AlignHCenter + text: login.editorVisible ? qsTr("Connect to Server") : qsTr("Select Server") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: login.editorVisible ? editor.implicitHeight + Theme.spacingLg * 2 + : serverList.implicitHeight + Theme.spacingLg * 2 + radius: Theme.radiusLg + color: Theme.bgSurface + border.width: 1 + border.color: Theme.borderSoft + + ListView { + id: serverList + visible: !login.editorVisible + anchors.fill: parent + anchors.margins: Theme.spacingLg + implicitHeight: Math.min(contentHeight, 390) + model: login.servers + spacing: Theme.spacingSm + clip: true + + header: ThemedButton { + width: serverList.width + text: qsTr("+ Add New Server") + onClicked: login.showAdd() + } + + delegate: Rectangle { + id: card + required property string serverId + required property string name + required property string serverUrl + required property string iconSource + width: serverList.width + height: 76 + radius: Theme.radiusMd + color: cardMouse.containsMouse ? Theme.alpha(Theme.accent, 0.12) : Theme.bgRaised + border.width: 1 + border.color: cardMouse.containsMouse ? Theme.accent : Theme.borderSoft + + MouseArea { + id: cardMouse + anchors.fill: parent + hoverEnabled: true + onClicked: login.selectServer(card.serverId) + } + + RowLayout { + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingMd + + Image { + Layout.preferredWidth: 42; Layout.preferredHeight: 42 + source: card.iconSource + fillMode: Image.PreserveAspectFit + } + ColumnLayout { + Layout.fillWidth: true; spacing: 2 + Text { text: card.name; color: Theme.textPrimary; font.pixelSize: Theme.fontSubtitle; font.weight: Font.DemiBold; elide: Text.ElideRight; Layout.fillWidth: true } + Text { text: card.serverUrl; color: Theme.textMuted; font.pixelSize: Theme.fontSmall; elide: Text.ElideRight; Layout.fillWidth: true } + } + ThemedButton { text: qsTr("Edit"); implicitWidth: 64; onClicked: login.editServer(card.serverId) } + ThemedButton { text: qsTr("Remove"); implicitWidth: 76; onClicked: login.removeServer(card.serverId) } + } + } + } + + ColumnLayout { + id: editor + visible: login.editorVisible + anchors.fill: parent + anchors.margins: Theme.spacingLg + spacing: Theme.spacingMd + + Text { + visible: login.errorText.length > 0 + Layout.fillWidth: true + text: login.errorText + color: Theme.danger + font.pixelSize: Theme.fontSmall + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + + ThemedTextField { + id: addressField + Layout.fillWidth: true + placeholderText: qsTr("Server Address (example.com/emby)") + text: login.address + onTextEdited: login.address = text + onAccepted: login.submit() + } + + RowLayout { + Layout.fillWidth: true; spacing: Theme.spacingSm + ThemedComboBox { + id: protocolBox + Layout.preferredWidth: 140 + model: ["http://", "https://"] + currentIndex: login.protocol === "https://" ? 1 : 0 + onActivated: login.protocol = currentText + } + ThemedTextField { + id: portField + Layout.fillWidth: true + placeholderText: qsTr("Port") + inputMethodHints: Qt.ImhDigitsOnly + maximumLength: 5 + text: login.port + onTextEdited: login.port = text + onAccepted: login.submit() + } + ThemedButton { + text: qsTr("Proxy…") + ToolTip.visible: hovered + ToolTip.text: login.proxySummary + onClicked: login.configureProxy() + } + } + + RowLayout { + visible: login.protocol === "https://" + Layout.fillWidth: true + Text { Layout.fillWidth: true; text: qsTr("Ignore SSL certificate verification"); color: Theme.textBody; font.pixelSize: Theme.fontBody; wrapMode: Text.Wrap } + ThemedSwitch { id: sslSwitch; checked: login.ignoreSsl; onToggled: login.ignoreSsl = checked } + } + + ThemedTextField { + id: usernameField + Layout.fillWidth: true + placeholderText: qsTr("Username") + text: login.username + onTextEdited: login.username = text + onAccepted: login.submit() + } + + RowLayout { + Layout.fillWidth: true; spacing: Theme.spacingSm + ThemedTextField { + id: password + Layout.fillWidth: true + placeholderText: qsTr("Password") + echoMode: showPassword.checked ? TextInput.Normal : TextInput.Password + text: login.password + onTextEdited: login.password = text + onAccepted: login.submit() + } + ThemedButton { + id: showPassword + checkable: true + text: checked ? qsTr("Hide") : qsTr("Show") + implicitWidth: 70 + } + } + + RowLayout { + Layout.fillWidth: true; spacing: Theme.spacingSm + ThemedButton { Layout.fillWidth: true; text: qsTr("Cancel"); enabled: login.hasServers && !login.busy; onClicked: login.cancelEdit() } + ThemedButton { Layout.fillWidth: true; text: login.busy ? qsTr("Connecting…") : (login.editing ? qsTr("Save & Login") : qsTr("Login")); enabled: !login.busy; onClicked: login.submit() } + } + } + } + } + + RowLayout { + anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom + anchors.margins: Theme.spacingMd + ThemedButton { text: qsTr("Cloud Sync"); enabled: !login.busy; onClicked: login.cloudSync() } + Item { Layout.fillWidth: true } + Text { text: login.versionText; color: Theme.textMuted; font.pixelSize: Theme.fontSmall } + } + + Rectangle { + anchors.fill: parent + visible: login.busy + color: Theme.alpha(Theme.bgBase, 0.72) + z: 20 + MouseArea { anchors.fill: parent } + BusyIndicator { anchors.centerIn: parent; running: parent.visible } + } +} diff --git a/src/qEmbyApp/qml/ManageDashboardPage.qml b/src/qEmbyApp/qml/ManageDashboardPage.qml new file mode 100644 index 0000000..0b77666 --- /dev/null +++ b/src/qEmbyApp/qml/ManageDashboardPage.qml @@ -0,0 +1,459 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Effects +import QtQuick.Layouts +import qEmby + +// 管理端 Dashboard(QML 试点页)。数据来自 context property `dashboard` +// (DashboardViewModel);颜色/尺寸全部走 Theme 单例,与 QSS 同一套令牌。 +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: contentColumn.implicitHeight + 30 + 40 + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar {} + + // 与 ManagePageBase 一致的卡片外观 + component ManageCard: Rectangle { + default property alias content: cardColumn.data + property alias spacing: cardColumn.spacing + + width: contentColumn.width + implicitHeight: cardColumn.implicitHeight + 40 + radius: Theme.radiusXl + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.5 : 0.9) + border.color: Theme.alpha(Theme.borderSoft, 0.4) + + ColumnLayout { + id: cardColumn + anchors.fill: parent + anchors.margins: 20 + spacing: 10 + } + } + + component CardTitle: Text { + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: 700 + } + + component EmptyLabel: Text { + color: Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + font.italic: true + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + Layout.topMargin: Theme.spacingSm + Layout.bottomMargin: Theme.spacingSm + } + + component IconButton: Rectangle { + id: iconBtn + + property string icon + property color tint: Theme.accent + property bool spinning: false + signal activated() + + width: 32 + height: 32 + radius: Theme.radiusLg + color: !enabled ? Theme.alpha(Theme.bgRaised, 0.3) + : btnArea.pressed ? Theme.alpha(tint, 0.35) + : btnArea.containsMouse ? Theme.alpha(tint, 0.25) + : Theme.alpha(tint, 0.15) + border.color: !enabled ? Theme.alpha(Theme.borderSoft, 0.2) + : btnArea.containsMouse ? Theme.alpha(tint, 0.5) + : Theme.alpha(tint, 0.3) + + Image { + anchors.centerIn: parent + width: 16 + height: 16 + sourceSize: Qt.size(32, 32) + source: "image://adaptiveicon/" + iconBtn.icon + "?d=" + (Theme.dark ? 1 : 0) + + RotationAnimation on rotation { + running: iconBtn.spinning + from: 0 + to: 360 + duration: 800 + loops: Animation.Infinite + } + onRotationChanged: if (!iconBtn.spinning && rotation !== 0) rotation = 0 + } + + MouseArea { + id: btnArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: iconBtn.enabled + onClicked: iconBtn.activated() + } + } + + ColumnLayout { + id: contentColumn + x: 16 + y: 30 + width: page.width - 32 + spacing: Theme.spacingLg + + Text { + text: qsTr("Dashboard") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 800 + Layout.bottomMargin: 10 + } + + // ===== 服务器信息 ===== + ManageCard { + RowLayout { + Layout.fillWidth: true + spacing: 0 + + CardTitle { text: qsTr("Server Information") } + Item { Layout.fillWidth: true } + + IconButton { + icon: "/svg/dark/refresh.svg" + tint: Theme.accent + spinning: dashboard.refreshing + enabled: !dashboard.refreshing + onActivated: dashboard.refresh() + } + Item { + Layout.preferredWidth: Theme.spacingSm + Layout.preferredHeight: 1 + } + IconButton { + icon: "/svg/dark/manage-restart.svg" + tint: Theme.warning + enabled: dashboard.canRestart && !dashboard.operationBusy + onActivated: dashboard.restartServer() + } + Item { + Layout.preferredWidth: Theme.spacingXs + Layout.preferredHeight: 1 + } + IconButton { + icon: "/svg/dark/manage-shutdown.svg" + tint: Theme.danger + enabled: !dashboard.operationBusy + onActivated: dashboard.shutdownServer() + } + } + + Repeater { + model: [ + [qsTr("Server Name"), dashboard.serverName], + [qsTr("Version"), dashboard.version], + [qsTr("Operating System"), dashboard.operatingSystem], + [qsTr("Address"), dashboard.address] + ] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: Theme.spacingMd + + Text { + text: modelData[0] + color: Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + Layout.preferredWidth: 140 + } + Text { + text: modelData[1] + color: Theme.textBody + font.pixelSize: Theme.fontBodyPlus + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + + // ===== 正在播放 ===== + ManageCard { + spacing: Theme.spacingMd + + CardTitle { text: qsTr("Now Playing") } + + ListView { + id: nowPlayingList + Layout.fillWidth: true + Layout.preferredHeight: 120 + visible: count > 0 + orientation: ListView.Horizontal + spacing: Theme.spacingMd + clip: true + model: dashboard.nowPlayingModel + + delegate: Rectangle { + width: 320 + height: 120 + radius: Theme.radiusLg + 2 + color: Theme.alpha(Theme.bgRaised, Theme.dark ? 0.4 : 0.6) + border.color: Theme.alpha(Theme.borderSoft, 0.3) + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: Theme.spacingMd + + // 圆角封面 + Item { + Layout.preferredWidth: 100 + Layout.preferredHeight: 100 + + Image { + id: coverImage + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + sourceSize.width: 280 + source: model.imageSource + visible: false + } + Rectangle { + id: coverMask + anchors.fill: parent + radius: 10 + visible: false + layer.enabled: true + } + MultiEffect { + anchors.fill: parent + source: coverImage + maskEnabled: true + maskSource: coverMask + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 2 + + Text { + text: model.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 600 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: model.subtitle + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Theme.fontBody + elide: Text.ElideRight + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + // 进度条 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 4 + radius: 2 + color: Theme.alpha(Theme.borderSoft, 0.5) + + Rectangle { + width: parent.width * Math.min(1, model.progress) + height: parent.height + radius: 2 + color: model.paused ? Theme.warning : Theme.accent + + Behavior on width { NumberAnimation { duration: 300 } } + } + } + Text { + text: model.timeText + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + } + } + + Text { + text: model.userLine + color: Theme.textBody + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: model.streamLine + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + } + + EmptyLabel { + visible: nowPlayingList.count === 0 + text: qsTr("Nothing is playing") + } + } + + // ===== 活动会话 ===== + ManageCard { + spacing: Theme.spacingMd + + CardTitle { text: qsTr("Active Sessions") } + + GridLayout { + Layout.fillWidth: true + visible: sessionsRepeater.count > 0 + columns: 3 + columnSpacing: Theme.spacingSm + rowSpacing: Theme.spacingSm + + Repeater { + id: sessionsRepeater + model: dashboard.sessionsModel + + delegate: Rectangle { + Layout.fillWidth: true + implicitHeight: sessionColumn.implicitHeight + 20 + radius: Theme.radiusLg + color: Theme.alpha(Theme.bgRaised, 0.4) + border.color: Theme.alpha(Theme.borderSoft, 0.3) + + ColumnLayout { + id: sessionColumn + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + anchors.topMargin: 10 + anchors.bottomMargin: 10 + spacing: Theme.spacingXs + + Text { + text: model.userName + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 600 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: model.clientLine + color: Theme.textMuted + font.pixelSize: Theme.fontBody + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: model.infoLine + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + } + + EmptyLabel { + visible: sessionsRepeater.count === 0 + text: qsTr("No active sessions") + } + } + + // ===== 活动日志 ===== + ManageCard { + spacing: Theme.spacingSm + + CardTitle { text: qsTr("Activity Log") } + + ListView { + id: activityList + Layout.fillWidth: true + Layout.preferredHeight: 400 + visible: count > 0 + clip: true + spacing: Theme.spacingXs + model: dashboard.activityModel + + ScrollBar.vertical: ScrollBar {} + + delegate: Item { + width: activityList.width + height: activityRow.implicitHeight + 12 + + RowLayout { + id: activityRow + anchors.fill: parent + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: Theme.spacingMd + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + Layout.alignment: Qt.AlignTop + Layout.topMargin: 5 + color: model.severity === "Error" ? Theme.danger + : model.severity === "Warn" ? Theme.warning + : Theme.accent + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: model.name + color: Theme.textBody + font.pixelSize: Theme.fontBodyPlus + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: model.overview + visible: text.length > 0 + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + Text { + text: model.dateText + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + Layout.alignment: Qt.AlignTop + } + } + } + } + + EmptyLabel { + visible: activityList.count === 0 + text: qsTr("No activity records") + } + } + } +} diff --git a/src/qEmbyApp/qml/Router.qml b/src/qEmbyApp/qml/Router.qml new file mode 100644 index 0000000..fba8a8f --- /dev/null +++ b/src/qEmbyApp/qml/Router.qml @@ -0,0 +1,274 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: router + + // Inject the session-scoped NavigationController from the root Quick host. + 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 currentRoute: navigationController + ? navigationController.currentRoute + : ({ "name": "navigationError", + "params": { "code": "routerNotConnected", + "message": qsTr("Navigation is not available.") } }) + property bool active: visible + + readonly property string currentRouteName: currentRoute && currentRoute.name + ? currentRoute.name : "" + readonly property string currentRouteKey: currentRoute && currentRoute.key + ? currentRoute.key : currentRouteName + + property string loadedRouteKey: "" + property var loadedRoute: ({}) + property Component requestedComponent: null + property bool routeSuspended: false + property string recoveryTitle: "" + property string recoveryMessage: "" + + function routeParams(route) { + return route && route.params ? route.params : ({}); + } + + function callHook(target, hookName, argument) { + if (!target || typeof target[hookName] !== "function") + return; + target[hookName](argument); + } + + function injectRoute(target, route) { + if (!target) + return; + if ("route" in target) + target.route = route; + if ("active" in target) + target.active = router.active; + } + + function deactivateLoadedPage() { + if (!pageLoader.item) + return; + if ("active" in pageLoader.item) + pageLoader.item.active = false; + callHook(pageLoader.item, "pageDeactivated", loadedRoute); + } + + function activateLoadedPage() { + if (!pageLoader.item) + return; + injectRoute(pageLoader.item, loadedRoute); + if (router.active) + callHook(pageLoader.item, "pageActivated", loadedRoute); + } + + function routeEnabled(name) { + return enabledRoutes && enabledRoutes.indexOf(name) !== -1; + } + + function routeImplemented(name) { + return name === "home" || name === "favorites"; + } + + function routeKnown(name) { + switch (name) { + case "login": + case "home": + case "favorites": + case "detail": + case "category": + case "library": + case "person": + case "search": + case "filtered": + case "season": + case "player": + case "settings": + case "manage": + case "navigationError": + return true; + default: + return false; + } + } + + function componentForRoute(route) { + const name = route && route.name ? route.name : ""; + if (!routeEnabled(name) || !routeImplemented(name)) + return null; + + switch (name) { + case "home": return homeComponent; + case "favorites": return favoritesComponent; + default: + return null; + } + } + + function setRecovery(route) { + const params = routeParams(route); + if (route && route.name === "navigationError") { + recoveryTitle = qsTr("Navigation error"); + recoveryMessage = params.message || params.code || qsTr("The page could not be opened."); + } else if (route && (route.name === "person" || route.name === "filtered" + || route.name === "player")) { + recoveryTitle = qsTr("Page migration in progress"); + recoveryMessage = qsTr("This page is not available in the QML shell yet."); + } else { + recoveryTitle = qsTr("Page not found"); + recoveryMessage = route && route.name + ? qsTr("Unknown route: %1").arg(route.name) + : qsTr("The route is empty or invalid."); + } + } + + function applyRoute(route) { + const nextRoute = route || ({}); + const nextKey = nextRoute.key || nextRoute.name || "invalid-route"; + + // A Widgets-owned route is presented above the Quick host. Keep the + // last QML page rendered underneath it, but suspend its lifecycle so + // it does not refresh or react while the legacy page is visible. + if (routeKnown(nextRoute.name) && nextRoute.name !== "navigationError" + && (!routeEnabled(nextRoute.name) || !routeImplemented(nextRoute.name))) { + if (!routeSuspended) + deactivateLoadedPage(); + routeSuspended = true; + return; + } + + const nextComponent = componentForRoute(nextRoute); + + if (nextKey === loadedRouteKey && nextComponent === requestedComponent + && pageLoader.item) { + loadedRoute = nextRoute; + injectRoute(pageLoader.item, loadedRoute); + if (routeSuspended) { + routeSuspended = false; + if (router.active) + callHook(pageLoader.item, "pageActivated", loadedRoute); + } else { + callHook(pageLoader.item, "pageRouteUpdated", loadedRoute); + } + return; + } + + if (!routeSuspended) + deactivateLoadedPage(); + routeSuspended = false; + loadedRoute = nextRoute; + loadedRouteKey = nextKey; + requestedComponent = nextComponent; + pageLoader.sourceComponent = null; + + if (nextComponent) { + pageLoader.sourceComponent = nextComponent; + } else { + setRecovery(nextRoute); + pageLoader.sourceComponent = recoveryComponent; + } + } + + function goBack() { + if (navigationController && navigationController.canGoBack) + return navigationController.back(); + return false; + } + + function goHome() { + if (!navigationController) + return false; + navigationController.home(); + return true; + } + + onCurrentRouteChanged: applyRoute(currentRoute) + onActiveChanged: { + if (!pageLoader.item) + return; + if (routeSuspended) { + if ("active" in pageLoader.item) + pageLoader.item.active = false; + return; + } + if ("active" in pageLoader.item) + pageLoader.item.active = active; + if (active) + callHook(pageLoader.item, "pageActivated", loadedRoute); + else + callHook(pageLoader.item, "pageDeactivated", loadedRoute); + } + Component.onCompleted: applyRoute(currentRoute) + + Loader { + id: pageLoader + anchors.fill: parent + asynchronous: false + + onLoaded: router.activateLoadedPage() + onStatusChanged: { + if (status !== Loader.Error) + return; + router.recoveryTitle = qsTr("Page failed to load"); + router.recoveryMessage = qsTr("The page component could not be created."); + sourceComponent = recoveryComponent; + } + } + + Component { id: homeComponent; DashboardPage { anchors.fill: parent } } + Component { id: favoritesComponent; FavoritesPage { anchors.fill: parent } } + + Component { + id: recoveryComponent + + Rectangle { + anchors.fill: parent + color: "#12151b" + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width - 48, 520) + spacing: 14 + + Label { + Layout.fillWidth: true + text: router.recoveryTitle + color: "#f3f4f6" + font.pixelSize: 24 + font.bold: true + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + text: router.recoveryMessage + color: "#b8bec9" + font.pixelSize: 15 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + Button { + text: qsTr("Back") + enabled: router.navigationController + && router.navigationController.canGoBack + onClicked: router.goBack() + } + + Button { + text: qsTr("Home") + enabled: router.navigationController !== null + onClicked: router.goHome() + } + } + } + } + } +} diff --git a/src/qEmbyApp/qml/SearchPage.qml b/src/qEmbyApp/qml/SearchPage.qml new file mode 100644 index 0000000..44a21cb --- /dev/null +++ b/src/qEmbyApp/qml/SearchPage.qml @@ -0,0 +1,313 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property var tabLabels: [qsTr("All"), qsTr("Movies"), qsTr("Shows"), + qsTr("Collections"), qsTr("People")] + readonly property int cardGap: Theme.spacingLg + readonly property int minimumCardWidth: searchPage.tileView ? 220 : 150 + readonly property int columnCount: Math.max(1, Math.floor((results.width + cardGap) / + (minimumCardWidth + cardGap))) + readonly property real cellWidth: results.width / columnCount + readonly property real cardWidth: Math.max(100, cellWidth - cardGap) + readonly property real imageHeight: searchPage.tileView ? cardWidth * 0.5625 : cardWidth * 1.5 + readonly property real cellHeight: imageHeight + 68 + cardGap + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.topMargin: 14 + Layout.bottomMargin: 10 + spacing: Theme.spacingLg + + Text { + text: searchPage.title + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + elide: Text.ElideRight + Layout.maximumWidth: 360 + } + + RowLayout { + spacing: Theme.spacingSm + + Repeater { + model: page.tabLabels + + delegate: Rectangle { + required property int index + required property string modelData + implicitWidth: tabText.implicitWidth + 24 + implicitHeight: 32 + radius: Theme.radiusMd + color: searchPage.currentTab === index + ? Theme.alpha(Theme.accent, 0.18) + : tabArea.containsMouse ? Theme.bgRaised : "transparent" + border.color: searchPage.currentTab === index + ? Theme.alpha(Theme.accent, 0.45) : "transparent" + + Text { + id: tabText + anchors.centerIn: parent + text: modelData + color: searchPage.currentTab === index ? Theme.accent : Theme.textBody + font.pixelSize: Theme.fontBody + font.weight: searchPage.currentTab === index ? Font.DemiBold : Font.Normal + } + + MouseArea { + id: tabArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: searchPage.currentTab = index + } + } + } + } + + Item { Layout.fillWidth: true } + + ThemedButton { + text: searchPage.tileView ? qsTr("Poster view") : qsTr("Wide view") + onClicked: searchPage.tileView = !searchPage.tileView + } + + Text { + text: searchPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignRight + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + GridView { + id: results + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 8 + anchors.topMargin: 16 + anchors.bottomMargin: 8 + clip: true + model: searchPage.resultsModel + cellWidth: page.cellWidth + cellHeight: page.cellHeight + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ThemedScrollBar { } + + delegate: Item { + id: cardCell + required property int index + required property string name + required property string subtitle + required property string itemType + required property string posterSource + required property string thumbSource + required property bool favorite + required property bool playable + width: results.cellWidth + height: results.cellHeight + + Rectangle { + id: card + width: page.cardWidth + height: page.imageHeight + 58 + radius: Theme.radiusLg + color: cardHover.hovered ? Theme.bgRaised : Theme.bgSurface + border.color: cardHover.hovered + ? Theme.alpha(Theme.accent, 0.45) : Theme.borderSoft + clip: true + + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + + Rectangle { + id: imageFrame + z: 1 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: page.imageHeight + color: Theme.bgRaised + + Image { + id: mediaImage + anchors.fill: parent + source: searchPage.tileView ? cardCell.thumbSource : cardCell.posterSource + sourceSize.width: Math.ceil(page.cardWidth * 1.5) + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + } + + Text { + anchors.centerIn: parent + visible: mediaImage.status !== Image.Ready + text: cardCell.itemType.length > 0 ? cardCell.itemType : qsTr("Media") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + Rectangle { + anchors.fill: parent + visible: cardHover.hovered + color: Theme.alpha(Theme.bgBase, 0.45) + } + + Row { + anchors.centerIn: parent + visible: cardHover.hovered + spacing: Theme.spacingSm + + Rectangle { + visible: cardCell.playable + width: 40; height: 40; radius: 20 + color: Theme.accent + Image { + anchors.centerIn: parent + width: 19; height: 19 + source: "image://adaptiveicon//svg/dark/quick-play.svg?d=" + (Theme.dark ? 1 : 0) + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: searchPage.requestPlay(cardCell.index) + } + } + + Rectangle { + width: 34; height: 34; radius: 17 + color: Theme.alpha(Theme.bgRaised, 0.92) + Image { + anchors.centerIn: parent + width: 17; height: 17 + source: "image://adaptiveicon//svg/dark/" + + (cardCell.favorite ? "heart-fill.svg" : "heart-outline.svg") + + "?d=" + (Theme.dark ? 1 : 0) + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: searchPage.requestFavorite(cardCell.index) + } + } + + Rectangle { + width: 34; height: 34; radius: 17 + color: Theme.alpha(Theme.bgRaised, 0.92) + Image { + anchors.centerIn: parent + width: 17; height: 17 + source: "image://adaptiveicon//svg/dark/more-line.svg?d=" + (Theme.dark ? 1 : 0) + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const p = mapToItem(page, width / 2, height) + searchPage.requestMore(cardCell.index, p.x, p.y) + } + } + } + } + } + + Column { + z: 1 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: imageFrame.bottom + anchors.margins: Theme.spacingSm + spacing: 2 + + Text { + width: parent.width + text: cardCell.name + color: Theme.textPrimary + font.pixelSize: Theme.fontBodyPlus + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: cardCell.subtitle + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + } + } + + MouseArea { + id: cardArea + anchors.fill: parent + z: 0 + cursorShape: Qt.PointingHandCursor + onClicked: searchPage.activate(cardCell.index) + } + + HoverHandler { + id: cardHover + acceptedDevices: PointerDevice.Mouse + } + } + } + } + + LoadingState { + anchors.centerIn: parent + width: Math.min(parent.width, 320) + visible: searchPage.loading + running: visible + title: qsTr("Searching…") + message: "" + } + + ErrorState { + anchors.centerIn: parent + width: Math.min(parent.width, 360) + visible: !searchPage.loading && searchPage.errorText.length > 0 + title: qsTr("Search failed") + message: searchPage.errorText + onRetryRequested: searchPage.retry() + } + + Column { + anchors.centerIn: parent + visible: !searchPage.loading && searchPage.errorText.length === 0 && results.count === 0 + spacing: Theme.spacingMd + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("No matching media") + color: Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + } + } + } + } +} diff --git a/src/qEmbyApp/qml/SeasonPage.qml b/src/qEmbyApp/qml/SeasonPage.qml new file mode 100644 index 0000000..9bde44d --- /dev/null +++ b/src/qEmbyApp/qml/SeasonPage.qml @@ -0,0 +1,262 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: page + + readonly property var sortLabels: [qsTr("Episode Number"), qsTr("Title"), qsTr("Premiere Date"), qsTr("Runtime")] + + function scrollToTop() { + if (episodeList.children.length > 0 && episodeList.children[0].positionViewAtBeginning) + episodeList.children[0].positionViewAtBeginning(); + + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(310, Math.max(244, page.height * 0.4)) + clip: true + + Image { + anchors.fill: parent + source: seasonPage.backdropSource + sourceSize.width: Math.ceil(width * 1.25) + asynchronous: true + fillMode: Image.PreserveAspectCrop + opacity: status === Image.Ready ? 0.38 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.animationNormal + } + + } + + } + + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgBase, 0.4) + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.62 + + gradient: Gradient { + GradientStop { + position: 0 + color: Theme.alpha(Theme.bgBase, 0) + } + + GradientStop { + position: 1 + color: Theme.bgBase + } + + } + + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingXl + anchors.topMargin: Theme.spacingLg + anchors.bottomMargin: Theme.spacingLg + spacing: Theme.spacingXl + + Rectangle { + Layout.preferredWidth: 152 + Layout.preferredHeight: 228 + Layout.alignment: Qt.AlignBottom + visible: page.width >= 700 + radius: Theme.radiusLg + color: Theme.bgRaised + border.color: Theme.borderSoft + clip: true + + Image { + id: posterImage + + anchors.fill: parent + source: seasonPage.posterSource + sourceSize.width: 320 + asynchronous: true + fillMode: Image.PreserveAspectCrop + } + + Text { + anchors.centerIn: parent + visible: posterImage.status === Image.Null || posterImage.status === Image.Error + text: "◇" + color: Theme.textMuted + font.pixelSize: Theme.fontDisplay + } + + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignBottom + spacing: Theme.spacingSm + + Text { + Layout.fillWidth: true + text: seasonPage.seriesTitle + color: Theme.textMuted + font.pixelSize: Theme.fontHeading + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: seasonPage.seasonTitle + color: Theme.textPrimary + font.pixelSize: Theme.fontHero + font.weight: Font.Bold + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + Layout.maximumHeight: 62 + text: seasonPage.overview + visible: text.length > 0 + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + elide: Text.ElideRight + maximumLineCount: 3 + } + + RowLayout { + spacing: Theme.spacingSm + + ThemedButton { + enabled: seasonPage.canPlay && !seasonPage.loading + text: "▶ " + seasonPage.playText + onClicked: seasonPage.requestPrimaryPlay() + } + + ThemedButton { + enabled: seasonPage.canPlay && !seasonPage.loading + text: qsTr("More…") + onClicked: seasonPage.requestPrimaryMore() + } + + ThemedButton { + enabled: seasonPage.seasonTitle.length > 0 + text: seasonPage.favorite ? qsTr("♥ Favorite") : qsTr("♡ Favorite") + Accessible.name: seasonPage.favorite ? qsTr("Remove season from favorites") : qsTr("Add season to favorites") + onClicked: seasonPage.requestSeasonFavorite() + } + + ThemedButton { + enabled: !seasonPage.loading + text: qsTr("Refresh") + onClicked: seasonPage.refresh() + } + + } + + } + + } + + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.topMargin: Theme.spacingSm + Layout.bottomMargin: Theme.spacingMd + spacing: Theme.spacingMd + + Text { + Layout.fillWidth: true + text: qsTr("Episodes") + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: Font.Bold + } + + Text { + text: seasonPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + } + + ThemedComboBox { + id: sortBox + + model: page.sortLabels + currentIndex: seasonPage.sortIndex + enabled: !seasonPage.loading + onActivated: function(index) { + seasonPage.sortIndex = index; + } + } + + ThemedButton { + enabled: !seasonPage.loading + text: seasonPage.descending ? qsTr("Descending") : qsTr("Ascending") + onClicked: seasonPage.descending = !seasonPage.descending + } + + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.borderSoft + } + + MediaList { + id: episodeList + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: Theme.spacingXl + Layout.rightMargin: Theme.spacingXl + Layout.topMargin: Theme.spacingMd + Layout.bottomMargin: Theme.spacingMd + model: seasonPage.episodesModel + loading: seasonPage.loading + errorText: seasonPage.errorText + rowHeight: 118 + emptyTitle: qsTr("No episodes") + emptyMessage: qsTr("This season does not have any available episodes.") + onActivated: function(itemId, modelIndex) { + seasonPage.activateEpisode(modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + seasonPage.requestEpisodePlay(modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + seasonPage.requestEpisodeFavorite(modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + seasonPage.requestEpisodeMore(modelIndex); + } + onRetryRequested: seasonPage.retry() + } + + } + +} diff --git a/src/qEmbyApp/qml/SettingsAboutPage.qml b/src/qEmbyApp/qml/SettingsAboutPage.qml new file mode 100644 index 0000000..d25e425 --- /dev/null +++ b/src/qEmbyApp/qml/SettingsAboutPage.qml @@ -0,0 +1,214 @@ +import QtQuick +import QtQuick.Layouts +import qEmby + +// 设置页「关于」(QML 试点页)。数据来自 context property `about` +// (AboutViewModel);LicensesDialog 仍是 Widgets 对话框,由 C++ 打开。 +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: Math.max(contentLayout.height + 30 + 40, height) + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: contentLayout + x: 16 + y: 30 + width: page.width - 32 + // 窗口够高时撑满以便上下留白拉伸,不够高时按内容高度滚动 + height: Math.max(implicitHeight, page.height - 30 - 40) + spacing: Theme.spacingLg + + Text { + text: qsTr("About") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 800 + } + + Item { Layout.fillHeight: true; Layout.preferredHeight: 1 } + + Image { + source: "qrc:/svg/qemby_logo.svg" + sourceSize: Qt.size(80, 80) + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: qsTr("qEmby") + color: Theme.textPrimary + font.pixelSize: Theme.fontHero + font.bold: true + font.letterSpacing: 1 + Layout.alignment: Qt.AlignHCenter + } + + Text { + text: qsTr("Version %1").arg(about.version) + color: Theme.textMuted + font.pixelSize: Theme.fontHeading + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 20 + } + + Text { + text: qsTr("© 2026 AlanHJ. All rights reserved.") + color: Theme.textBody + font.pixelSize: Theme.fontBodyPlus + Layout.alignment: Qt.AlignHCenter + } + + Text { + textFormat: Text.RichText + text: "" + qsTr("GitHub Repository") + + "  |  " + + qsTr("Report an Issue") + "" + color: Theme.textMuted + linkColor: Theme.accent + font.pixelSize: Theme.fontBodyPlus + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 10 + onLinkActivated: url => about.openUrl(url) + + HoverHandler { + enabled: parent.hoveredLink.length > 0 + cursorShape: Qt.PointingHandCursor + } + } + + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 40 + Layout.rightMargin: 40 + Layout.topMargin: 20 + Layout.bottomMargin: 20 + Layout.preferredHeight: 1 + color: Theme.alpha(Theme.dark ? "#FFFFFF" : "#000000", 0.12) + } + + Text { + text: qsTr("This project is open-source software built with Qt 6 and modern C++.\n" + + "Special thanks to the Emby and Jellyfin communities for their fantastic APIs.") + color: Theme.textMuted + font.pixelSize: Theme.fontBodyPlus + lineHeight: 1.5 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + Layout.bottomMargin: 5 + } + + // ===== 致谢 ===== + ColumnLayout { + spacing: Theme.spacingSm + 2 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 8 + + Text { + text: qsTr("Acknowledgements") + color: Theme.textBody + font.pixelSize: Theme.fontBody + font.weight: 700 + font.letterSpacing: 1 + Layout.alignment: Qt.AlignHCenter + } + Text { + text: qsTr("People who help qEmby reach more users.") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + Layout.alignment: Qt.AlignHCenter + } + + Flow { + spacing: 6 + Layout.alignment: Qt.AlignHCenter + + Repeater { + model: [ + { name: "Arkoth79", role: qsTr("French translation"), initials: "A" } + ] + + delegate: Rectangle { + id: thanksCard + required property var modelData + + width: cardRow.implicitWidth + 13 + height: cardRow.implicitHeight + 6 + radius: Theme.radiusXl + color: cardHover.hovered ? Theme.alpha(Theme.bgSurface, 0.58) + : Theme.alpha(Theme.bgBase, 0.44) + border.color: cardHover.hovered ? Theme.alpha(Theme.accentHover, 0.35) + : Theme.alpha(Theme.textMuted, 0.16) + + HoverHandler { id: cardHover } + + RowLayout { + id: cardRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + radius: 11 + // 与 QSS AboutThanksAvatar 相同的品牌渐变 + gradient: Gradient { + GradientStop { position: 0.0; color: "#F97316" } + GradientStop { position: 0.48; color: "#DB2777" } + GradientStop { position: 1.0; color: "#2563EB" } + } + + Text { + anchors.centerIn: parent + text: thanksCard.modelData.initials + color: "#FFFFFF" + font.pixelSize: Theme.fontCaption + font.weight: 700 + } + } + + ColumnLayout { + spacing: 1 + + Text { + text: thanksCard.modelData.name + color: Theme.textPrimary + font.pixelSize: Theme.fontSmall + font.weight: 700 + } + Text { + text: thanksCard.modelData.role + color: Theme.accentHover + font.pixelSize: Theme.fontCaption + } + } + } + } + } + } + } + + // ===== 开源协议按钮 ===== + Text { + text: qsTr("Open Source Licenses") + color: licenseHover.hovered ? Theme.accentHover : Theme.accent + font.pixelSize: Theme.fontBodyPlus + font.underline: licenseHover.hovered + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 25 + + HoverHandler { + id: licenseHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + onTapped: about.openLicenses() + } + } + + // 与旧版 addStretch(1)/addStretch(2) 的比例一致 + Item { Layout.fillHeight: true; Layout.preferredHeight: 2 } + } +} diff --git a/src/qEmbyApp/qml/SettingsAppearancePage.qml b/src/qEmbyApp/qml/SettingsAppearancePage.qml new file mode 100644 index 0000000..33a42cc --- /dev/null +++ b/src/qEmbyApp/qml/SettingsAppearancePage.qml @@ -0,0 +1,211 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby 1.0 +import "components" + +// Appearance settings page — migrated from pageappearance.cpp (Widgets → QML). +// All settings bind to SettingsViewModel properties via `settings` context property. +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: Math.max(contentLayout.height + 60, height) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + id: contentLayout + x: 20 + y: 30 + width: page.width - 40 + spacing: Theme.spacingLg + + // ── Page Title ─────────────────────────────────────────── + Text { + text: qsTr("Appearance") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 700 + } + + // ── 1. Theme Mode ──────────────────────────────────────── + SettingsCard { + title: qsTr("Theme Mode") + description: qsTr("Switch application theme (takes effect immediately)") + + ThemedComboBox { + id: themeModeCombo + Layout.fillWidth: true + property string settingsValue: settings.themeMode + property var valueMap: ["system", "light", "dark"] + + model: [qsTr("System Default"), qsTr("Light Mode"), qsTr("Dark Mode")] + currentIndex: valueMap.indexOf(settingsValue) + onActivated: settings.setThemeMode(valueMap[index]) + } + } + + // ── 2. Font Size ───────────────────────────────────────── + SettingsCard { + title: qsTr("Font Size") + description: qsTr("Adjust the overall font size of the interface") + + ThemedComboBox { + id: fontSizeCombo + Layout.fillWidth: true + property string settingsValue: settings.fontSize + property var valueMap: ["small", "medium", "large"] + + model: [qsTr("Small"), qsTr("Medium"), qsTr("Large")] + currentIndex: Math.max(0, valueMap.indexOf(settingsValue)) + onActivated: settings.setFontSize(valueMap[index]) + } + } + + // ── 3. Sidebar Position ────────────────────────────────── + SettingsCard { + title: qsTr("Sidebar Position") + description: qsTr("Place the navigation sidebar on the left or right side") + + ThemedComboBox { + id: sidebarPositionCombo + Layout.fillWidth: true + property string settingsValue: settings.sidebarPosition + property var valueMap: ["left", "right"] + + model: [qsTr("Left"), qsTr("Right")] + currentIndex: valueMap.indexOf(settingsValue) + onActivated: settings.setSidebarPosition(valueMap[index]) + } + } + + // ── 4. Pin Sidebar ─────────────────────────────────────── + SettingsCard { + title: qsTr("Pin Sidebar") + description: qsTr("Keep the sidebar always visible and integrated into the main layout") + + ThemedSwitch { + id: pinSidebarSwitch + checked: settings.sidebarPinned + onToggled: settings.setSidebarPinned(checked) + } + } + + // ── 5. Custom Sidebar ──────────────────────────────────── + SettingsCard { + title: qsTr("Custom Sidebar") + description: qsTr("Customize which items appear in the navigation sidebar") + + ThemedSwitch { + id: customSidebarSwitch + checked: settings.sidebarCustomEnabled + onToggled: settings.setSidebarCustomEnabled(checked) + } + } + + CollapsibleSection { + title: qsTr("Custom Sidebar Options") + expanded: settings.sidebarCustomEnabled + visible: settings.sidebarCustomEnabled + Layout.leftMargin: Theme.spacingMd + + SettingsCard { + title: qsTr("Hide Search Bar") + ThemedSwitch { + checked: settings.sidebarHideSearch + onToggled: settings.setSidebarHideSearch(checked) + } + } + + SettingsCard { + title: qsTr("Hide Home Item") + ThemedSwitch { + checked: settings.sidebarHideHome + onToggled: settings.setSidebarHideHome(checked) + } + } + + SettingsCard { + title: qsTr("Hide Favorites Item") + ThemedSwitch { + checked: settings.sidebarHideFavorites + onToggled: settings.setSidebarHideFavorites(checked) + } + } + } + + // ── 6. Startup Window State ────────────────────────────── + SettingsCard { + title: qsTr("Startup Window State") + description: qsTr("Choose window state when the application launches") + + ThemedComboBox { + id: windowStateCombo + Layout.fillWidth: true + property string settingsValue: settings.startupWindowState + property var valueMap: ["normal", "maximized"] + + model: [qsTr("Normal Window"), qsTr("Maximized")] + currentIndex: valueMap.indexOf(settingsValue) + onActivated: settings.setStartupWindowState(valueMap[index]) + } + } + + // ── 7. Reduce Animations ───────────────────────────────── + SettingsCard { + title: qsTr("Reduce Animations") + description: qsTr("Reduce page transitions and hover effects for better performance") + + ThemedSwitch { + checked: settings.uiAnimations + onToggled: settings.setUiAnimations(checked) + } + } + + // ── 8. Snapshot Navigation ─────────────────────────────── + SettingsCard { + title: qsTr("Snapshot Navigation") + description: qsTr("Use static screenshots during page transitions for smoother animation on complex views") + + ThemedSwitch { + checked: settings.snapshotNavigation + onToggled: settings.setSnapshotNavigation(checked) + } + } + + // ── 9. Search History ──────────────────────────────────── + SettingsCard { + title: qsTr("Enable Search History") + description: qsTr("Save recent searches locally for quick reuse") + + ThemedSwitch { + id: searchHistorySwitch + checked: settings.searchHistoryEnabled + onToggled: settings.setSearchHistoryEnabled(checked) + } + } + + CollapsibleSection { + title: qsTr("Search History Options") + expanded: settings.searchHistoryEnabled + visible: settings.searchHistoryEnabled + Layout.leftMargin: Theme.spacingMd + + SettingsCard { + title: qsTr("Enable Search Autocomplete") + ThemedSwitch { + checked: settings.searchAutocompleteEnabled + onToggled: settings.setSearchAutocompleteEnabled(checked) + } + } + } + + // ── Bottom spacer ──────────────────────────────────────── + Item { + Layout.fillWidth: true + Layout.preferredHeight: Theme.spacingLg + } + } +} diff --git a/src/qEmbyApp/qml/SettingsGeneralPage.qml b/src/qEmbyApp/qml/SettingsGeneralPage.qml new file mode 100644 index 0000000..6b4c563 --- /dev/null +++ b/src/qEmbyApp/qml/SettingsGeneralPage.qml @@ -0,0 +1,123 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby 1.0 +import "components" + +// Migrated from pagegeneral.cpp — General settings via SettingsViewModel. +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: Math.max(contentLayout.height + 60, height) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + id: contentLayout + x: 20 + y: 30 + width: page.width - 40 + spacing: Theme.spacingLg + + // ─── Page title ────────────────────────────────────── + Text { + text: qsTr("General") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 700 + } + + // ─── 1. Language ───────────────────────────────────── + SettingsCard { + title: qsTr("Language") + description: qsTr("App interface language (requires restart)") + + ThemedComboBox { + id: langCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("System Default"); value: "system" } + ListElement { text: qsTr("简体中文"); value: "zh_CN" } + ListElement { text: qsTr("English"); value: "en_US" } + ListElement { text: qsTr("French"); value: "fr_FR" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.language) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + settings.language = model.get(currentIndex).value; + } + } + } + } + + // ─── 2. Remember Last Server ───────────────────────── + SettingsCard { + title: qsTr("Remember Last Server") + description: qsTr("Automatically connect to the last used server on startup") + + ThemedSwitch { + checked: settings.rememberServer + onToggled: settings.rememberServer = checked + } + } + + // ─── 3. Close to Tray ──────────────────────────────── + SettingsCard { + title: qsTr("Close to Tray") + description: qsTr("Minimize to system tray instead of exiting the application") + + ThemedSwitch { + checked: settings.closeToTray + onToggled: settings.closeToTray = checked + } + } + + // ─── 4. Network Proxy ──────────────────────────────── + SettingsCard { + title: qsTr("Network Proxy") + description: qsTr("Proxy for the current server") + + RowLayout { + spacing: Theme.spacingSm + + Text { + text: qsTr("Configure proxy settings for Emby/Jellyfin connections") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + Layout.fillWidth: true + } + + ThemedButton { + text: qsTr("Configure...") + implicitHeight: 30 + + onClicked: { + // TODO: open ProxySettingsDialog once ported to QML + } + } + } + } + + // ─── 5. Enable Logging ─────────────────────────────── + SettingsCard { + title: qsTr("Enable Logging") + description: qsTr("Record debug information to a log file for troubleshooting") + + ThemedSwitch { + checked: settings.logEnable + onToggled: settings.logEnable = checked + } + } + } +} diff --git a/src/qEmbyApp/qml/SettingsLibraryPage.qml b/src/qEmbyApp/qml/SettingsLibraryPage.qml new file mode 100644 index 0000000..0275e5c --- /dev/null +++ b/src/qEmbyApp/qml/SettingsLibraryPage.qml @@ -0,0 +1,475 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby 1.0 +import "components" + +// Migrated from pagelibrary.cpp — Library settings via SettingsViewModel. +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: Math.max(contentLayout.height + 60, height) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + id: contentLayout + x: 20 + y: 30 + width: page.width - 40 + spacing: Theme.spacingLg + + // ─── Page title ────────────────────────────────────── + Text { + text: qsTr("Library") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 700 + } + + // ═══════════════════════════════════════════════════════ + // Home Screen Section + // ═══════════════════════════════════════════════════════ + + // ─── 1. Show Continue Watching ─────────────────────── + SettingsCard { + title: qsTr("Show Continue Watching") + description: qsTr("Display unfinished media on the home screen") + + ThemedSwitch { + checked: settings.showContinueWatching + onToggled: settings.showContinueWatching = checked + } + } + + // ─── 2. Show Latest Added ──────────────────────────── + SettingsCard { + title: qsTr("Show Latest Added") + description: qsTr("Display recently added media on the home screen") + + ThemedSwitch { + checked: settings.showLatestAdded + onToggled: settings.showLatestAdded = checked + } + } + + // ─── 3. Show Recommended ───────────────────────────── + SettingsCard { + title: qsTr("Show Recommended") + description: qsTr("Display recommended media on the home screen") + + ThemedSwitch { + checked: settings.showRecommended + onToggled: settings.showRecommended = checked + } + } + + // ─── 4. Show Media Libraries ───────────────────────── + SettingsCard { + title: qsTr("Show Media Libraries") + description: qsTr("Display media libraries on the home screen") + + ThemedSwitch { + checked: settings.showMediaLibraries + onToggled: settings.showMediaLibraries = checked + } + } + + // ─── 5. Show Each Library ──────────────────────────── + SettingsCard { + title: qsTr("Show Each Library") + description: qsTr("Display each library section individually on the home screen") + + ThemedSwitch { + checked: settings.showEachLibrary + onToggled: settings.showEachLibrary = checked + } + } + + // ─── 6. Show Completed Watching ────────────────────── + SettingsCard { + title: qsTr("Show Completed Watching") + description: qsTr("Display completed media on the home screen") + + ThemedSwitch { + checked: settings.showCompletedWatching + onToggled: settings.showCompletedWatching = checked + } + } + + // ═══════════════════════════════════════════════════════ + // Request Limits + // ═══════════════════════════════════════════════════════ + + // ─── 7. Continue Watching Request Limit ────────────── + SettingsCard { + title: qsTr("Continue Watching Request Limit") + description: qsTr("Full list request count. 0 means unlimited; home sections use the same value up to 50.") + + RowLayout { + spacing: Theme.spacingSm + + ThemedSpinBox { + from: 0 + to: 9999 + value: settings.continueWatchingRequestLimit + onValueModified: settings.continueWatchingRequestLimit = value + } + + Text { + text: qsTr("0 = Unlimited") + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + } + } + } + + // ─── 8. Latest Media Request Limit ─────────────────── + SettingsCard { + title: qsTr("Latest Media Request Limit") + description: qsTr("Full list request count. 0 means unlimited; home sections use the same value up to 50.") + + RowLayout { + spacing: Theme.spacingSm + + ThemedSpinBox { + from: 0 + to: 9999 + value: settings.latestMediaRequestLimit + onValueModified: settings.latestMediaRequestLimit = value + } + + Text { + text: qsTr("0 = Unlimited") + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + } + } + } + + // ─── 9. Recommended Request Limit ──────────────────── + SettingsCard { + title: qsTr("Recommended Request Limit") + description: qsTr("Full list request count. 0 means unlimited; home sections use the same value up to 50.") + + RowLayout { + spacing: Theme.spacingSm + + ThemedSpinBox { + from: 0 + to: 9999 + value: settings.recommendedRequestLimit + onValueModified: settings.recommendedRequestLimit = value + } + + Text { + text: qsTr("0 = Unlimited") + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + } + } + } + + // ─── 10. Completed Watching Request Limit ──────────── + SettingsCard { + title: qsTr("Completed Watching Request Limit") + description: qsTr("Full list request count. 0 means unlimited; home sections use the same value up to 50.") + + RowLayout { + spacing: Theme.spacingSm + + ThemedSpinBox { + from: 0 + to: 9999 + value: settings.completedWatchingRequestLimit + onValueModified: settings.completedWatchingRequestLimit = value + } + + Text { + text: qsTr("0 = Unlimited") + color: Theme.textMuted + font.pixelSize: Theme.fontCaption + } + } + } + + // ═══════════════════════════════════════════════════════ + // Display Options + // ═══════════════════════════════════════════════════════ + + // ─── 11. Adaptive Images ───────────────────────────── + SettingsCard { + title: qsTr("Adaptive Images") + description: qsTr("When a specific image type is missing, automatically use another available type as fallback") + + ThemedSwitch { + checked: settings.adaptiveImages + onToggled: settings.adaptiveImages = checked + } + } + + // ─── 12. Show Media Tooltips ───────────────────────── + SettingsCard { + title: qsTr("Show Media Tooltips") + description: qsTr("Show media details in tooltips when hovering over media cards") + + ThemedSwitch { + checked: settings.showMediaTooltips + onToggled: settings.showMediaTooltips = checked + } + } + + // ─── 13. Show Favorite Folders ─────────────────────── + SettingsCard { + title: qsTr("Show Favorite Folders") + description: qsTr("Display favorite folders in the favorites page") + + ThemedSwitch { + checked: settings.showFavoriteFolders + onToggled: settings.showFavoriteFolders = checked + } + } + + // ─── 14. Default Library View ──────────────────────── + SettingsCard { + title: qsTr("Default Library View") + description: qsTr("Choose how media items are presented by default") + + ThemedComboBox { + id: viewCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Poster Wall"); value: "poster" } + ListElement { text: qsTr("List View"); value: "tile" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.defaultLibraryView) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + settings.defaultLibraryView = model.get(currentIndex).value; + } + } + } + } + + // ─── 15. Image Quality ─────────────────────────────── + SettingsCard { + title: qsTr("Image Quality") + description: qsTr("Set image download quality (higher uses more bandwidth)") + + ThemedComboBox { + id: qualityCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Low"); value: "low" } + ListElement { text: qsTr("Medium"); value: "medium" } + ListElement { text: qsTr("High"); value: "high" } + ListElement { text: qsTr("Original"); value: "original" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.imageQuality) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + settings.imageQuality = model.get(currentIndex).value; + } + } + } + } + + // ─── 16. Shimmer Loading Animation ─────────────────── + SettingsCard { + title: qsTr("Shimmer Loading Animation") + description: qsTr("Show shimmer skeleton animation while loading dashboard content") + + ThemedSwitch { + checked: settings.shimmerAnimation + onToggled: settings.shimmerAnimation = checked + } + } + + // ═══════════════════════════════════════════════════════ + // Home Section Order + // ═══════════════════════════════════════════════════════ + + CollapsibleSection { + title: qsTr("Custom Home Section Order") + expanded: customHomeOrderSwitch.checked + + SettingsCard { + title: qsTr("Custom Home Section Order") + description: qsTr("Override the default order of the main home screen sections") + + ThemedSwitch { + id: customHomeOrderSwitch + checked: false + } + } + + // Placeholder — full DashboardSectionOrderWidget is complex C++ + // drag-and-drop. Will be implemented in a future phase. + SettingsCard { + visible: customHomeOrderSwitch.checked + + ColumnLayout { + spacing: Theme.spacingSm + + Text { + text: qsTr("Drag-and-drop section reordering will be available in a future update.") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + } + + // ═══════════════════════════════════════════════════════ + // Cache Management + // ═══════════════════════════════════════════════════════ + + Text { + text: qsTr("Cache Management") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 600 + Layout.topMargin: Theme.spacingSm + } + + // ─── 18. Recommendation Cache Duration ─────────────── + SettingsCard { + title: qsTr("Recommendation Cache Duration") + description: qsTr("Set how long to cache recommended content before refreshing") + + ThemedComboBox { + id: dataCacheCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("6 Hours"); value: "6" } + ListElement { text: qsTr("12 Hours"); value: "12" } + ListElement { text: qsTr("1 Day"); value: "24" } + ListElement { text: qsTr("3 Days"); value: "72" } + ListElement { text: qsTr("7 Days"); value: "168" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.dataCacheDuration) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + settings.dataCacheDuration = model.get(currentIndex).value; + } + } + } + } + + // ─── 19. Clear Recommendation Cache ────────────────── + SettingsCard { + title: qsTr("Clear Recommendation Cache") + description: qsTr("Clear cached recommendations and refresh on next visit") + + RowLayout { + spacing: Theme.spacingSm + + Text { + text: qsTr("Cache") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + Layout.fillWidth: true + } + + ThemedButton { + text: qsTr("Clear") + implicitHeight: 30 + + onClicked: { + // TODO: call mediaService->clearRecommendCache() via bridge + } + } + } + } + + // ─── 20. Image Cache Duration ──────────────────────── + SettingsCard { + title: qsTr("Image Cache Duration") + description: qsTr("How long to keep downloaded images in local cache") + + ThemedComboBox { + id: imgCacheCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("1 Day"); value: "1" } + ListElement { text: qsTr("3 Days"); value: "3" } + ListElement { text: qsTr("7 Days"); value: "7" } + ListElement { text: qsTr("14 Days"); value: "14" } + ListElement { text: qsTr("30 Days"); value: "30" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.imageCacheDuration) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) { + settings.imageCacheDuration = model.get(currentIndex).value; + } + } + } + } + + // ─── 21. Clear Image Cache ─────────────────────────── + SettingsCard { + title: qsTr("Clear Image Cache") + description: qsTr("Remove all cached images from disk") + + RowLayout { + spacing: Theme.spacingSm + + Text { + text: qsTr("Cache") + color: Theme.textMuted + font.pixelSize: Theme.fontBody + Layout.fillWidth: true + } + + ThemedButton { + text: qsTr("Clear") + implicitHeight: 30 + + onClicked: { + // TODO: clear image cache dir via bridge + } + } + } + } + } +} diff --git a/src/qEmbyApp/qml/SettingsPlayerPage.qml b/src/qEmbyApp/qml/SettingsPlayerPage.qml new file mode 100644 index 0000000..8276a4e --- /dev/null +++ b/src/qEmbyApp/qml/SettingsPlayerPage.qml @@ -0,0 +1,918 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby 1.0 +import "components" + +// Migrated from pageplayer.cpp — Player settings via SettingsViewModel. +Flickable { + id: page + + clip: true + contentWidth: width + contentHeight: Math.max(contentLayout.height + 60, height) + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ThemedScrollBar {} + + ColumnLayout { + id: contentLayout + x: 20 + y: 30 + width: page.width - 40 + spacing: Theme.spacingLg + + // ─── Page title ────────────────────────────────────── + Text { + text: qsTr("Player") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 700 + } + + // ═══════════════════════════════════════════════════════ + // Section 1 — Video & Audio + // ═══════════════════════════════════════════════════════ + + // ─── 1. Hardware Decoding ──────────────────────────── + SettingsCard { + title: qsTr("Hardware Decoding") + description: qsTr("Select GPU acceleration method for video decoding") + + ThemedComboBox { + id: hwDecCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Auto"); value: "auto-copy" } + ListElement { text: qsTr("VAAPI"); value: "vaapi-copy" } + ListElement { text: qsTr("VDPAU"); value: "vdpau-copy" } + ListElement { text: qsTr("CUDA (NVIDIA)"); value: "cuda-copy" } + ListElement { text: qsTr("None (Software)"); value: "no" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.hwDecoding) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.hwDecoding = model.get(currentIndex).value; + } + } + } + + // ─── 2. Video Output Driver ────────────────────────── + SettingsCard { + title: qsTr("Video Output Driver") + description: qsTr("Rendering backend for embedded player (Render API mode)") + + ThemedComboBox { + id: voCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("libmpv (Embedded)"); value: "libmpv" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.videoOutput) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.videoOutput = model.get(currentIndex).value; + } + } + } + + // ─── 3. Video Sync Mode ────────────────────────────── + SettingsCard { + title: qsTr("Video Sync Mode") + description: qsTr("Method for synchronizing video frames to display") + + ThemedComboBox { + id: videoSyncCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Audio"); value: "audio" } + ListElement { text: qsTr("Display Resample (Default)"); value: "display-resample" } + ListElement { text: qsTr("Display VSync"); value: "display-vdrop" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.videoSync) + return i; + } + return 1; // default: display-resample + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.videoSync = model.get(currentIndex).value; + } + } + } + + // ─── 4. Tone Mapping ───────────────────────────────── + SettingsCard { + title: qsTr("Tone Mapping") + description: qsTr("HDR to SDR tone mapping algorithm") + + ThemedComboBox { + id: toneMapCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Auto"); value: "auto" } + ListElement { text: qsTr("Mobius"); value: "mobius" } + ListElement { text: qsTr("BT.2390"); value: "bt.2390" } + ListElement { text: qsTr("Hable"); value: "hable" } + ListElement { text: qsTr("Reinhard"); value: "reinhard" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.toneMapping) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.toneMapping = model.get(currentIndex).value; + } + } + } + + // ─── 5. Preferred Audio Language ────────────────────── + SettingsCard { + title: qsTr("Preferred Audio Language") + description: qsTr("Rules are matched in order; choose presets or type custom keywords and press Enter") + + ThemedTextField { + id: audioLangField + Layout.fillWidth: true + placeholderText: qsTr("e.g., chi,eng,jpn") + text: settings.audioLang + onEditingFinished: settings.audioLang = text + } + } + + // ─── 6. Preferred Subtitle Language ─────────────────── + SettingsCard { + title: qsTr("Preferred Subtitle Language") + description: qsTr("Rules are matched in order; choose presets or type custom keywords and press Enter") + + ThemedTextField { + id: subLangField + Layout.fillWidth: true + placeholderText: qsTr("e.g., chi,eng") + text: settings.subLang + onEditingFinished: settings.subLang = text + } + } + + // ─── 7. Preferred Video Version ────────────────────── + SettingsCard { + title: qsTr("Preferred Video Version") + description: qsTr("Choose keyword or sort rules in order; type custom keywords and press Enter") + + ThemedTextField { + id: preferredVersionField + Layout.fillWidth: true + placeholderText: qsTr("e.g., original,2160p,1080p") + text: settings.preferredVersion + onEditingFinished: settings.preferredVersion = text + } + } + + // ─── 8. Continuous Playback ────────────────────────── + SettingsCard { + title: qsTr("Continuous Playback") + description: qsTr("Automatically play the next episode or media when playback ends") + + ThemedSwitch { + checked: settings.continuousPlay + onToggled: settings.continuousPlay = checked + } + } + + // ─── 9. Long Press Left/Right ──────────────────────── + SettingsCard { + title: qsTr("Long Press Left/Right") + description: qsTr("Enable long press actions for left/right keys during playback") + + ThemedSwitch { + checked: settings.longPressSeek + onToggled: settings.longPressSeek = checked + } + } + + CollapsibleSection { + title: qsTr("Long Press Options") + expanded: settings.longPressSeek + Layout.fillWidth: true + + SettingsCard { + title: qsTr("Seek Step") + description: qsTr("Number of seconds to skip when pressing forward or rewind") + + ThemedComboBox { + id: seekStepCombo + implicitWidth: 160 + + model: ListModel { + ListElement { text: qsTr("3 Seconds"); value: "3" } + ListElement { text: qsTr("5 Seconds"); value: "5" } + ListElement { text: qsTr("10 Seconds"); value: "10" } + ListElement { text: qsTr("15 Seconds"); value: "15" } + ListElement { text: qsTr("30 Seconds"); value: "30" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.seekStep) + return i; + } + return 2; // default: 10 + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.seekStep = model.get(currentIndex).value; + } + } + } + + SettingsCard { + title: qsTr("Long Press Trigger Time") + description: qsTr("Set how long left/right keys must be held before the long press action starts") + + ThemedComboBox { + id: longPressTriggerCombo + implicitWidth: 160 + + model: ListModel { + ListElement { text: qsTr("300 ms"); value: "300" } + ListElement { text: qsTr("500 ms"); value: "500" } + ListElement { text: qsTr("800 ms"); value: "800" } + ListElement { text: qsTr("1000 ms"); value: "1000" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.longPressTriggerMs) + return i; + } + return 1; // default: 500 + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.longPressTriggerMs = model.get(currentIndex).value; + } + } + } + + SettingsCard { + title: qsTr("Long Press Mode") + description: qsTr("Choose whether holding left/right keys performs time jump seek or temporary speed playback") + + ThemedComboBox { + id: longPressModeCombo + implicitWidth: 160 + + model: ListModel { + ListElement { text: qsTr("Time Jump Playback"); value: "seek" } + ListElement { text: qsTr("Speed Playback"); value: "speed" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.longPressMode) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.longPressMode = model.get(currentIndex).value; + } + } + } + } + + // ─── 10. Mouse Edge Long Press ─────────────────────── + SettingsCard { + title: qsTr("Mouse Edge Long Press") + description: qsTr("Hold the left or right side of the playback area for speed rewind or fast forward") + + ThemedSwitch { + checked: settings.mouseEdgeLongPress + onToggled: settings.mouseEdgeLongPress = checked + } + } + + // ─── 11. Media Switcher Position ───────────────────── + SettingsCard { + title: qsTr("Media Switcher Position") + description: qsTr("Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD") + + ThemedComboBox { + id: mediaSwitcherCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Right Sidebar"); value: "sidebar" } + ListElement { text: qsTr("Bottom HUD"); value: "hud" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.mediaSwitcherMode) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.mediaSwitcherMode = model.get(currentIndex).value; + } + } + } + + // ─── 12. Click to Pause ────────────────────────────── + SettingsCard { + title: qsTr("Click to Pause") + description: qsTr("Single click on the video area to toggle play/pause") + + ThemedSwitch { + checked: settings.clickToPause + onToggled: settings.clickToPause = checked + } + } + + // ─── 13. Auto Skip Intro ───────────────────────────── + SettingsCard { + title: qsTr("Auto Skip Intro") + description: qsTr("Automatically skip the intro sequence when playback starts") + + ThemedSwitch { + checked: settings.skipIntro + onToggled: settings.skipIntro = checked + } + } + + // ─── 14. Auto Skip Outro ───────────────────────────── + SettingsCard { + title: qsTr("Auto Skip Outro") + description: qsTr("Automatically skip the ending credits during playback") + + ThemedSwitch { + checked: settings.skipOutro + onToggled: settings.skipOutro = checked + } + } + + // ─── 15. Independent Player Window ─────────────────── + SettingsCard { + title: qsTr("Independent Player Window") + description: qsTr("Open video in a separate detached window") + + ThemedSwitch { + checked: settings.independentWindow + onToggled: settings.independentWindow = checked + } + } + + // ─── 16. Volume Normalization ──────────────────────── + SettingsCard { + title: qsTr("Volume Normalization") + description: qsTr("Automatically adjust volume to prevent sudden loud noises") + + ThemedSwitch { + checked: settings.volNormalization + onToggled: settings.volNormalization = checked + } + } + + // ─── 17. Use Custom mpv.conf ───────────────────────── + SettingsCard { + title: qsTr("Use Custom mpv.conf") + description: qsTr("Load settings from an external mpv.conf configuration file") + + ThemedSwitch { + id: mpvConfSwitch + checked: settings.useMpvConf + onToggled: settings.useMpvConf = checked + } + } + + CollapsibleSection { + title: qsTr("mpv.conf") + expanded: settings.useMpvConf + Layout.fillWidth: true + + SettingsCard { + description: qsTr("Config path: ~//mpv/mpv.conf") + + ThemedButton { + text: qsTr("Open File") + implicitHeight: 30 + onClicked: { + // TODO: open mpv.conf via QDesktopServices when ported to QML + } + } + } + } + + // ═══════════════════════════════════════════════════════ + // Section 2 — Danmaku + // ═══════════════════════════════════════════════════════ + Text { + text: qsTr("Danmaku") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 700 + Layout.topMargin: Theme.spacingMd + } + + // ─── 18. Enable Danmaku ────────────────────────────── + SettingsCard { + title: qsTr("Enable Danmaku") + description: qsTr("Display streaming danmaku in the embedded player") + + ThemedSwitch { + id: danmakuSwitch + checked: settings.danmakuEnabled + onToggled: settings.danmakuEnabled = checked + } + } + + // ─── 19. Danmaku Renderer ──────────────────────────── + SettingsCard { + title: qsTr("Danmaku Renderer") + description: qsTr("Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer") + visible: danmakuSwitch.checked + + ThemedComboBox { + id: danmakuRendererCombo + implicitWidth: 200 + + model: ListModel { + ListElement { text: qsTr("ASS Subtitle Track"); value: "ass-track" } + ListElement { text: qsTr("Native Smooth Renderer"); value: "native-smooth" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.danmakuRenderer) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.danmakuRenderer = model.get(currentIndex).value; + } + } + } + + // ─── 20. Danmaku Source ────────────────────────────── + SettingsCard { + title: qsTr("Danmaku Source") + description: qsTr("Choose whether local danmaku files or online danmaku should be preferred during matching") + visible: danmakuSwitch.checked + + ThemedComboBox { + id: danmakuSourceCombo + implicitWidth: 180 + + model: ListModel { + ListElement { text: qsTr("Prefer Local"); value: "prefer-local" } + ListElement { text: qsTr("Prefer Online"); value: "prefer-online" } + ListElement { text: qsTr("Online Only"); value: "online-only" } + ListElement { text: qsTr("Local Only"); value: "local-only" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.danmakuSourceMode) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.danmakuSourceMode = model.get(currentIndex).value; + } + } + } + + // ─── 21. Auto Load Danmaku ─────────────────────────── + SettingsCard { + title: qsTr("Auto Load Danmaku") + description: qsTr("Automatically search and load danmaku when playback starts") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuAutoLoad + onToggled: settings.danmakuAutoLoad = checked + } + } + + // ─── 22. Auto Match Danmaku ────────────────────────── + SettingsCard { + title: qsTr("Auto Match Danmaku") + description: qsTr("Automatically choose the best danmaku match when no cached manual result exists") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuAutoMatch + onToggled: settings.danmakuAutoMatch = checked + } + } + + // ─── 23. Load Related Comments ─────────────────────── + SettingsCard { + title: qsTr("Load Related Comments") + description: qsTr("Include related third-party comment sources when the provider supports it") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuWithRelated + onToggled: settings.danmakuWithRelated = checked + } + } + + // ─── 24. Dual Subtitle Mode ────────────────────────── + SettingsCard { + title: qsTr("Dual Subtitle Mode") + description: qsTr("Keep regular subtitles visible alongside danmaku when possible") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuDualSubtitle + onToggled: settings.danmakuDualSubtitle = checked + } + } + + // ─── 25. Danmaku Opacity ───────────────────────────── + SettingsCard { + title: qsTr("Danmaku Opacity") + description: qsTr("Control the transparency of rendered danmaku") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: opacitySlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuOpacity + onMoved: settings.danmakuOpacity = value + Layout.fillWidth: true + } + Text { + text: opacitySlider.value.toFixed(0) + "%" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 40 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 26. Danmaku Size ──────────────────────────────── + SettingsCard { + title: qsTr("Danmaku Size") + description: qsTr("Scale the font size used for danmaku rendering") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: sizeSlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuFontScale + onMoved: settings.danmakuFontScale = value + Layout.fillWidth: true + } + Text { + text: sizeSlider.value.toFixed(0) + "%" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 40 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 27. Display Area ──────────────────────────────── + SettingsCard { + title: qsTr("Display Area") + description: qsTr("Limit how much of the screen height danmaku can occupy") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: areaSlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuAreaPercent + onMoved: settings.danmakuAreaPercent = value + Layout.fillWidth: true + } + Text { + text: areaSlider.value.toFixed(0) + "%" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 40 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 28. Danmaku Density ───────────────────────────── + SettingsCard { + title: qsTr("Danmaku Density") + description: qsTr("Reduce or increase the number of simultaneous lanes") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: densitySlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuDensity + onMoved: settings.danmakuDensity = value + Layout.fillWidth: true + } + Text { + text: densitySlider.value.toFixed(0) + "%" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 40 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 29. Danmaku Speed ─────────────────────────────── + SettingsCard { + title: qsTr("Danmaku Speed") + description: qsTr("Adjust how quickly scrolling danmaku travels across the screen") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: speedSlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuSpeedScale + onMoved: settings.danmakuSpeedScale = value + Layout.fillWidth: true + } + Text { + text: speedSlider.value.toFixed(0) + "%" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 40 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 30. Danmaku Offset ────────────────────────────── + SettingsCard { + title: qsTr("Danmaku Offset") + description: qsTr("Shift danmaku timing earlier or later to align with playback") + visible: danmakuSwitch.checked + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + ThemedSlider { + id: offsetSlider + from: 0; to: 100; stepSize: 1 + value: settings.danmakuOffsetMs + onMoved: settings.danmakuOffsetMs = value + Layout.fillWidth: true + } + Text { + text: offsetSlider.value.toFixed(0) + " ms" + color: Theme.textBody + font.pixelSize: Theme.fontSmall + Layout.minimumWidth: 50 + horizontalAlignment: Text.AlignRight + } + } + } + + // ─── 31. Hide Scrolling Danmaku ────────────────────── + SettingsCard { + title: qsTr("Hide Scrolling Danmaku") + description: qsTr("Hide standard right-to-left scrolling comments") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuHideScroll + onToggled: settings.danmakuHideScroll = checked + } + } + + // ─── 32. Hide Top Danmaku ──────────────────────────── + SettingsCard { + title: qsTr("Hide Top Danmaku") + description: qsTr("Hide fixed danmaku positioned at the top of the screen") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuHideTop + onToggled: settings.danmakuHideTop = checked + } + } + + // ─── 33. Hide Bottom Danmaku ───────────────────────── + SettingsCard { + title: qsTr("Hide Bottom Danmaku") + description: qsTr("Hide fixed danmaku positioned at the bottom of the screen") + visible: danmakuSwitch.checked + + ThemedSwitch { + checked: settings.danmakuHideBottom + onToggled: settings.danmakuHideBottom = checked + } + } + + // ─── 34. Blocked Keywords ──────────────────────────── + SettingsCard { + title: qsTr("Blocked Keywords") + description: qsTr("Filter out danmaku containing these keywords") + visible: danmakuSwitch.checked + + ThemedTextField { + Layout.fillWidth: true + placeholderText: qsTr("Enter keywords separated by commas") + text: settings.danmakuBlockedKeywords + onEditingFinished: settings.danmakuBlockedKeywords = text + } + } + + // ─── 35. Danmaku Cache Duration ────────────────────── + SettingsCard { + title: qsTr("Danmaku Cache Duration") + description: qsTr("Reuse online match results, cached comment payloads, and generated ASS files for this long") + visible: danmakuSwitch.checked + + ThemedComboBox { + id: cacheDurationCombo + implicitWidth: 160 + + model: ListModel { + ListElement { text: qsTr("6 Hours"); value: "6" } + ListElement { text: qsTr("12 Hours"); value: "12" } + ListElement { text: qsTr("1 Day"); value: "24" } + ListElement { text: qsTr("3 Days"); value: "72" } + ListElement { text: qsTr("7 Days"); value: "168" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.danmakuCacheHours) + return i; + } + return 3; // default: 24 (1 Day) + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.danmakuCacheHours = model.get(currentIndex).value; + } + } + } + + // ═══════════════════════════════════════════════════════ + // Section 3 — External Player + // ═══════════════════════════════════════════════════════ + Text { + text: qsTr("External Player") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 700 + Layout.topMargin: Theme.spacingMd + } + + // ─── 36. Enable External Player ────────────────────── + SettingsCard { + title: qsTr("Enable External Player") + description: { + var players = ["MPV", "VLC", "MPC-Qt"]; + return qsTr("Supported players on this platform: %1").arg(players.join(", ")); + } + + ThemedSwitch { + id: extPlayerSwitch + checked: settings.extPlayerEnable + onToggled: settings.extPlayerEnable = checked + } + } + + // ─── 37. Player Selection ──────────────────────────── + SettingsCard { + title: qsTr("Player") + description: qsTr("Choose which external player to launch") + visible: extPlayerSwitch.checked + + ThemedComboBox { + id: extPlayerCombo + implicitWidth: 200 + + model: ListModel { + ListElement { text: qsTr("MPV"); value: "mpv" } + ListElement { text: qsTr("VLC"); value: "vlc" } + ListElement { text: qsTr("MPC-Qt"); value: "mpc-qt" } + } + + currentIndex: { + for (var i = 0; i < model.count; i++) { + if (model.get(i).value === settings.extPlayerPath) + return i; + } + return 0; + } + + onCurrentIndexChanged: { + if (currentIndex >= 0) + settings.extPlayerPath = model.get(currentIndex).value; + } + } + } + + // ─── 38. Arguments ─────────────────────────────────── + SettingsCard { + title: qsTr("Arguments") + description: qsTr("Additional command-line arguments passed to the external player") + visible: extPlayerSwitch.checked + + ThemedTextField { + Layout.fillWidth: true + placeholderText: qsTr("e.g., --fullscreen --volume=100") + text: settings.extPlayerArgs + onEditingFinished: settings.extPlayerArgs = text + } + } + + // ─── 39. Quick Play with External Player ───────────── + SettingsCard { + title: qsTr("Quick Play with External Player") + description: qsTr("Use external player for quick play actions on cards and menus") + visible: extPlayerSwitch.checked + + ThemedSwitch { + checked: settings.extPlayerQuickPlay + onToggled: settings.extPlayerQuickPlay = checked + } + } + + // ─── 40. Direct Stream ─────────────────────────────── + SettingsCard { + title: qsTr("Direct Stream") + description: qsTr("Bypass server proxy and stream directly from source. May not work on external networks") + visible: extPlayerSwitch.checked + + ThemedSwitch { + checked: settings.extPlayerDirectStream + onToggled: settings.extPlayerDirectStream = checked + } + } + + // ─── Bottom spacer ─────────────────────────────────── + Item { Layout.fillHeight: true; Layout.preferredHeight: 1 } + } +} diff --git a/src/qEmbyApp/qml/components/CollapsibleSection.qml b/src/qEmbyApp/qml/components/CollapsibleSection.qml new file mode 100644 index 0000000..aa9862d --- /dev/null +++ b/src/qEmbyApp/qml/components/CollapsibleSection.qml @@ -0,0 +1,110 @@ +import QtQuick +import QtQuick.Layouts + +// Expandable section with a tappable header and animated content reveal. +// Uses Theme.animationNormal for transition duration. +ColumnLayout { + id: root + + // ─── Public API ────────────────────────────────────────── + property string title: "" + property bool expanded: true + default property alias content: contentColumn.data + + spacing: 0 + + // ─── Header ────────────────────────────────────────────── + Rectangle { + id: header + + Layout.fillWidth: true + implicitHeight: headerRow.implicitHeight + Theme.spacingMd * 2 + radius: expanded ? Theme.radiusLg : Theme.radiusSm + color: headerArea.containsMouse + ? Theme.bgRaised + : Theme.bgSurface + + Behavior on radius { + NumberAnimation { + duration: Theme.animationFast + } + } + + RowLayout { + id: headerRow + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + + // Chevron indicator + Text { + id: chevron + + text: "\u25B6" // BLACK RIGHT-POINTING SMALL TRIANGLE + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + rotation: root.expanded ? 90 : 0 + + Behavior on rotation { + RotationAnimation { + duration: Theme.animationNormal + direction: RotationAnimation.Shortest + } + } + } + + // Section title + Text { + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: 600 + Layout.fillWidth: true + } + + // Expand/collapse toggle area + Text { + text: root.expanded ? "\u2212" : "+" // − or + + color: Theme.textMuted + font.pixelSize: Theme.fontHeading + font.weight: 300 + } + } + + MouseArea { + id: headerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + } + } + + // ─── Content area with animated height ─────────────────── + Item { + id: contentWrapper + + Layout.fillWidth: true + Layout.topMargin: root.expanded ? Theme.spacingXs : 0 + // Height is driven by the contentColumn; animation handled by Behavior + implicitHeight: root.expanded ? contentColumn.implicitHeight : 0 + clip: true + + Behavior on implicitHeight { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.InOutQuad + } + } + + ColumnLayout { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingMd + spacing: Theme.spacingSm + } + } +} diff --git a/src/qEmbyApp/qml/components/ErrorState.qml b/src/qEmbyApp/qml/components/ErrorState.qml new file mode 100644 index 0000000..1d93acc --- /dev/null +++ b/src/qEmbyApp/qml/components/ErrorState.qml @@ -0,0 +1,99 @@ +import QtQuick +import QtQuick.Layouts +import qEmby 1.0 + +FocusScope { + id: root + + property string title: qsTr("Something went wrong") + property string message: "" + property string retryText: qsTr("Try again") + property bool retryVisible: true + property bool autoFocus: true + + signal retryRequested() + + implicitWidth: 320 + implicitHeight: errorLayout.implicitHeight + Theme.spacingXl * 2 + Accessible.role: Accessible.AlertMessage + Accessible.name: title + Accessible.description: message + onVisibleChanged: { + if (visible && autoFocus && retryButton.visible) + Qt.callLater(retryButton.forceActiveFocus); + + } + Keys.onEnterPressed: function(event) { + if (retryButton.visible && retryButton.enabled) + root.retryRequested(); + + event.accepted = true; + } + Keys.onReturnPressed: function(event) { + if (retryButton.visible && retryButton.enabled) + root.retryRequested(); + + event.accepted = true; + } + + ColumnLayout { + id: errorLayout + + anchors.centerIn: parent + width: Math.min(implicitWidth, root.width) + spacing: Theme.spacingMd + + Rectangle { + color: Theme.alpha(Theme.danger, 0.14) + border.width: 1 + border.color: Theme.alpha(Theme.danger, 0.5) + radius: width / 2 + Layout.preferredWidth: 44 + Layout.preferredHeight: 44 + Layout.alignment: Qt.AlignHCenter + + Text { + anchors.centerIn: parent + text: "!" + color: Theme.danger + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + } + + } + + Text { + visible: root.title.length > 0 + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + visible: root.message.length > 0 + text: root.message + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + ThemedButton { + id: retryButton + + visible: root.retryVisible + text: root.retryText + Accessible.name: text + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Theme.spacingXs + onClicked: root.retryRequested() + } + + } + +} diff --git a/src/qEmbyApp/qml/components/LoadingState.qml b/src/qEmbyApp/qml/components/LoadingState.qml new file mode 100644 index 0000000..4c21220 --- /dev/null +++ b/src/qEmbyApp/qml/components/LoadingState.qml @@ -0,0 +1,85 @@ +import QtQuick +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: root + + property string title: qsTr("Loading") + property string message: "" + property bool running: visible + property int indicatorSize: 36 + + implicitWidth: 280 + implicitHeight: loadingLayout.implicitHeight + Theme.spacingXl * 2 + Accessible.role: Accessible.ProgressBar + Accessible.name: title + Accessible.description: message + + ColumnLayout { + id: loadingLayout + + anchors.centerIn: parent + width: Math.min(implicitWidth, root.width) + spacing: Theme.spacingMd + + Item { + id: spinner + + Layout.preferredWidth: root.indicatorSize + Layout.preferredHeight: root.indicatorSize + Layout.alignment: Qt.AlignHCenter + + Repeater { + model: 10 + + Rectangle { + required property int index + readonly property real angle: index * Math.PI * 2 / 10 + readonly property real orbitRadius: spinner.width * 0.38 + + width: Math.max(3, spinner.width * 0.11) + height: width + radius: width / 2 + x: spinner.width / 2 + Math.cos(angle) * orbitRadius - width / 2 + y: spinner.height / 2 + Math.sin(angle) * orbitRadius - height / 2 + color: Theme.accent + opacity: 0.22 + index * 0.075 + } + + } + + RotationAnimator on rotation { + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + running: root.running && root.visible + } + + } + + Text { + visible: root.title.length > 0 + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + visible: root.message.length > 0 + text: root.message + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + } + +} diff --git a/src/qEmbyApp/qml/components/MediaCard.qml b/src/qEmbyApp/qml/components/MediaCard.qml new file mode 100644 index 0000000..ed170fe --- /dev/null +++ b/src/qEmbyApp/qml/components/MediaCard.qml @@ -0,0 +1,289 @@ +import QtQuick +import qEmby 1.0 + +FocusScope { + id: root + + property string itemId: "" + property string title: "" + property string subtitle: "" + property string itemType: "" + property url posterSource: "" + property url thumbSource: "" + property bool favorite: false + property bool played: false + property bool playable: true + property real progress: 0 + property int modelIndex: -1 + property string imageAspect: "poster" // "poster" (2:3) or "thumb" (16:9) + property bool horizontal: false + property real artworkWidth: horizontal ? Math.min(width * 0.32, 180) : width + readonly property real imageRatio: imageAspect === "thumb" ? 16 / 9 : 2 / 3 + readonly property real normalizedProgress: Math.max(0, Math.min(1, progress > 1 ? progress / 100 : progress)) + readonly property url effectiveSource: imageAspect === "thumb" && thumbSource.toString().length > 0 ? thumbSource : posterSource + readonly property bool actionsVisible: hoverHandler.hovered || activeFocus + + signal activated(string itemId, int modelIndex) + signal playRequested(string itemId, int modelIndex) + signal favoriteRequested(string itemId, int modelIndex) + signal moreRequested(string itemId, int modelIndex) + + function triggerActivated() { + root.activated(root.itemId, root.modelIndex); + } + + implicitWidth: horizontal ? 420 : 168 + implicitHeight: horizontal ? Math.max(104, artwork.height) : artwork.height + labels.implicitHeight + Theme.spacingMd * 2 + activeFocusOnTab: true + Accessible.role: Accessible.ListItem + Accessible.name: subtitle.length > 0 ? title + ", " + subtitle : title + Accessible.description: itemType + Keys.onEnterPressed: function(event) { + root.triggerActivated(); + event.accepted = true; + } + Keys.onReturnPressed: function(event) { + root.triggerActivated(); + event.accepted = true; + } + Keys.onSpacePressed: function(event) { + root.triggerActivated(); + event.accepted = true; + } + + Rectangle { + anchors.fill: parent + radius: Theme.radiusLg + color: root.actionsVisible ? Theme.bgRaised : Theme.bgSurface + border.width: root.activeFocus ? 2 : 1 + border.color: root.activeFocus ? Theme.accent : root.actionsVisible ? Theme.alpha(Theme.accent, 0.45) : Theme.borderSoft + clip: true + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + + } + + Behavior on border.color { + ColorAnimation { + duration: Theme.animationFast + } + + } + + } + + Item { + id: artwork + + anchors.left: parent.left + anchors.top: parent.top + width: root.horizontal ? root.artworkWidth : parent.width + height: root.horizontal ? parent.height : width / root.imageRatio + clip: true + + Rectangle { + anchors.fill: parent + color: Theme.bgRaised + } + + Image { + id: image + + anchors.fill: parent + source: root.effectiveSource + sourceSize.width: Math.ceil(width * 1.5) + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + } + + Column { + anchors.centerIn: parent + width: parent.width - Theme.spacingLg * 2 + spacing: Theme.spacingXs + visible: image.status === Image.Null || image.status === Image.Error + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: image.status === Image.Error ? "!" : "◇" + color: Theme.textMuted + font.pixelSize: Theme.fontTitle + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: root.itemType.length > 0 ? root.itemType : qsTr("Media") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + } + + Rectangle { + anchors.fill: parent + visible: root.actionsVisible + color: Theme.alpha(Theme.bgBase, 0.48) + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 4 + visible: root.normalizedProgress > 0 && root.normalizedProgress < 1 + color: Theme.alpha(Theme.bgBase, 0.7) + + Rectangle { + width: parent.width * root.normalizedProgress + height: parent.height + color: Theme.accent + } + + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: Theme.spacingSm + width: 24 + height: 24 + radius: 12 + visible: root.played + color: Theme.success + + Text { + anchors.centerIn: parent + text: "✓" + color: "white" + font.pixelSize: Theme.fontSmall + font.weight: Font.Bold + } + + } + + Row { + anchors.centerIn: parent + visible: root.actionsVisible + spacing: Theme.spacingSm + + ActionButton { + visible: root.playable + iconName: "quick-play.svg" + emphasized: true + accessibleName: qsTr("Play %1").arg(root.title) + onTriggered: root.playRequested(root.itemId, root.modelIndex) + } + + ActionButton { + iconName: root.favorite ? "heart-fill.svg" : "heart-outline.svg" + accessibleName: root.favorite ? qsTr("Remove %1 from favorites").arg(root.title) : qsTr("Add %1 to favorites").arg(root.title) + onTriggered: root.favoriteRequested(root.itemId, root.modelIndex) + } + + ActionButton { + iconName: "more-line.svg" + accessibleName: qsTr("More actions for %1").arg(root.title) + onTriggered: root.moreRequested(root.itemId, root.modelIndex) + } + + } + + } + + Column { + id: labels + + anchors.left: root.horizontal ? artwork.right : parent.left + anchors.right: parent.right + anchors.top: root.horizontal ? parent.top : artwork.bottom + anchors.leftMargin: root.horizontal ? Theme.spacingMd : Theme.spacingSm + anchors.rightMargin: Theme.spacingSm + anchors.topMargin: Theme.spacingSm + spacing: 2 + + Text { + width: parent.width + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontBodyPlus + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: text.length > 0 + text: root.subtitle + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + } + + } + + HoverHandler { + id: hoverHandler + + acceptedDevices: PointerDevice.Mouse + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + root.forceActiveFocus(); + root.triggerActivated(); + } + } + + component ActionButton: Rectangle { + id: action + + property string iconName: "" + property string accessibleName: "" + property bool emphasized: false + + signal triggered() + + width: emphasized ? 40 : 34 + height: width + activeFocusOnTab: true + radius: width / 2 + color: emphasized ? Theme.accent : Theme.alpha(Theme.bgRaised, 0.94) + border.width: activeFocus ? 2 : 0 + border.color: Theme.accent + Accessible.role: Accessible.Button + Accessible.name: accessibleName + Keys.onEnterPressed: function(event) { + action.triggered(); + event.accepted = true; + } + Keys.onReturnPressed: function(event) { + action.triggered(); + event.accepted = true; + } + Keys.onSpacePressed: function(event) { + action.triggered(); + event.accepted = true; + } + + Image { + anchors.centerIn: parent + width: action.emphasized ? 19 : 17 + height: width + source: "image://adaptiveicon//svg/dark/" + action.iconName + "?d=" + (Theme.dark ? 1 : 0) + } + + TapHandler { + gesturePolicy: TapHandler.ReleaseWithinBounds + onTapped: action.triggered() + } + + } + +} diff --git a/src/qEmbyApp/qml/components/MediaGrid.qml b/src/qEmbyApp/qml/components/MediaGrid.qml new file mode 100644 index 0000000..ad7e1a8 --- /dev/null +++ b/src/qEmbyApp/qml/components/MediaGrid.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Controls.Basic +import qEmby 1.0 + +FocusScope { + id: root + + property var model: null + property bool loading: false + property string errorText: "" + property string emptyTitle: qsTr("No media") + property string emptyMessage: "" + property string imageAspect: "poster" + property real minimumCardWidth: imageAspect === "thumb" ? 220 : 150 + property real cardSpacing: Theme.spacingLg + property Component loadingDelegate: defaultLoading + property Component errorDelegate: defaultError + property Component emptyDelegate: defaultEmpty + readonly property int columnCount: Math.max(1, Math.floor((width + cardSpacing) / (minimumCardWidth + cardSpacing))) + readonly property real cellWidth: width / columnCount + readonly property real cardWidth: Math.max(96, cellWidth - cardSpacing) + readonly property real cardImageHeight: cardWidth / (imageAspect === "thumb" ? 16 / 9 : 2 / 3) + readonly property real cellHeight: cardImageHeight + 62 + cardSpacing + + signal activated(string itemId, int modelIndex) + signal playRequested(string itemId, int modelIndex) + signal favoriteRequested(string itemId, int modelIndex) + signal moreRequested(string itemId, int modelIndex) + signal retryRequested() + + function optionalValue(row, roleName, fallback) { + return row && row[roleName] !== undefined && row[roleName] !== null ? row[roleName] : fallback; + } + + GridView { + id: view + + anchors.fill: parent + visible: !root.loading && root.errorText.length === 0 && count > 0 + clip: true + model: root.model + cellWidth: root.cellWidth + cellHeight: root.cellHeight + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + activeFocusOnTab: true + highlightMoveDuration: Theme.animationFast + + ScrollBar.vertical: ThemedScrollBar { + } + + delegate: Item { + id: cell + + required property int index + required property var model + + width: view.cellWidth + height: view.cellHeight + + MediaCard { + id: card + + width: root.cardWidth + itemId: root.optionalValue(cell.model, "itemId", "") + title: root.optionalValue(cell.model, "name", "") + subtitle: root.optionalValue(cell.model, "subtitle", "") + itemType: root.optionalValue(cell.model, "itemType", root.optionalValue(cell.model, "type", "")) + posterSource: root.optionalValue(cell.model, "posterSource", "") + thumbSource: root.optionalValue(cell.model, "thumbSource", "") + favorite: root.optionalValue(cell.model, "favorite", false) + playable: root.optionalValue(cell.model, "playable", true) + played: root.optionalValue(cell.model, "played", false) + progress: root.optionalValue(cell.model, "progress", 0) + modelIndex: cell.index + imageAspect: root.imageAspect + focus: view.currentIndex === cell.index + onActiveFocusChanged: { + if (activeFocus) + view.currentIndex = cell.index; + + } + onActivated: function(itemId, modelIndex) { + root.activated(itemId, modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + root.playRequested(itemId, modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + root.favoriteRequested(itemId, modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + root.moreRequested(itemId, modelIndex); + } + } + + } + + } + + Loader { + anchors.fill: parent + visible: active + active: root.loading || root.errorText.length > 0 || (!root.loading && view.count === 0) + sourceComponent: root.loading ? root.loadingDelegate : root.errorText.length > 0 ? root.errorDelegate : root.emptyDelegate + } + + Component { + id: defaultLoading + + LoadingState { + title: qsTr("Loading media…") + running: true + } + + } + + Component { + id: defaultError + + ErrorState { + title: qsTr("Unable to load media") + message: root.errorText + onRetryRequested: root.retryRequested() + } + + } + + Component { + id: defaultEmpty + + EmptyState { + anchors.centerIn: parent + text: root.emptyTitle + hint: root.emptyMessage + } + + } + +} diff --git a/src/qEmbyApp/qml/components/MediaList.qml b/src/qEmbyApp/qml/components/MediaList.qml new file mode 100644 index 0000000..c510620 --- /dev/null +++ b/src/qEmbyApp/qml/components/MediaList.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Controls.Basic +import qEmby 1.0 + +FocusScope { + id: root + + property var model: null + property bool loading: false + property string errorText: "" + property string emptyTitle: qsTr("No media") + property string emptyMessage: "" + property real rowHeight: 112 + property Component loadingDelegate: defaultLoading + property Component errorDelegate: defaultError + property Component emptyDelegate: defaultEmpty + + signal activated(string itemId, int modelIndex) + signal playRequested(string itemId, int modelIndex) + signal favoriteRequested(string itemId, int modelIndex) + signal moreRequested(string itemId, int modelIndex) + signal retryRequested() + + function optionalValue(row, roleName, fallback) { + return row && row[roleName] !== undefined && row[roleName] !== null ? row[roleName] : fallback; + } + + ListView { + id: view + + anchors.fill: parent + visible: !root.loading && root.errorText.length === 0 && count > 0 + clip: true + spacing: Theme.spacingSm + model: root.model + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + activeFocusOnTab: true + highlightMoveDuration: Theme.animationFast + + ScrollBar.vertical: ThemedScrollBar { + } + + delegate: MediaCard { + id: card + + required property int index + required property var model + + width: view.width - (view.ScrollBar.vertical.visible ? view.ScrollBar.vertical.width : 0) + height: root.rowHeight + horizontal: true + imageAspect: "thumb" + itemId: root.optionalValue(model, "itemId", "") + title: root.optionalValue(model, "name", "") + subtitle: root.optionalValue(model, "subtitle", "") + itemType: root.optionalValue(model, "itemType", root.optionalValue(model, "type", "")) + posterSource: root.optionalValue(model, "posterSource", "") + thumbSource: root.optionalValue(model, "thumbSource", "") + favorite: root.optionalValue(model, "favorite", false) + playable: root.optionalValue(model, "playable", true) + played: root.optionalValue(model, "played", false) + progress: root.optionalValue(model, "progress", 0) + modelIndex: index + focus: view.currentIndex === index + onActiveFocusChanged: { + if (activeFocus) + view.currentIndex = index; + + } + onActivated: function(itemId, modelIndex) { + root.activated(itemId, modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + root.playRequested(itemId, modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + root.favoriteRequested(itemId, modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + root.moreRequested(itemId, modelIndex); + } + } + + } + + Loader { + anchors.fill: parent + visible: active + active: root.loading || root.errorText.length > 0 || (!root.loading && view.count === 0) + sourceComponent: root.loading ? root.loadingDelegate : root.errorText.length > 0 ? root.errorDelegate : root.emptyDelegate + } + + Component { + id: defaultLoading + + LoadingState { + title: qsTr("Loading media…") + } + + } + + Component { + id: defaultError + + ErrorState { + title: qsTr("Unable to load media") + message: root.errorText + onRetryRequested: root.retryRequested() + } + + } + + Component { + id: defaultEmpty + + EmptyState { + anchors.centerIn: parent + text: root.emptyTitle + hint: root.emptyMessage + } + + } + +} diff --git a/src/qEmbyApp/qml/components/MediaSection.qml b/src/qEmbyApp/qml/components/MediaSection.qml new file mode 100644 index 0000000..393b90c --- /dev/null +++ b/src/qEmbyApp/qml/components/MediaSection.qml @@ -0,0 +1,123 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby 1.0 + +ColumnLayout { + id: root + + property string title: "" + property string subtitle: "" + property string seeAllText: qsTr("See all") + property bool seeAllVisible: true + property var model: null + property string imageAspect: "poster" + property real cardWidth: imageAspect === "thumb" ? 240 : 160 + property real cardSpacing: Theme.spacingMd + readonly property real cardImageHeight: cardWidth / (imageAspect === "thumb" ? 16 / 9 : 2 / 3) + readonly property real cardHeight: cardImageHeight + 62 + Theme.spacingMd * 2 + + signal seeAllRequested() + signal activated(string itemId, int modelIndex) + signal playRequested(string itemId, int modelIndex) + signal favoriteRequested(string itemId, int modelIndex) + signal moreRequested(string itemId, int modelIndex) + + function optionalValue(row, roleName, fallback) { + return row && row[roleName] !== undefined && row[roleName] !== null ? row[roleName] : fallback; + } + + spacing: Theme.spacingMd + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + visible: root.subtitle.length > 0 + text: root.subtitle + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + Layout.fillWidth: true + } + + } + + ThemedButton { + visible: root.seeAllVisible + text: root.seeAllText + onClicked: root.seeAllRequested() + } + + } + + ListView { + id: view + + Layout.fillWidth: true + Layout.preferredHeight: root.cardHeight + orientation: ListView.Horizontal + spacing: root.cardSpacing + clip: true + model: root.model + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + activeFocusOnTab: true + + ScrollBar.horizontal: ThemedScrollBar { + } + + delegate: MediaCard { + required property int index + required property var model + + width: root.cardWidth + itemId: root.optionalValue(model, "itemId", "") + title: root.optionalValue(model, "name", "") + subtitle: root.optionalValue(model, "subtitle", "") + itemType: root.optionalValue(model, "itemType", root.optionalValue(model, "type", "")) + posterSource: root.optionalValue(model, "posterSource", "") + thumbSource: root.optionalValue(model, "thumbSource", "") + favorite: root.optionalValue(model, "favorite", false) + playable: root.optionalValue(model, "playable", true) + played: root.optionalValue(model, "played", false) + progress: root.optionalValue(model, "progress", 0) + modelIndex: index + imageAspect: root.imageAspect + focus: view.currentIndex === index + onActiveFocusChanged: { + if (activeFocus) + view.currentIndex = index; + + } + onActivated: function(itemId, modelIndex) { + root.activated(itemId, modelIndex); + } + onPlayRequested: function(itemId, modelIndex) { + root.playRequested(itemId, modelIndex); + } + onFavoriteRequested: function(itemId, modelIndex) { + root.favoriteRequested(itemId, modelIndex); + } + onMoreRequested: function(itemId, modelIndex) { + root.moreRequested(itemId, modelIndex); + } + } + + } + +} diff --git a/src/qEmbyApp/qml/components/ModernDialog.qml b/src/qEmbyApp/qml/components/ModernDialog.qml new file mode 100644 index 0000000..808afa8 --- /dev/null +++ b/src/qEmbyApp/qml/components/ModernDialog.qml @@ -0,0 +1,150 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Themed modal dialog with dark/light-aware styling. +// Provides title, content area, and standard Ok/Cancel buttons. +Dialog { + id: root + + // ─── Public API ────────────────────────────────────────── + default property alias content: dialogContent.data + + // ─── Dialog setup ──────────────────────────────────────── + modal: true + anchors.centerIn: parent + padding: Theme.spacingXl + spacing: Theme.spacingLg + + // Close on Escape + onOpened: forceActiveFocus() + Keys.onEscapePressed: root.close() + + // ─── Background ────────────────────────────────────────── + background: Rectangle { + radius: Theme.radiusXl + color: Theme.bgSurface + border.color: Theme.border + border.width: 1 + } + + // ─── ContentItem: title + content + buttons ───────────── + contentItem: ColumnLayout { + spacing: Theme.spacingLg + + // Title bar + RowLayout { + visible: root.title.length > 0 + Layout.fillWidth: true + spacing: Theme.spacingSm + + Text { + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: 700 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + // Separator + Rectangle { + visible: root.title.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + } + + // Content area — consumer children injected here + ColumnLayout { + id: dialogContent + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Theme.spacingMd + } + + // Separator above buttons + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + } + + // Standard buttons + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Item { Layout.fillWidth: true } + + // Cancel + Rectangle { + id: cancelBtn + + property bool hovered: cancelArea.containsMouse + + implicitWidth: cancelLabel.implicitWidth + Theme.spacingXl * 2 + implicitHeight: 32 + radius: Theme.radiusSm + color: hovered ? Theme.bgRaised : "transparent" + border.color: Theme.borderSoft + border.width: 1 + + Text { + id: cancelLabel + anchors.centerIn: parent + text: qsTr("Cancel") + color: Theme.textBody + font.pixelSize: Theme.fontBody + } + + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.reject() + } + } + + // Ok / Accept + Rectangle { + id: okBtn + + property bool hovered: okArea.containsMouse + + implicitWidth: okLabel.implicitWidth + Theme.spacingXl * 2 + implicitHeight: 32 + radius: Theme.radiusSm + color: hovered ? Theme.accentHover : Theme.accent + + Text { + id: okLabel + anchors.centerIn: parent + text: qsTr("OK") + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + font.weight: 600 + } + + MouseArea { + id: okArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.accept() + } + } + } + } + + // ─── Overlay / backdrop ────────────────────────────────── + Overlay.modal: Rectangle { + color: Theme.dark ? Qt.rgba(0, 0, 0, 0.6) : Qt.rgba(0, 0, 0, 0.35) + } + + Overlay.modeless: Rectangle { + color: Theme.dark ? Qt.rgba(0, 0, 0, 0.4) : Qt.rgba(0, 0, 0, 0.2) + } +} diff --git a/src/qEmbyApp/qml/components/SettingsCard.qml b/src/qEmbyApp/qml/components/SettingsCard.qml new file mode 100644 index 0000000..d871239 --- /dev/null +++ b/src/qEmbyApp/qml/components/SettingsCard.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Layouts + +// Reusable styled card container for settings pages. +// Provides optional title/description header and a content area. +// All styling flows through Theme.tokens — zero hardcoded values. +Rectangle { + id: root + + // ─── Public API ────────────────────────────────────────── + default property alias content: contentColumn.data + property string title: "" + property string description: "" + property real innerPadding: Theme.spacingLg + + // ─── Sizing ────────────────────────────────────────────── + implicitWidth: 300 + implicitHeight: cardColumn.implicitHeight + root.innerPadding * 2 + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + + // ─── Appearance ────────────────────────────────────────── + radius: Theme.radiusLg + color: Theme.bgSurface + border.color: Theme.border + border.width: 1 + + // ─── Internal layout ───────────────────────────────────── + ColumnLayout { + id: cardColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: root.innerPadding + spacing: Theme.spacingSm + + // Optional title + Text { + visible: root.title.length > 0 + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: 700 + Layout.fillWidth: true + } + + // Optional description + Text { + visible: root.description.length > 0 + text: root.description + color: Theme.textMuted + font.pixelSize: Theme.fontBody + font.weight: Font.Normal + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.bottomMargin: root.title.length > 0 ? Theme.spacingXs : 0 + } + + // Separator when header is present + Rectangle { + visible: root.title.length > 0 || root.description.length > 0 + Layout.fillWidth: true + Layout.topMargin: Theme.spacingXs + Layout.bottomMargin: Theme.spacingXs + Layout.preferredHeight: 1 + color: Theme.border + } + + // Content area — consumers inject children here via default property + ColumnLayout { + id: contentColumn + Layout.fillWidth: true + spacing: Theme.spacingSm + } + } +} diff --git a/src/qEmbyApp/qml/components/ThemedButton.qml b/src/qEmbyApp/qml/components/ThemedButton.qml new file mode 100644 index 0000000..50b042e --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedButton.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.Button { + id: control + + implicitWidth: Math.max(88, contentItem.implicitWidth + leftPadding + rightPadding) + implicitHeight: 34 + leftPadding: Theme.spacingLg + rightPadding: Theme.spacingLg + font.pixelSize: Theme.fontBody + font.weight: Font.Medium + + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? Theme.textPrimary : Theme.textMuted + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: Theme.radiusMd + color: !control.enabled ? Theme.alpha(Theme.bgRaised, 0.55) + : control.down ? Theme.alpha(Theme.accent, 0.28) + : control.hovered ? Theme.alpha(Theme.accent, 0.16) + : Theme.bgRaised + border.width: control.activeFocus ? 2 : 1 + border.color: control.activeFocus ? Theme.accent : Theme.borderSoft + + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + } +} diff --git a/src/qEmbyApp/qml/components/ThemedComboBox.qml b/src/qEmbyApp/qml/components/ThemedComboBox.qml new file mode 100644 index 0000000..f68d05a --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedComboBox.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.ComboBox { + id: control + + implicitWidth: Math.max(160, contentItem.implicitWidth + leftPadding + rightPadding) + implicitHeight: 36 + leftPadding: Theme.spacingMd + rightPadding: 34 + font.pixelSize: Theme.fontBody + font.weight: Font.Normal + + delegate: Basic.ItemDelegate { + id: option + required property int index + + width: ListView.view ? ListView.view.width : control.width + implicitHeight: 34 + highlighted: control.highlightedIndex === index + + contentItem: Text { + text: control.textAt(option.index) + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: Theme.radiusSm + color: option.highlighted ? Theme.alpha(Theme.accent, 0.18) : "transparent" + } + } + + indicator: Text { + x: control.width - width - Theme.spacingMd + y: (control.height - height) / 2 - 1 + text: "\u2304" + color: control.enabled ? Theme.textMuted : Theme.alpha(Theme.textMuted, 0.55) + font.pixelSize: Theme.fontSubtitle + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + contentItem: Text { + text: control.displayText + font: control.font + color: control.enabled ? Theme.textPrimary : Theme.textMuted + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: Theme.radiusMd + color: control.enabled ? Theme.bgSurface : Theme.alpha(Theme.bgRaised, 0.5) + border.width: control.activeFocus || control.popup.visible ? 2 : 1 + border.color: control.activeFocus || control.popup.visible ? Theme.accent + : Theme.borderSoft + } + + popup: Basic.Popup { + y: control.height + Theme.spacingXs + width: control.width + implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding, 280) + padding: Theme.spacingXs + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + highlightMoveDuration: 0 + boundsBehavior: Flickable.StopAtBounds + } + + background: Rectangle { + radius: Theme.radiusMd + color: Theme.bgSurface + border.width: 1 + border.color: Theme.borderSoft + } + } +} diff --git a/src/qEmbyApp/qml/components/ThemedDialog.qml b/src/qEmbyApp/qml/components/ThemedDialog.qml new file mode 100644 index 0000000..bf0e515 --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedDialog.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import qEmby 1.0 + +Basic.Dialog { + id: root + + default property alias content: dialogBody.data + property string confirmText: qsTr("Confirm") + property string cancelText: qsTr("Cancel") + // Supported values: "primary" and "danger". + property string confirmRole: "primary" + property bool confirmEnabled: true + property bool showConfirmButton: true + property bool showCancelButton: true + property string accessibleDescription: "" + readonly property color confirmColor: confirmRole === "danger" ? Theme.danger : Theme.accent + + modal: true + focus: true + anchors.centerIn: parent + padding: Theme.spacingXl + closePolicy: Basic.Popup.CloseOnEscape + implicitWidth: 440 + onOpened: { + if (confirmButton.visible && confirmButton.enabled) + confirmButton.forceActiveFocus(); + else if (cancelButton.visible) + cancelButton.forceActiveFocus(); + else + forceActiveFocus(); + } + Keys.onEscapePressed: function(event) { + root.reject(); + event.accepted = true; + } + Keys.onEnterPressed: function(event) { + if (confirmButton.visible && confirmButton.enabled) + root.accept(); + + event.accepted = true; + } + Keys.onReturnPressed: function(event) { + if (confirmButton.visible && confirmButton.enabled) + root.accept(); + + event.accepted = true; + } + + background: Rectangle { + radius: Theme.radiusXl + color: Theme.bgSurface + border.width: 1 + border.color: Theme.borderSoft + } + + contentItem: ColumnLayout { + spacing: Theme.spacingLg + Accessible.role: Accessible.Dialog + Accessible.name: root.title + Accessible.description: root.accessibleDescription + + Text { + visible: root.title.length > 0 + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontSubtitle + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Rectangle { + visible: root.title.length > 0 + color: Theme.border + Layout.fillWidth: true + Layout.preferredHeight: 1 + } + + ColumnLayout { + id: dialogBody + + spacing: Theme.spacingMd + Layout.fillWidth: true + } + + Rectangle { + visible: root.showCancelButton || root.showConfirmButton + color: Theme.border + Layout.fillWidth: true + Layout.preferredHeight: 1 + } + + RowLayout { + visible: root.showCancelButton || root.showConfirmButton + spacing: Theme.spacingSm + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + ThemedButton { + id: cancelButton + + visible: root.showCancelButton + text: root.cancelText + Accessible.name: text + onClicked: root.reject() + } + + ThemedButton { + id: confirmButton + + visible: root.showConfirmButton + enabled: root.confirmEnabled + text: root.confirmText + Accessible.name: text + Accessible.description: root.confirmRole === "danger" ? qsTr("Destructive action") : "" + onClicked: root.accept() + + contentItem: Text { + text: confirmButton.text + color: confirmButton.enabled ? "#FFFFFF" : Theme.textMuted + font: confirmButton.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: Theme.radiusMd + color: !confirmButton.enabled ? Theme.alpha(Theme.bgRaised, 0.55) : confirmButton.down ? Theme.alpha(root.confirmColor, 0.72) : confirmButton.hovered ? Theme.alpha(root.confirmColor, 0.86) : root.confirmColor + border.width: confirmButton.activeFocus ? 2 : 0 + border.color: confirmButton.activeFocus ? Theme.alpha("#FFFFFF", 0.9) : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animationFast + } + + } + + } + + } + + } + + } + + Basic.Overlay.modal: Rectangle { + color: Theme.dark ? Qt.rgba(0, 0, 0, 0.62) : Qt.rgba(15, 23, 42, 0.34) + } + +} diff --git a/src/qEmbyApp/qml/components/ThemedScrollBar.qml b/src/qEmbyApp/qml/components/ThemedScrollBar.qml new file mode 100644 index 0000000..c2d92e5 --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedScrollBar.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.ScrollBar { + id: control + + implicitWidth: 10 + implicitHeight: 10 + padding: 2 + policy: Basic.ScrollBar.AsNeeded + + contentItem: Rectangle { + implicitWidth: 6 + implicitHeight: 80 + radius: width / 2 + color: control.pressed || control.hovered ? Theme.textMuted : Theme.borderSoft + opacity: control.active ? 0.9 : 0.55 + + Behavior on opacity { NumberAnimation { duration: Theme.animationFast } } + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + } + + background: Item {} +} diff --git a/src/qEmbyApp/qml/components/ThemedSlider.qml b/src/qEmbyApp/qml/components/ThemedSlider.qml new file mode 100644 index 0000000..c2b42d1 --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedSlider.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.Slider { + id: control + + implicitWidth: 220 + implicitHeight: 30 + + background: Rectangle { + x: control.leftPadding + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: 6 + width: control.availableWidth + height: implicitHeight + radius: height / 2 + color: Theme.bgRaised + + Rectangle { + width: control.visualPosition * parent.width + height: parent.height + radius: parent.radius + color: control.enabled ? Theme.accent : Theme.textMuted + } + } + + handle: Rectangle { + x: control.leftPadding + control.visualPosition * (control.availableWidth - width) + y: control.topPadding + control.availableHeight / 2 - height / 2 + implicitWidth: control.pressed ? 20 : 18 + implicitHeight: implicitWidth + radius: width / 2 + color: "#FFFFFF" + border.width: control.activeFocus ? 3 : 2 + border.color: control.enabled ? Theme.accent : Theme.textMuted + + Behavior on implicitWidth { NumberAnimation { duration: Theme.animationFast } } + } +} diff --git a/src/qEmbyApp/qml/components/ThemedSpinBox.qml b/src/qEmbyApp/qml/components/ThemedSpinBox.qml new file mode 100644 index 0000000..424a509 --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedSpinBox.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.SpinBox { + id: control + + implicitWidth: 120 + implicitHeight: 36 + editable: true + leftPadding: Theme.spacingSm + rightPadding: 38 + font.pixelSize: Theme.fontBody + font.weight: Font.Normal + + contentItem: TextInput { + z: 2 + text: control.textFromValue(control.value, control.locale) + font: control.font + color: Theme.textPrimary + selectionColor: Theme.accent + selectedTextColor: "#FFFFFF" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !control.editable + validator: control.validator + inputMethodHints: Qt.ImhFormattedNumbersOnly + } + + up.indicator: Rectangle { + x: control.width - width + width: 30 + height: control.height / 2 + color: control.up.pressed ? Theme.alpha(Theme.accent, 0.22) + : control.up.hovered ? Theme.alpha(Theme.accent, 0.12) + : "transparent" + + Text { + anchors.centerIn: parent + text: "+" + color: control.enabled ? Theme.textBody : Theme.textMuted + font.pixelSize: Theme.fontBody + } + } + + down.indicator: Rectangle { + x: control.width - width + y: control.height / 2 + width: 30 + height: control.height / 2 + color: control.down.pressed ? Theme.alpha(Theme.accent, 0.22) + : control.down.hovered ? Theme.alpha(Theme.accent, 0.12) + : "transparent" + + Text { + anchors.centerIn: parent + text: "\u2212" + color: control.enabled ? Theme.textBody : Theme.textMuted + font.pixelSize: Theme.fontBody + } + } + + background: Rectangle { + radius: Theme.radiusMd + color: control.enabled ? Theme.bgSurface : Theme.alpha(Theme.bgRaised, 0.5) + border.width: control.activeFocus ? 2 : 1 + border.color: control.activeFocus ? Theme.accent : Theme.borderSoft + + Rectangle { + width: 1 + height: parent.height + anchors.right: parent.right + anchors.rightMargin: 30 + color: Theme.border + } + } +} diff --git a/src/qEmbyApp/qml/components/ThemedSwitch.qml b/src/qEmbyApp/qml/components/ThemedSwitch.qml new file mode 100644 index 0000000..2dc98dd --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedSwitch.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.Switch { + id: control + + implicitWidth: Math.max(indicator.implicitWidth, contentItem.implicitWidth) + + leftPadding + rightPadding + implicitHeight: Math.max(30, indicator.implicitHeight, contentItem.implicitHeight) + spacing: Theme.spacingSm + leftPadding: 0 + rightPadding: 0 + font.pixelSize: Theme.fontBody + font.weight: Font.Normal + + indicator: Rectangle { + implicitWidth: 42 + implicitHeight: 24 + x: control.leftPadding + y: (control.height - height) / 2 + radius: height / 2 + color: !control.enabled ? Theme.alpha(Theme.bgRaised, 0.65) + : control.checked ? Theme.accent + : Theme.borderSoft + border.width: control.activeFocus ? 2 : 1 + border.color: control.activeFocus ? Theme.accentHover + : (control.checked ? Theme.accent : Theme.border) + + Rectangle { + width: 18 + height: 18 + y: 3 + x: control.checked ? parent.width - width - 3 : 3 + radius: width / 2 + color: control.enabled ? "#FFFFFF" : Theme.textMuted + + Behavior on x { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + } + + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + } + + contentItem: Text { + leftPadding: control.text.length > 0 + ? control.indicator.width + control.spacing : 0 + text: control.text + font: control.font + color: control.enabled ? Theme.textBody : Theme.textMuted + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } +} diff --git a/src/qEmbyApp/qml/components/ThemedTextField.qml b/src/qEmbyApp/qml/components/ThemedTextField.qml new file mode 100644 index 0000000..99eeb25 --- /dev/null +++ b/src/qEmbyApp/qml/components/ThemedTextField.qml @@ -0,0 +1,27 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import qEmby 1.0 + +Basic.TextField { + id: control + + implicitWidth: 220 + implicitHeight: 36 + leftPadding: Theme.spacingMd + rightPadding: Theme.spacingMd + color: Theme.textPrimary + selectionColor: Theme.accent + selectedTextColor: "#FFFFFF" + placeholderTextColor: Theme.textMuted + font.pixelSize: Theme.fontBody + font.weight: Font.Normal + + background: Rectangle { + radius: Theme.radiusMd + color: control.enabled ? Theme.bgSurface : Theme.alpha(Theme.bgRaised, 0.5) + border.width: control.activeFocus ? 2 : 1 + border.color: control.activeFocus ? Theme.accent : Theme.borderSoft + + Behavior on border.color { ColorAnimation { duration: Theme.animationFast } } + } +} diff --git a/src/qEmbyApp/qml/components/ToastHost.qml b/src/qEmbyApp/qml/components/ToastHost.qml new file mode 100644 index 0000000..9fd9f60 --- /dev/null +++ b/src/qEmbyApp/qml/components/ToastHost.qml @@ -0,0 +1,179 @@ +import QtQuick +import QtQuick.Layouts +import qEmby 1.0 + +Item { + id: root + + property int defaultDuration: 4000 + property int maximumVisible: 5 + property int nextToastId: 1 + + function show(message, kind, duration) { + const normalizedKind = ["info", "success", "warning", "error"].indexOf(kind) >= 0 ? kind : "info"; + const timeout = duration === undefined ? defaultDuration : Math.max(0, duration); + const toastId = nextToastId++; + toastModel.append({ + "toastId": toastId, + "message": String(message), + "kind": normalizedKind, + "timeoutMs": timeout + }); + while (toastModel.count > maximumVisible)toastModel.remove(0) + return toastId; + } + + function info(message, duration) { + return show(message, "info", duration); + } + + function success(message, duration) { + return show(message, "success", duration); + } + + function warning(message, duration) { + return show(message, "warning", duration); + } + + function error(message, duration) { + return show(message, "error", duration); + } + + function dismiss(toastId) { + for (let i = 0; i < toastModel.count; ++i) { + if (toastModel.get(i).toastId === toastId) { + toastModel.remove(i); + return ; + } + } + } + + function clear() { + toastModel.clear(); + } + + ListModel { + id: toastModel + } + + ListView { + id: toastView + + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: Theme.spacingLg + anchors.rightMargin: Theme.spacingLg + width: Math.min(360, Math.max(240, root.width - Theme.spacingXl * 2)) + height: Math.min(contentHeight, Math.max(0, root.height - Theme.spacingXl * 2)) + spacing: Theme.spacingSm + interactive: contentHeight > height + clip: true + model: toastModel + z: 1000 + + add: Transition { + NumberAnimation { + properties: "opacity,x" + from: 0 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + + } + + remove: Transition { + NumberAnimation { + property: "opacity" + to: 0 + duration: Theme.animationFast + } + + } + + displaced: Transition { + NumberAnimation { + properties: "x,y" + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + + } + + delegate: Rectangle { + id: toast + + required property int toastId + required property string message + required property string kind + required property int timeoutMs + readonly property color statusColor: kind === "success" ? Theme.success : kind === "warning" ? Theme.warning : kind === "error" ? Theme.danger : Theme.accent + readonly property string statusIcon: kind === "success" ? "✓" : kind === "warning" ? "!" : kind === "error" ? "×" : "i" + readonly property string statusLabel: kind === "success" ? qsTr("Success") : kind === "warning" ? qsTr("Warning") : kind === "error" ? qsTr("Error") : qsTr("Information") + + width: toastView.width + implicitHeight: toastLayout.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusLg + color: Theme.bgSurface + border.width: 1 + border.color: Theme.alpha(statusColor, 0.72) + Accessible.role: Accessible.AlertMessage + Accessible.name: qsTr("%1: %2").arg(statusLabel).arg(message) + + RowLayout { + id: toastLayout + + anchors.fill: parent + anchors.margins: Theme.spacingMd + spacing: Theme.spacingMd + + Rectangle { + color: toast.statusColor + radius: width / 2 + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + Layout.alignment: Qt.AlignTop + + Text { + anchors.centerIn: parent + text: toast.statusIcon + color: "#FFFFFF" + font.pixelSize: Theme.fontBody + font.weight: Font.Bold + } + + } + + Text { + text: toast.message + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + ThemedButton { + text: "×" + implicitWidth: 28 + implicitHeight: 28 + leftPadding: 0 + rightPadding: 0 + Accessible.name: qsTr("Dismiss notification") + Layout.alignment: Qt.AlignTop + onClicked: root.dismiss(toast.toastId) + } + + } + + Timer { + interval: toast.timeoutMs + running: toast.timeoutMs > 0 + repeat: false + onTriggered: root.dismiss(toast.toastId) + } + + } + + } + +} diff --git a/src/qEmbyApp/qml/theme/Theme.qml b/src/qEmbyApp/qml/theme/Theme.qml new file mode 100644 index 0000000..6f70a0e --- /dev/null +++ b/src/qEmbyApp/qml/theme/Theme.qml @@ -0,0 +1,81 @@ +pragma Singleton +import QtQuick +import qEmby 1.0 + +// QML 侧的设计令牌单例 — 唯一的 QML 主题入口。 +// 所有值与 C++ tokens/ColorTokens.h、tokens/DesignTokens.h、tokens/AnimationTokens.h +// 严格一致。颜色通过 QuickTheme.dark 三元切换,间距/圆角/字号/动画为常量。 +QtObject { + id: root + + // ─── 语义色(跟随暗色 / 亮色主题) ─────────────────────── + readonly property QtObject colors: QtObject { + // 背景层级 + readonly property color bgBase: QuickTheme.dark ? "#0F172A" : "#F8FAFC" + readonly property color bgSurface: QuickTheme.dark ? "#1E293B" : "#F1F5F9" + readonly property color bgRaised: QuickTheme.dark ? "#334155" : "#E2E8F0" + + // 边框 + readonly property color border: QuickTheme.dark ? "#334155" : "#E2E8F0" + readonly property color borderSoft: QuickTheme.dark ? "#475569" : "#CBD5E1" + + // 文字 + readonly property color textPrimary: QuickTheme.dark ? "#F1F5F9" : "#0F172A" + readonly property color textSecondary: QuickTheme.dark ? "#E2E8F0" : "#1E293B" + readonly property color textBody: QuickTheme.dark ? "#CBD5E1" : "#334155" + readonly property color textMuted: QuickTheme.dark ? "#94A3B8" : "#64748B" + + // 强调色 + readonly property color accent: QuickTheme.dark ? "#3B82F6" : "#2563EB" + readonly property color accentHover: QuickTheme.dark ? "#60A5FA" : "#3B82F6" + + // 状态色(不随主题变化) + readonly property color danger: "#EF4444" + readonly property color warning: "#F59E0B" + readonly property color success: "#22C55E" + } + + // ─── 间距(DesignTokens::Spacing) ──────────────────────── + readonly property QtObject spacing: QtObject { + readonly property int xs: 4 + readonly property int sm: 8 + readonly property int md: 12 + readonly property int lg: 16 + readonly property int xl: 24 + } + + // ─── 圆角(DesignTokens::Radius) ───────────────────────── + readonly property QtObject radius: QtObject { + readonly property int xs: 2 + readonly property int sm: 4 + readonly property int md: 6 + readonly property int lg: 8 + readonly property int xl: 14 + } + + // ─── 字号(DesignTokens::FontSize,基准值) ─────────────── + // 注意:C++ 侧 QuickTheme 会乘以 ThemeManager::fontScaleFactor(), + // 此处为未缩放的基准值。需要缩放的场景请直接用 QuickTheme.fontXxx。 + readonly property QtObject fonts: QtObject { + readonly property int caption: 10 + readonly property int small: 11 + readonly property int body: 12 + readonly property int bodyPlus: 13 + readonly property int heading: 14 + readonly property int subtitle: 16 + readonly property int title: 20 + readonly property int display: 24 + readonly property int hero: 28 + } + + // ─── 动画时长(AnimationTokens::DurationMs) ────────────── + readonly property QtObject animation: QtObject { + readonly property int micro: 120 + readonly property int quick: 150 + readonly property int fast: 180 + readonly property int normal: 300 + readonly property int slow: 400 + readonly property int gentle: 800 + readonly property int shimmer: 1800 + } +} diff --git a/src/qEmbyApp/quick/aboutviewmodel.cpp b/src/qEmbyApp/quick/aboutviewmodel.cpp new file mode 100644 index 0000000..1ae0a4e --- /dev/null +++ b/src/qEmbyApp/quick/aboutviewmodel.cpp @@ -0,0 +1,29 @@ +#include "aboutviewmodel.h" + +#include "../components/licensesdialog.h" + +#include +#include +#include +#include + +AboutViewModel::AboutViewModel(QWidget *dialogParent, QObject *parent) + : QObject(parent), m_dialogParent(dialogParent) +{ +} +QString AboutViewModel::version() const +{ + const QString version = QCoreApplication::applicationVersion(); + return version.isEmpty() ? QStringLiteral("0.0.1") : version; +} + +void AboutViewModel::openLicenses() +{ + LicensesDialog dialog(m_dialogParent ? m_dialogParent->window() : nullptr); + dialog.exec(); +} + +void AboutViewModel::openUrl(const QString &url) +{ + QDesktopServices::openUrl(QUrl(url)); +} diff --git a/src/qEmbyApp/quick/aboutviewmodel.h b/src/qEmbyApp/quick/aboutviewmodel.h new file mode 100644 index 0000000..faa8c57 --- /dev/null +++ b/src/qEmbyApp/quick/aboutviewmodel.h @@ -0,0 +1,28 @@ +#ifndef ABOUTVIEWMODEL_H +#define ABOUTVIEWMODEL_H + +#include +#include + +class QWidget; + +// 设置页「关于」的 QML 数据层。内容是静态的,唯一的行为是打开 +// 开源协议对话框(LicensesDialog 保留 Widgets 实现)。 +class AboutViewModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString version READ version CONSTANT) + +public: + explicit AboutViewModel(QWidget *dialogParent, QObject *parent = nullptr); + + QString version() const; + + Q_INVOKABLE void openLicenses(); + Q_INVOKABLE void openUrl(const QString &url); + +private: + QPointer m_dialogParent; +}; + +#endif // ABOUTVIEWMODEL_H diff --git a/src/qEmbyApp/quick/adminlibrariesviewmodel.cpp b/src/qEmbyApp/quick/adminlibrariesviewmodel.cpp new file mode 100644 index 0000000..e4e4542 --- /dev/null +++ b/src/qEmbyApp/quick/adminlibrariesviewmodel.cpp @@ -0,0 +1,737 @@ +#include "adminlibrariesviewmodel.h" + +#include "../components/librarycreatedlg.h" +#include "../components/libraryeditdlg.h" +#include "../components/mediaimageeditdialog.h" +#include "../components/moderntoast.h" +#include +#include +#include +#include + +#include +#include +#include + +namespace { +QString imageSourceFor(const VirtualFolder &folder) { + const QString id = folder.primaryImageItemId.isEmpty() + ? folder.itemId + : folder.primaryImageItemId; + return id.isEmpty() ? QString() + : QStringLiteral("image://emby/%1,Primary,").arg(id); +} +} // namespace + +AdminLibrariesModel::AdminLibrariesModel(QObject *parent) + : QAbstractListModel(parent) {} + +int AdminLibrariesModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_folders.size(); +} + +QVariant AdminLibrariesModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_folders.size()) + return {}; + + const VirtualFolder &folder = m_folders.at(index.row()); + switch (role) { + case ItemIdRole: + return folder.itemId; + case NameRole: + return folder.name; + case CollectionTypeRole: + return folder.collectionType; + case CollectionTypeNameRole: + return folder.collectionTypeDisplayName(); + case LocationsRole: + return folder.locations; + case LocationSummaryRole: + return folder.locations.isEmpty() + ? tr("No media folders") + : folder.locations.join(QStringLiteral(" · ")); + case ImageSourceRole: + return imageSourceFor(folder); + case SelectedRole: + return m_selectedIds.contains(folder.itemId); + case ScanActiveRole: + return m_scanProgress.contains(folder.itemId); + case ScanProgressRole: + return m_scanProgress.value(folder.itemId, 0); + } + return {}; +} + +QHash AdminLibrariesModel::roleNames() const { + return {{ItemIdRole, "itemId"}, + {NameRole, "name"}, + {CollectionTypeRole, "collectionType"}, + {CollectionTypeNameRole, "collectionTypeName"}, + {LocationsRole, "locations"}, + {LocationSummaryRole, "locationSummary"}, + {ImageSourceRole, "imageSource"}, + {SelectedRole, "selected"}, + {ScanActiveRole, "scanActive"}, + {ScanProgressRole, "scanProgress"}}; +} + +void AdminLibrariesModel::resetFolders(QList folders, + const QSet &selectedIds) { + beginResetModel(); + m_folders = std::move(folders); + m_selectedIds = selectedIds; + + QSet liveIds; + for (const auto &folder : std::as_const(m_folders)) + liveIds.insert(folder.itemId); + for (auto it = m_scanProgress.begin(); it != m_scanProgress.end();) { + if (!liveIds.contains(it.key())) + it = m_scanProgress.erase(it); + else + ++it; + } + endResetModel(); +} + +const VirtualFolder *AdminLibrariesModel::folderAt(int row) const { + return row >= 0 && row < m_folders.size() ? &m_folders.at(row) : nullptr; +} + +int AdminLibrariesModel::rowForItemId(const QString &itemId) const { + for (int row = 0; row < m_folders.size(); ++row) { + if (m_folders.at(row).itemId == itemId) + return row; + } + return -1; +} + +void AdminLibrariesModel::setSelected(const QString &itemId, bool selected) { + const int row = rowForItemId(itemId); + if (row < 0 || m_selectedIds.contains(itemId) == selected) + return; + if (selected) + m_selectedIds.insert(itemId); + else + m_selectedIds.remove(itemId); + emit dataChanged(index(row), index(row), {SelectedRole}); +} + +void AdminLibrariesModel::setAllSelected(const QSet &selectedIds) { + m_selectedIds = selectedIds; + if (!m_folders.isEmpty()) + emit dataChanged(index(0), index(m_folders.size() - 1), {SelectedRole}); +} + +void AdminLibrariesModel::setScanProgress(const QString &itemId, int progress) { + const int row = rowForItemId(itemId); + if (row < 0) + return; + if (progress <= 0 || progress >= 100) + m_scanProgress.remove(itemId); + else + m_scanProgress.insert(itemId, qBound(0, progress, 100)); + emit dataChanged(index(row), index(row), {ScanActiveRole, ScanProgressRole}); +} + +void AdminLibrariesModel::clearAllScanProgress() { + if (m_scanProgress.isEmpty()) + return; + m_scanProgress.clear(); + if (!m_folders.isEmpty()) + emit dataChanged(index(0), index(m_folders.size() - 1), + {ScanActiveRole, ScanProgressRole}); +} + +bool AdminLibrariesModel::moveFolder(int from, int to) { + if (from < 0 || from >= m_folders.size() || to < 0 || + to >= m_folders.size() || from == to) + return false; + const int destination = to > from ? to + 1 : to; + if (!beginMoveRows({}, from, from, {}, destination)) + return false; + m_folders.move(from, to); + endMoveRows(); + return true; +} + +AdminLibrariesViewModel::AdminLibrariesViewModel(QEmbyCore *core, + QWidget *dialogParent, + QObject *parent) + : QObject(parent), m_core(core), m_dialogParent(dialogParent), + m_model(this), m_scanTimer(new QTimer(this)) { + m_scanTimer->setInterval(2000); + connect(m_scanTimer, &QTimer::timeout, this, [this] { pollScanProgress(); }); +} + +AdminLibrariesViewModel::~AdminLibrariesViewModel() { + ++m_generation; + disconnectWebSocket(); +} + +bool AdminLibrariesViewModel::allSelected() const { + return libraryCount() > 0 && selectedCount() == libraryCount(); +} + +bool AdminLibrariesViewModel::scanActive() const { + return m_scanAll || !m_scanningItemIds.isEmpty() || + !m_runningTaskId.isEmpty(); +} + +void AdminLibrariesViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + emit activeChanged(); + if (active) { + m_scanTimer->start(); + connectWebSocket(); + loadData(false); + pollScanProgress(); + } else { + ++m_generation; + ++m_loadGeneration; + m_scanTimer->stop(); + disconnectWebSocket(); + if (m_loading || m_refreshing) { + m_loading = false; + m_refreshing = false; + emit loadingChanged(); + emit refreshingChanged(); + } + if (m_busy) { + m_busy = false; + emit busyChanged(); + } + } +} + +void AdminLibrariesViewModel::refresh() { loadData(true); } +void AdminLibrariesViewModel::retry() { loadData(true); } + +void AdminLibrariesViewModel::toggleSelected(const QString &itemId) { + const bool selected = !m_selectedIds.contains(itemId); + if (selected) + m_selectedIds.insert(itemId); + else + m_selectedIds.remove(itemId); + m_model.setSelected(itemId, selected); + emit summaryChanged(); +} + +void AdminLibrariesViewModel::toggleSelectAll() { + if (allSelected()) { + m_selectedIds.clear(); + } else { + for (const auto &folder : m_model.folders()) + m_selectedIds.insert(folder.itemId); + } + m_model.setAllSelected(m_selectedIds); + emit summaryChanged(); +} + +void AdminLibrariesViewModel::createLibrary() { doCreateLibrary(); } +void AdminLibrariesViewModel::editLibrary(const QString &itemId) { + doEditLibrary(itemId); +} +void AdminLibrariesViewModel::editImages(const QString &itemId) { + doEditImages(itemId); +} +void AdminLibrariesViewModel::deleteLibrary(const QString &itemId) { + doDeleteLibrary(itemId); +} +void AdminLibrariesViewModel::deleteSelected() { doDeleteSelected(); } +void AdminLibrariesViewModel::refreshMetadata(const QString &itemId) { + startLibraryRefresh(itemId, true, true); +} +void AdminLibrariesViewModel::scanLibrary(const QString &itemId) { + startLibraryRefresh(itemId, false, false); +} +void AdminLibrariesViewModel::scanAll() { doScanAll(); } +void AdminLibrariesViewModel::cancelScan() { doCancelScan(); } + +void AdminLibrariesViewModel::moveLibrary(int from, int to) { + if (m_busy || !m_model.moveFolder(from, to)) + return; + QStringList ids; + for (const auto &folder : m_model.folders()) + ids.append(folder.itemId); + saveOrder(ids, m_generation); +} + +const VirtualFolder * +AdminLibrariesViewModel::folderForId(const QString &itemId) const { + return m_model.folderAt(m_model.rowForItemId(itemId)); +} + +bool AdminLibrariesViewModel::accepts(quint64 generation) const { + return m_active && generation == m_generation; +} + +quint64 AdminLibrariesViewModel::beginOperation() { return m_generation; } + +void AdminLibrariesViewModel::setLoading(bool loading, bool manual) { + if (m_loading != loading) { + m_loading = loading; + emit loadingChanged(); + } + const bool refreshing = loading && manual; + if (m_refreshing != refreshing) { + m_refreshing = refreshing; + emit refreshingChanged(); + } +} + +void AdminLibrariesViewModel::setBusy(bool busy) { + if (m_busy == busy) + return; + m_busy = busy; + emit busyChanged(); +} + +void AdminLibrariesViewModel::setError(QString error) { + if (m_errorMessage == error) + return; + m_errorMessage = std::move(error); + emit errorMessageChanged(); +} + +QCoro::Task AdminLibrariesViewModel::loadData(bool manual) { + if (!m_active || (m_loading && !manual)) + co_return; + const quint64 generation = m_generation; + const quint64 loadGeneration = ++m_loadGeneration; + QPointer self(this); + setLoading(true, manual); + setError({}); + + try { + QList folders = + co_await m_core->adminService()->getVirtualFolders(); + if (!self || !accepts(generation) || loadGeneration != m_loadGeneration) + co_return; + const QStringList order = + co_await m_core->adminService()->getLibraryOrder(); + if (!self || !accepts(generation) || loadGeneration != m_loadGeneration) + co_return; + + if (!order.isEmpty()) { + QList sorted; + for (const QString &id : order) { + for (int row = 0; row < folders.size(); ++row) { + if (folders.at(row).itemId == id) { + sorted.append(folders.takeAt(row)); + break; + } + } + } + sorted.append(folders); + folders = sorted; + } + + QSet retained; + for (const auto &folder : std::as_const(folders)) { + if (m_selectedIds.contains(folder.itemId)) + retained.insert(folder.itemId); + } + m_selectedIds = retained; + m_model.resetFolders(std::move(folders), m_selectedIds); + emit summaryChanged(); + } catch (const std::exception &e) { + if (!self || !accepts(generation) || loadGeneration != m_loadGeneration) + co_return; + setError( + tr("Failed to load libraries: %1").arg(QString::fromUtf8(e.what()))); + } + + if (self && accepts(generation) && loadGeneration == m_loadGeneration) + setLoading(false); +} + +QCoro::Task AdminLibrariesViewModel::doCreateLibrary() { + if (!m_active || m_busy || !m_dialogParent) + co_return; + const quint64 generation = beginOperation(); + QPointer self(this); + setBusy(true); + try { + auto providers = co_await m_core->adminService()->getAvailableProviders(); + if (!self || !accepts(generation) || !m_dialogParent) + co_return; + auto localization = + co_await m_core->adminService()->getLocalizationOptions(); + if (!self || !accepts(generation) || !m_dialogParent) + co_return; + + LibraryCreateDialog dialog( + m_core, providers.metadataFetchers, providers.imageFetchers, + providers.subtitleDownloaders, providers.lyricsFetchers, + localization.cultures, localization.countries, m_dialogParent); + if (dialog.exec() == QDialog::Accepted) { + co_await m_core->adminService()->addVirtualFolder( + dialog.libraryName(), dialog.collectionType(), dialog.paths(), + dialog.libraryOptions()); + if (!self || !accepts(generation)) + co_return; + ModernToast::showMessage(tr("Library created successfully"), 2000); + setBusy(false); + co_await loadData(false); + co_return; + } + } catch (const std::exception &e) { + if (self && accepts(generation)) + ModernToast::showMessage( + tr("Failed to create library: %1").arg(QString::fromUtf8(e.what())), + 3000); + } + if (self && accepts(generation)) + setBusy(false); +} + +QCoro::Task AdminLibrariesViewModel::doEditLibrary(QString itemId) { + const VirtualFolder *current = folderForId(itemId); + if (!m_active || m_busy || !m_dialogParent || !current) + co_return; + const VirtualFolder folder = *current; + const quint64 generation = beginOperation(); + QPointer self(this); + setBusy(true); + try { + auto providers = co_await m_core->adminService()->getAvailableProviders(); + if (!self || !accepts(generation) || !m_dialogParent) + co_return; + auto localization = + co_await m_core->adminService()->getLocalizationOptions(); + if (!self || !accepts(generation) || !m_dialogParent) + co_return; + + LibraryEditDialog dialog( + folder, m_core, providers.metadataFetchers, providers.imageFetchers, + providers.subtitleDownloaders, providers.lyricsFetchers, + localization.cultures, localization.countries, m_dialogParent); + if (dialog.exec() == QDialog::Accepted) { + const QString name = + dialog.newName().isEmpty() ? folder.name : dialog.newName(); + const QString virtualFolderId = folder.virtualFolderId(); + if (name != folder.name) + co_await m_core->adminService()->renameVirtualFolder(folder.name, name); + if (!self || !accepts(generation)) + co_return; + for (const QString &path : dialog.addedPaths()) { + co_await m_core->adminService()->addMediaPath(virtualFolderId, name, + path); + if (!self || !accepts(generation)) + co_return; + } + for (const QString &path : dialog.removedPaths()) { + co_await m_core->adminService()->removeMediaPath(virtualFolderId, name, + path); + if (!self || !accepts(generation)) + co_return; + } + co_await m_core->adminService()->updateVirtualFolderOptions( + virtualFolderId, dialog.libraryOptions(), dialog.paths()); + if (!self || !accepts(generation)) + co_return; + ModernToast::showMessage(tr("Library updated successfully"), 2000); + setBusy(false); + co_await loadData(false); + co_return; + } + } catch (const std::exception &e) { + if (self && accepts(generation)) + ModernToast::showMessage( + tr("Failed to update library: %1").arg(QString::fromUtf8(e.what())), + 3000); + } + if (self && accepts(generation)) + setBusy(false); +} + +QCoro::Task AdminLibrariesViewModel::doEditImages(QString itemId) { + const VirtualFolder *current = folderForId(itemId); + if (!m_active || m_busy || !m_dialogParent || !current) + co_return; + const VirtualFolder folder = *current; + const quint64 generation = beginOperation(); + setBusy(true); + + MediaImageEditTarget target; + target.itemId = folder.itemId; + target.imageItemId = folder.primaryImageItemId.isEmpty() + ? folder.itemId + : folder.primaryImageItemId; + target.displayName = folder.name; + target.itemType = QStringLiteral("CollectionFolder"); + target.collectionType = folder.collectionType; + target.isLibrary = true; + MediaImageEditDialog dialog(m_core, target, m_dialogParent); + dialog.exec(); + if (!accepts(generation)) + co_return; + setBusy(false); + if (dialog.hasChanges()) + co_await loadData(false); +} + +QCoro::Task AdminLibrariesViewModel::doDeleteLibrary(QString itemId) { + const VirtualFolder *current = folderForId(itemId); + if (!m_active || m_busy || !current) + co_return; + const VirtualFolder folder = *current; + const quint64 generation = beginOperation(); + QPointer self(this); + setBusy(true); + try { + co_await m_core->adminService()->removeVirtualFolder(folder); + if (!self || !accepts(generation)) + co_return; + m_selectedIds.remove(itemId); + ModernToast::showMessage(tr("Library \"%1\" deleted").arg(folder.name), + 2000); + setBusy(false); + co_await loadData(false); + co_return; + } catch (const std::exception &e) { + if (self && accepts(generation)) + ModernToast::showMessage( + tr("Failed to delete library: %1").arg(QString::fromUtf8(e.what())), + 3000); + } + if (self && accepts(generation)) + setBusy(false); +} + +QCoro::Task AdminLibrariesViewModel::doDeleteSelected() { + if (!m_active || m_busy || m_selectedIds.isEmpty()) + co_return; + const quint64 generation = beginOperation(); + QPointer self(this); + const QSet selected = m_selectedIds; + setBusy(true); + int deleted = 0; + try { + const QList folders = m_model.folders(); + for (const auto &folder : folders) { + if (!selected.contains(folder.itemId)) + continue; + co_await m_core->adminService()->removeVirtualFolder(folder); + if (!self || !accepts(generation)) + co_return; + ++deleted; + } + m_selectedIds.clear(); + ModernToast::showMessage(tr("Deleted %n library(s)", "", deleted), 2000); + setBusy(false); + co_await loadData(false); + co_return; + } catch (const std::exception &e) { + if (self && accepts(generation)) + ModernToast::showMessage(tr("Deleted %1 library(s), then failed: %2") + .arg(deleted) + .arg(QString::fromUtf8(e.what())), + 3500); + } + if (self && accepts(generation)) + setBusy(false); +} + +QCoro::Task AdminLibrariesViewModel::startLibraryRefresh( + QString itemId, bool replaceAllMetadata, bool replaceAllImages) { + const VirtualFolder *current = folderForId(itemId); + if (!m_active || !current || itemId.trimmed().isEmpty()) + co_return; + const QString name = current->name; + const quint64 generation = beginOperation(); + QPointer self(this); + m_scanningItemIds.insert(itemId); + m_model.setScanProgress(itemId, 1); + emit scanStateChanged(); + try { + co_await m_core->adminService()->refreshItemMetadata( + itemId, replaceAllMetadata, replaceAllImages); + if (!self || !accepts(generation)) + co_return; + ModernToast::showMessage( + replaceAllMetadata ? tr("Refreshing metadata for \"%1\"...").arg(name) + : tr("Scanning \"%1\"...").arg(name), + 2000); + scheduleReload(); + } catch (const std::exception &e) { + if (!self || !accepts(generation)) + co_return; + m_scanningItemIds.remove(itemId); + m_model.setScanProgress(itemId, 0); + emit scanStateChanged(); + ModernToast::showMessage( + tr("Scan failed: %1").arg(QString::fromUtf8(e.what())), 3000); + } +} + +QCoro::Task AdminLibrariesViewModel::doScanAll() { + if (!m_active || scanActive()) + co_return; + const quint64 generation = beginOperation(); + QPointer self(this); + m_scanAll = true; + m_scanProgress = 0; + emit scanStateChanged(); + try { + co_await m_core->adminService()->refreshLibrary(); + if (!self || !accepts(generation)) + co_return; + ModernToast::showMessage(tr("Full library scan started"), 2000); + } catch (const std::exception &e) { + if (!self || !accepts(generation)) + co_return; + m_scanAll = false; + emit scanStateChanged(); + ModernToast::showMessage( + tr("Scan failed: %1").arg(QString::fromUtf8(e.what())), 3000); + } +} + +QCoro::Task AdminLibrariesViewModel::doCancelScan() { + if (!m_active || m_runningTaskId.isEmpty()) + co_return; + const quint64 generation = beginOperation(); + QPointer self(this); + try { + co_await m_core->adminService()->stopScheduledTask(m_runningTaskId); + if (!self || !accepts(generation)) + co_return; + finishScan(m_reloadAfterScan); + ModernToast::showMessage(tr("Scan cancelled"), 2000); + } catch (const std::exception &e) { + if (self && accepts(generation)) + ModernToast::showMessage( + tr("Cancel failed: %1").arg(QString::fromUtf8(e.what())), 3000); + } +} + +QCoro::Task AdminLibrariesViewModel::saveOrder(QStringList orderedIds, + quint64 generation) { + QPointer self(this); + try { + co_await m_core->adminService()->updateLibraryOrder(orderedIds); + if (self && accepts(generation)) + ModernToast::showMessage(tr("Library order updated"), 1500); + } catch (const std::exception &e) { + if (self && accepts(generation)) { + ModernToast::showMessage( + tr("Failed to save order: %1").arg(QString::fromUtf8(e.what())), + 3000); + loadData(false); + } + } +} + +QCoro::Task AdminLibrariesViewModel::pollScanProgress() { + if (!m_active) + co_return; + const quint64 generation = beginOperation(); + QPointer self(this); + try { + const auto tasks = co_await m_core->adminService()->getScheduledTasks(); + if (!self || !accepts(generation)) + co_return; + bool found = false; + for (const auto &task : tasks) { + if (task.category != QLatin1String("Library") || !task.isRunning()) + continue; + found = true; + m_runningTaskId = task.id; + if (!m_scanAll && m_scanningItemIds.isEmpty()) + m_scanAll = true; + const int progress = + qBound(0, static_cast(task.currentProgressPercentage), 100); + if (progress > 0 && progress >= m_scanProgress) + m_scanProgress = progress; + if (!m_scanAll && m_scanningItemIds.size() == 1) + m_model.setScanProgress(*m_scanningItemIds.constBegin(), progress); + emit scanStateChanged(); + break; + } + if (!found && scanActive()) + finishScan(true); + } catch (...) { + // WebSocket remains the primary source while connected. Poll failures are + // transient and must not replace a usable page with an error state. + } +} + +void AdminLibrariesViewModel::connectWebSocket() { + disconnectWebSocket(); + EmbyWebSocket *webSocket = m_core->serverManager()->activeWebSocket(); + if (!webSocket) + return; + m_wsConnections.append(connect(webSocket, &EmbyWebSocket::refreshProgress, + this, [this](const QString &id, int progress) { + if (m_active) + onRefreshProgress(id, progress); + })); + m_wsConnections.append( + connect(webSocket, &EmbyWebSocket::libraryChanged, this, [this] { + if (m_active) + scheduleReload(); + })); + m_wsConnections.append(connect(webSocket, &EmbyWebSocket::connected, this, + [this] { m_scanTimer->setInterval(5000); })); + m_wsConnections.append(connect(webSocket, &EmbyWebSocket::disconnected, this, + [this] { m_scanTimer->setInterval(2000); })); + m_scanTimer->setInterval(webSocket->isConnected() ? 5000 : 2000); +} + +void AdminLibrariesViewModel::disconnectWebSocket() { + for (const auto &connection : std::as_const(m_wsConnections)) + disconnect(connection); + m_wsConnections.clear(); +} + +void AdminLibrariesViewModel::onRefreshProgress(const QString &itemId, + int progress) { + const int bounded = qBound(0, progress, 100); + if (itemId.isEmpty()) { + if (!m_scanAll && m_scanningItemIds.isEmpty()) + m_scanAll = true; + if (bounded > 0 && bounded >= m_scanProgress) + m_scanProgress = bounded; + emit scanStateChanged(); + if (bounded >= 100) + finishScan(true); + return; + } + if (m_model.rowForItemId(itemId) < 0) + return; + if (bounded <= 0 || bounded >= 100) + m_scanningItemIds.remove(itemId); + else + m_scanningItemIds.insert(itemId); + m_model.setScanProgress(itemId, bounded); + emit scanStateChanged(); +} + +void AdminLibrariesViewModel::finishScan(bool reload) { + m_model.clearAllScanProgress(); + m_scanAll = false; + m_scanningItemIds.clear(); + m_runningTaskId.clear(); + m_scanProgress = 0; + const bool shouldReload = reload || m_reloadAfterScan; + m_reloadAfterScan = false; + emit scanStateChanged(); + if (shouldReload && m_active) + loadData(false); +} + +void AdminLibrariesViewModel::scheduleReload() { + if (scanActive()) { + m_reloadAfterScan = true; + return; + } + const quint64 generation = m_generation; + QPointer self(this); + QTimer::singleShot(1200, this, [self, generation] { + if (self && self->accepts(generation)) + self->loadData(false); + }); +} diff --git a/src/qEmbyApp/quick/adminlibrariesviewmodel.h b/src/qEmbyApp/quick/adminlibrariesviewmodel.h new file mode 100644 index 0000000..0616813 --- /dev/null +++ b/src/qEmbyApp/quick/adminlibrariesviewmodel.h @@ -0,0 +1,161 @@ +#ifndef ADMINLIBRARIESVIEWMODEL_H +#define ADMINLIBRARIESVIEWMODEL_H + +#include +#include +#include +#include + +#include +#include + +class EmbyWebSocket; +class QEmbyCore; +class QTimer; +class QWidget; + +class AdminLibrariesModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + CollectionTypeRole, + CollectionTypeNameRole, + LocationsRole, + LocationSummaryRole, + ImageSourceRole, + SelectedRole, + ScanActiveRole, + ScanProgressRole, + }; + + explicit AdminLibrariesModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void resetFolders(QList folders, + const QSet &selectedIds); + const QList &folders() const { return m_folders; } + const VirtualFolder *folderAt(int row) const; + int rowForItemId(const QString &itemId) const; + void setSelected(const QString &itemId, bool selected); + void setAllSelected(const QSet &selectedIds); + void setScanProgress(const QString &itemId, int progress); + void clearAllScanProgress(); + bool moveFolder(int from, int to); + +private: + QList m_folders; + QSet m_selectedIds; + QHash m_scanProgress; +}; + +class AdminLibrariesViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QAbstractItemModel *librariesModel READ librariesModel CONSTANT) + Q_PROPERTY(bool active READ active NOTIFY activeChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY refreshingChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) + Q_PROPERTY(int libraryCount READ libraryCount NOTIFY summaryChanged) + Q_PROPERTY(int selectedCount READ selectedCount NOTIFY summaryChanged) + Q_PROPERTY(bool allSelected READ allSelected NOTIFY summaryChanged) + Q_PROPERTY(bool scanActive READ scanActive NOTIFY scanStateChanged) + Q_PROPERTY(int scanProgress READ scanProgress NOTIFY scanStateChanged) + +public: + explicit AdminLibrariesViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent = nullptr); + ~AdminLibrariesViewModel() override; + + QAbstractItemModel *librariesModel() { return &m_model; } + bool active() const { return m_active; } + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + bool busy() const { return m_busy; } + QString errorMessage() const { return m_errorMessage; } + int libraryCount() const { return m_model.rowCount(); } + int selectedCount() const { return m_selectedIds.size(); } + bool allSelected() const; + bool scanActive() const; + int scanProgress() const { return m_scanProgress; } + + void setActive(bool active); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void toggleSelected(const QString &itemId); + Q_INVOKABLE void toggleSelectAll(); + Q_INVOKABLE void createLibrary(); + Q_INVOKABLE void editLibrary(const QString &itemId); + Q_INVOKABLE void editImages(const QString &itemId); + // Confirmation belongs to AdminLibrariesPage.qml; these methods execute it. + Q_INVOKABLE void deleteLibrary(const QString &itemId); + Q_INVOKABLE void deleteSelected(); + Q_INVOKABLE void refreshMetadata(const QString &itemId); + Q_INVOKABLE void scanLibrary(const QString &itemId); + Q_INVOKABLE void scanAll(); + Q_INVOKABLE void cancelScan(); + Q_INVOKABLE void moveLibrary(int from, int to); + +signals: + void activeChanged(); + void loadingChanged(); + void refreshingChanged(); + void busyChanged(); + void errorMessageChanged(); + void summaryChanged(); + void scanStateChanged(); + +private: + QCoro::Task loadData(bool manual); + QCoro::Task doCreateLibrary(); + QCoro::Task doEditLibrary(QString itemId); + QCoro::Task doEditImages(QString itemId); + QCoro::Task doDeleteLibrary(QString itemId); + QCoro::Task doDeleteSelected(); + QCoro::Task startLibraryRefresh(QString itemId, bool replaceAllMetadata, + bool replaceAllImages); + QCoro::Task doScanAll(); + QCoro::Task doCancelScan(); + QCoro::Task saveOrder(QStringList orderedIds, quint64 generation); + QCoro::Task pollScanProgress(); + + const VirtualFolder *folderForId(const QString &itemId) const; + bool accepts(quint64 generation) const; + quint64 beginOperation(); + void setLoading(bool loading, bool manual = false); + void setBusy(bool busy); + void setError(QString error); + void connectWebSocket(); + void disconnectWebSocket(); + void onRefreshProgress(const QString &itemId, int progress); + void finishScan(bool reload); + void scheduleReload(); + + QEmbyCore *m_core = nullptr; + QPointer m_dialogParent; + AdminLibrariesModel m_model; + QTimer *m_scanTimer = nullptr; + QList m_wsConnections; + QSet m_selectedIds; + QSet m_scanningItemIds; + QString m_runningTaskId; + QString m_errorMessage; + bool m_active = false; + bool m_loading = false; + bool m_refreshing = false; + bool m_busy = false; + bool m_scanAll = false; + bool m_reloadAfterScan = false; + int m_scanProgress = 0; + quint64 m_generation = 0; + quint64 m_loadGeneration = 0; +}; + +#endif diff --git a/src/qEmbyApp/quick/dashboardviewmodel.cpp b/src/qEmbyApp/quick/dashboardviewmodel.cpp new file mode 100644 index 0000000..15de3f1 --- /dev/null +++ b/src/qEmbyApp/quick/dashboardviewmodel.cpp @@ -0,0 +1,491 @@ +#include "dashboardviewmodel.h" + +#include "../components/modernmessagebox.h" +#include "../components/moderntoast.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +// 与 NowPlayingCard::loadImage 相同的候选回退顺序,编码进 image://emby/ URL +QString buildImageSource(const SessionInfo &s) +{ + QStringList candidates; + auto append = [&candidates](const QString &itemId, const QString &type, const QString &tag) + { + if (!itemId.isEmpty()) + candidates.append(itemId + QLatin1Char(',') + type + QLatin1Char(',') + tag); + }; + + if (!s.nowPlayingThumbTag.isEmpty()) + append(s.nowPlayingItemId, QStringLiteral("Thumb"), s.nowPlayingThumbTag); + if (!s.nowPlayingBackdropTag.isEmpty()) + append(s.nowPlayingItemId, QStringLiteral("Backdrop"), s.nowPlayingBackdropTag); + if (!s.nowPlayingImageTag.isEmpty()) + append(s.nowPlayingItemId, QStringLiteral("Primary"), s.nowPlayingImageTag); + if (!s.parentThumbItemId.isEmpty() && !s.parentThumbImageTag.isEmpty()) + append(s.parentThumbItemId, QStringLiteral("Thumb"), s.parentThumbImageTag); + if (!s.parentBackdropItemId.isEmpty() && !s.parentBackdropImageTag.isEmpty()) + append(s.parentBackdropItemId, QStringLiteral("Backdrop"), s.parentBackdropImageTag); + if (!s.nowPlayingSeriesId.isEmpty() && !s.nowPlayingSeriesPrimaryTag.isEmpty()) + append(s.nowPlayingSeriesId, QStringLiteral("Primary"), s.nowPlayingSeriesPrimaryTag); + if (candidates.isEmpty()) + append(s.nowPlayingItemId, QStringLiteral("Primary"), QString()); + + if (candidates.isEmpty()) + return {}; + return QStringLiteral("image://emby/") + candidates.join(QLatin1Char(';')); +} + +QString buildSubtitle(const SessionInfo &s) +{ + if (!s.nowPlayingSeriesName.isEmpty()) + { + QString subtitle = s.nowPlayingSeriesName; + const QString seLabel = s.seasonEpisodeLabel(); + if (!seLabel.isEmpty()) + subtitle += QStringLiteral(" - %1").arg(seLabel); + return subtitle; + } + return s.nowPlayingItemType; +} + +QString buildStreamLine(const SessionInfo &s) +{ + if (s.playMethod.isEmpty()) + return {}; + + QString methodDisplay; + if (s.playMethod == QLatin1String("DirectPlay")) + methodDisplay = DashboardViewModel::tr("Direct Play"); + else if (s.playMethod == QLatin1String("DirectStream")) + methodDisplay = DashboardViewModel::tr("Direct Stream"); + else if (s.playMethod == QLatin1String("Transcode")) + methodDisplay = DashboardViewModel::tr("Transcode"); + else + methodDisplay = s.playMethod; + + if (s.playMethod == QLatin1String("Transcode") && !s.transcodingVideoCodec.isEmpty()) + { + QString bitrateStr; + if (s.transcodingBitrate > 0) + { + const double mbps = s.transcodingBitrate / 1000000.0; + bitrateStr = QStringLiteral(" %1 Mbps").arg(mbps, 0, 'f', 1); + } + return QStringLiteral("%1 · %2%3") + .arg(methodDisplay, s.transcodingVideoCodec.toUpper(), bitrateStr); + } + return methodDisplay; +} + +QString formatIsoToLocal(const QString &isoDate) +{ + if (isoDate.isEmpty()) + return {}; + QDateTime dt = QDateTime::fromString(isoDate, Qt::ISODate); + if (!dt.isValid()) + dt = QDateTime::fromString(isoDate, "yyyy-MM-ddTHH:mm:ss.zzzzzzzZ"); + if (!dt.isValid()) + return isoDate; + dt.setTimeZone(QTimeZone::UTC); + return dt.toLocalTime().toString("yyyy-MM-dd HH:mm:ss"); +} + +} // namespace + +// ===== NowPlayingListModel ===== + +int NowPlayingListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_entries.size(); +} + +QVariant NowPlayingListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_entries.size()) + return {}; + + const SessionInfo &s = m_entries.at(index.row()).session; + switch (role) + { + case TitleRole: return s.nowPlayingItemName; + case SubtitleRole: return buildSubtitle(s); + case UserLineRole: return QStringLiteral("%1 · %2").arg(s.userName, s.client); + case StreamLineRole: return buildStreamLine(s); + case ProgressRole: return s.playbackProgress(); + case TimeTextRole: + return SessionInfo::formatTicks(s.positionTicks) + QStringLiteral(" / ") + + SessionInfo::formatTicks(s.nowPlayingRunTimeTicks); + case PausedRole: return s.isPaused; + case ImageSourceRole: return buildImageSource(s); + } + return {}; +} + +QHash NowPlayingListModel::roleNames() const +{ + return { + {TitleRole, "title"}, + {SubtitleRole, "subtitle"}, + {UserLineRole, "userLine"}, + {StreamLineRole, "streamLine"}, + {ProgressRole, "progress"}, + {TimeTextRole, "timeText"}, + {PausedRole, "paused"}, + {ImageSourceRole, "imageSource"}, + }; +} + +void NowPlayingListModel::sync(const QList &playing) +{ + static constexpr qint64 kExpireMs = 30000; + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + + QSet activeKeys; + for (const auto &session : playing) + activeKeys.insert(session.id + QLatin1Char('|') + session.nowPlayingItemId); + + // 移除超过宽限期的旧卡片 + for (int i = m_entries.size() - 1; i >= 0; --i) + { + const Entry &entry = m_entries.at(i); + if (!activeKeys.contains(entry.key) && nowMs - entry.lastSeenMs >= kExpireMs) + { + beginRemoveRows({}, i, i); + m_entries.removeAt(i); + endRemoveRows(); + } + } + + // 更新已有行 / 追加新行 + for (const auto &session : playing) + { + const QString key = session.id + QLatin1Char('|') + session.nowPlayingItemId; + int row = -1; + for (int i = 0; i < m_entries.size(); ++i) + { + if (m_entries.at(i).key == key) + { + row = i; + break; + } + } + + if (row >= 0) + { + m_entries[row].session = session; + m_entries[row].lastSeenMs = nowMs; + const QModelIndex idx = index(row); + emit dataChanged(idx, idx); + } + else + { + beginInsertRows({}, m_entries.size(), m_entries.size()); + m_entries.append({key, session, nowMs}); + endInsertRows(); + } + } +} + +// ===== SessionListModel ===== + +int SessionListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_rows.size(); +} + +QVariant SessionListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_rows.size()) + return {}; + + const Row &row = m_rows.at(index.row()); + switch (role) + { + case UserNameRole: return row.userName; + case ClientLineRole: return row.clientLine; + case InfoLineRole: return row.infoLine; + } + return {}; +} + +QHash SessionListModel::roleNames() const +{ + return { + {UserNameRole, "userName"}, + {ClientLineRole, "clientLine"}, + {InfoLineRole, "infoLine"}, + }; +} + +void SessionListModel::reset(const QList &sessions) +{ + beginResetModel(); + m_rows.clear(); + for (const auto &session : sessions) + { + QString info = formatIsoToLocal(session.lastActivityDate); + if (!session.remoteEndPoint.isEmpty()) + { + if (!info.isEmpty()) + info += QStringLiteral(" | "); + info += session.remoteEndPoint; + } + m_rows.append({session.userName, + QStringLiteral("%1 · %2 (v%3)") + .arg(session.client, session.deviceName, session.applicationVersion), + info}); + } + endResetModel(); +} + +// ===== ActivityListModel ===== + +int ActivityListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_entries.size(); +} + +QVariant ActivityListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_entries.size()) + return {}; + + const ActivityLogEntry &entry = m_entries.at(index.row()); + switch (role) + { + case NameRole: return entry.name; + case OverviewRole: + return entry.overview.isEmpty() ? entry.shortOverview : entry.overview; + case DateTextRole: return formatIsoToLocal(entry.date); + case SeverityRole: return entry.severity; + } + return {}; +} + +QHash ActivityListModel::roleNames() const +{ + return { + {NameRole, "name"}, + {OverviewRole, "overview"}, + {DateTextRole, "dateText"}, + {SeverityRole, "severity"}, + }; +} + +void ActivityListModel::reset(const QList &entries) +{ + beginResetModel(); + m_entries = entries; + endResetModel(); +} + +// ===== DashboardViewModel ===== + +DashboardViewModel::DashboardViewModel(QEmbyCore *core, QWidget *dialogParent, QObject *parent) + : QObject(parent), m_core(core), m_dialogParent(dialogParent) +{ + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(2000); + connect(m_refreshTimer, &QTimer::timeout, this, [this]() { loadData(false); }); +} + +void DashboardViewModel::refresh() +{ + loadData(true); +} + +void DashboardViewModel::restartServer() +{ + if (m_operationBusy) + return; + doRestart(); +} + +void DashboardViewModel::shutdownServer() +{ + if (m_operationBusy) + return; + doShutdown(); +} + +void DashboardViewModel::setActive(bool active) +{ + if (m_active == active) + return; + m_active = active; + ++m_generation; + if (active) + { + m_refreshTimer->start(); + loadData(false); + } + else + { + m_refreshTimer->stop(); + ++m_operationGeneration; + m_loadingData = false; + if (m_refreshing) + { + m_refreshing = false; + emit refreshingChanged(); + } + if (m_operationBusy) + { + m_operationBusy = false; + emit operationBusyChanged(); + } + } +} + +QCoro::Task DashboardViewModel::loadData(bool isManual) +{ + if (!m_active || !m_core || !m_core->adminService() || m_loadingData) + co_return; + const quint64 generation = ++m_generation; + QPointer guard(this); + m_loadingData = true; + + if (isManual && !m_refreshing) + { + m_refreshing = true; + emit refreshingChanged(); + } + + try + { + auto sysInfo = co_await m_core->adminService()->getSystemInfo(); + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + + m_serverName = sysInfo.serverName; + m_version = sysInfo.version; + m_operatingSystem = sysInfo.operatingSystemName.isEmpty() ? sysInfo.operatingSystem + : sysInfo.operatingSystemName; + m_address = sysInfo.localAddress; + if (!sysInfo.wanAddress.isEmpty()) + m_address += QStringLiteral(" | WAN: %1").arg(sysInfo.wanAddress); + m_canRestart = sysInfo.canSelfRestart; + emit infoChanged(); + + auto sessions = co_await m_core->adminService()->getActiveSessions(); + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + + QList playingSessions; + QList allSessions; + for (const auto &session : sessions) + { + if (session.userName.isEmpty()) + continue; + allSessions.append(session); + if (session.isNowPlaying()) + playingSessions.append(session); + } + + m_nowPlayingModel.sync(playingSessions); + m_sessionsModel.reset(allSessions); + + auto entries = co_await m_core->adminService()->getActivityLog(200); + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + m_activityModel.reset(entries); + } + catch (const std::exception &e) + { + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + ModernToast::showMessage(tr("Failed to load: %1").arg(QString::fromUtf8(e.what())), 3000); + } + + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + if (m_refreshing) + { + m_refreshing = false; + emit refreshingChanged(); + } + m_loadingData = false; +} + +QCoro::Task DashboardViewModel::doRestart() +{ + if (m_operationBusy || !m_active || !m_core || !m_core->adminService()) + co_return; + const bool confirmed = ModernMessageBox::question( + m_dialogParent, tr("Restart Server"), + tr("Are you sure you want to restart the server? All active sessions " + "will be terminated."), + tr("Restart"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); + + if (!confirmed) + co_return; + + m_operationBusy = true; + emit operationBusyChanged(); + const quint64 generation = ++m_operationGeneration; + QPointer guard(this); + try + { + co_await m_core->adminService()->restartServer(); + if (!guard || generation != guard->m_operationGeneration) + co_return; + ModernToast::showMessage(tr("Server restart command sent"), 2000); + } + catch (const std::exception &e) + { + if (!guard || generation != guard->m_operationGeneration) + co_return; + ModernToast::showMessage(tr("Restart failed: %1").arg(QString::fromUtf8(e.what())), 3000); + } + if (guard && generation == guard->m_operationGeneration) + { + m_operationBusy = false; + emit operationBusyChanged(); + } +} + +QCoro::Task DashboardViewModel::doShutdown() +{ + if (m_operationBusy || !m_active || !m_core || !m_core->adminService()) + co_return; + const bool confirmed = ModernMessageBox::question( + m_dialogParent, tr("Shutdown Server"), + tr("Are you sure you want to shut down the server? This will stop all " + "services and disconnect all clients."), + tr("Shutdown"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); + + if (!confirmed) + co_return; + + m_operationBusy = true; + emit operationBusyChanged(); + const quint64 generation = ++m_operationGeneration; + QPointer guard(this); + try + { + co_await m_core->adminService()->shutdownServer(); + if (!guard || generation != guard->m_operationGeneration) + co_return; + ModernToast::showMessage(tr("Server shutdown command sent"), 2000); + } + catch (const std::exception &e) + { + if (!guard || generation != guard->m_operationGeneration) + co_return; + ModernToast::showMessage(tr("Shutdown failed: %1").arg(QString::fromUtf8(e.what())), 3000); + } + if (guard && generation == guard->m_operationGeneration) + { + m_operationBusy = false; + emit operationBusyChanged(); + } +} diff --git a/src/qEmbyApp/quick/dashboardviewmodel.h b/src/qEmbyApp/quick/dashboardviewmodel.h new file mode 100644 index 0000000..a7e4e76 --- /dev/null +++ b/src/qEmbyApp/quick/dashboardviewmodel.h @@ -0,0 +1,181 @@ +#ifndef DASHBOARDVIEWMODEL_H +#define DASHBOARDVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; +class QTimer; +class QWidget; + +// ===== 管理端 Dashboard 的 QML 数据层 ===== +// 角色全部扁平化为字符串/数值/布尔,图片以 image://emby/ URL 提供, +// QCoro 协程只存在于 C++ 侧,QML 通过属性与信号感知结果。 + +// 正在播放的会话卡片。按 sessionId|itemId 增量同步并保留 30 秒宽限, +// 与旧 Widgets 版 PageDashboard 的卡片复用策略一致,避免刷新闪烁。 +class NowPlayingListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles + { + TitleRole = Qt::UserRole + 1, + SubtitleRole, + UserLineRole, + StreamLineRole, + ProgressRole, + TimeTextRole, + PausedRole, + ImageSourceRole, + }; + + using QAbstractListModel::QAbstractListModel; + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void sync(const QList &playing); + +private: + struct Entry + { + QString key; + SessionInfo session; + qint64 lastSeenMs = 0; + }; + QList m_entries; +}; + +// 活动会话卡片(纯文本三行) +class SessionListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles + { + UserNameRole = Qt::UserRole + 1, + ClientLineRole, + InfoLineRole, + }; + + using QAbstractListModel::QAbstractListModel; + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void reset(const QList &sessions); + +private: + struct Row + { + QString userName; + QString clientLine; + QString infoLine; + }; + QList m_rows; +}; + +// 活动日志 +class ActivityListModel : public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles + { + NameRole = Qt::UserRole + 1, + OverviewRole, + DateTextRole, + SeverityRole, + }; + + using QAbstractListModel::QAbstractListModel; + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void reset(const QList &entries); + +private: + QList m_entries; +}; + +class DashboardViewModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString serverName READ serverName NOTIFY infoChanged) + Q_PROPERTY(QString version READ version NOTIFY infoChanged) + Q_PROPERTY(QString operatingSystem READ operatingSystem NOTIFY infoChanged) + Q_PROPERTY(QString address READ address NOTIFY infoChanged) + Q_PROPERTY(bool canRestart READ canRestart NOTIFY infoChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY refreshingChanged) + Q_PROPERTY(bool operationBusy READ operationBusy NOTIFY operationBusyChanged) + Q_PROPERTY(QAbstractListModel *nowPlayingModel READ nowPlayingModel CONSTANT) + Q_PROPERTY(QAbstractListModel *sessionsModel READ sessionsModel CONSTANT) + Q_PROPERTY(QAbstractListModel *activityModel READ activityModel CONSTANT) + +public: + // dialogParent: ModernMessageBox / 确认弹窗的宿主 Widget + explicit DashboardViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent = nullptr); + + QString serverName() const { return m_serverName; } + QString version() const { return m_version; } + QString operatingSystem() const { return m_operatingSystem; } + QString address() const { return m_address; } + bool canRestart() const { return m_canRestart; } + bool refreshing() const { return m_refreshing; } + bool operationBusy() const { return m_operationBusy; } + + QAbstractListModel *nowPlayingModel() { return &m_nowPlayingModel; } + QAbstractListModel *sessionsModel() { return &m_sessionsModel; } + QAbstractListModel *activityModel() { return &m_activityModel; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void restartServer(); + Q_INVOKABLE void shutdownServer(); + +public slots: + // 页面可见时开启 2 秒轮询,隐藏时停止(与旧版 show/hideEvent 一致) + void setActive(bool active); + +signals: + void infoChanged(); + void refreshingChanged(); + void operationBusyChanged(); + +private: + QCoro::Task loadData(bool isManual); + QCoro::Task doRestart(); + QCoro::Task doShutdown(); + + QEmbyCore *m_core; + QPointer m_dialogParent; + QTimer *m_refreshTimer; + + NowPlayingListModel m_nowPlayingModel; + SessionListModel m_sessionsModel; + ActivityListModel m_activityModel; + + QString m_serverName; + QString m_version; + QString m_operatingSystem; + QString m_address; + bool m_canRestart = false; + bool m_refreshing = false; + bool m_loadingData = false; + bool m_active = false; + bool m_operationBusy = false; + quint64 m_generation = 0; + quint64 m_operationGeneration = 0; +}; + +#endif // DASHBOARDVIEWMODEL_H diff --git a/src/qEmbyApp/quick/embyimageprovider.cpp b/src/qEmbyApp/quick/embyimageprovider.cpp new file mode 100644 index 0000000..9ff0fd8 --- /dev/null +++ b/src/qEmbyApp/quick/embyimageprovider.cpp @@ -0,0 +1,148 @@ +#include "embyimageprovider.h" + +#include +#include + +#include +#include +#include +#include + +#include + +#include "../tokens/ColorTokens.h" + +QQuickTextureFactory *EmbyImageResponse::textureFactory() const +{ + return QQuickTextureFactory::textureFactoryForImage(m_image); +} + +void EmbyImageResponse::complete(const QImage &image) +{ + m_image = image; + emit finished(); +} + +EmbyImageProvider::EmbyImageProvider(QEmbyCore *core) : m_core(core) {} + +namespace { + +struct ImageCandidate +{ + QString itemId; + QString imageType; + QString imageTag; +}; + +QCoro::Task fetchCandidates(QPointer core, + QList candidates, int maxWidth, + QPointer response) +{ + for (const auto &candidate : candidates) + { + if (!core || !response) + break; + + try + { + auto pixmap = co_await core->mediaService()->fetchImage( + candidate.itemId, candidate.imageType, candidate.imageTag, maxWidth); + + if (!response) + co_return; + + if (!pixmap.isNull()) + { + response->complete(pixmap.toImage()); + co_return; + } + } + catch (...) + { + // 单个候选失败继续尝试下一个 + } + } + + if (response) + response->complete(QImage()); +} + +} // namespace + +QQuickImageResponse *EmbyImageProvider::requestImageResponse(const QString &id, + const QSize &requestedSize) +{ + auto *response = new EmbyImageResponse(); + + QList candidates; + const QStringList parts = id.split(QLatin1Char(';'), Qt::SkipEmptyParts); + for (const QString &part : parts) + { + const QStringList fields = part.split(QLatin1Char(',')); + if (fields.size() >= 2) + candidates.append({fields.at(0), fields.at(1), + fields.size() > 2 ? fields.at(2) : QString()}); + } + + const int maxWidth = requestedSize.width() > 0 ? requestedSize.width() : 280; + + if (candidates.isEmpty() || !m_core) + { + // 无法请求时也必须 finish,否则 QML Image 会一直处于 Loading + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [response]() { response->complete(QImage()); }, Qt::QueuedConnection); + return response; + } + + // MediaService 与 QCoro 协程都只在主线程使用,这里从 Quick 的 + // 加载线程切回主线程发起请求 + QPointer core = m_core; + QPointer safeResponse(response); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [core, candidates, maxWidth, safeResponse]() + { fetchCandidates(core, candidates, maxWidth, safeResponse); }, + Qt::QueuedConnection); + + return response; +} + +AdaptiveIconProvider::AdaptiveIconProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + +QImage AdaptiveIconProvider::requestImage(const QString &id, QSize *size, + const QSize &requestedSize) +{ + QString path = id; + bool dark = true; + const int queryPos = path.indexOf(QLatin1Char('?')); + if (queryPos >= 0) + { + const QUrlQuery query(path.mid(queryPos + 1)); + dark = query.queryItemValue(QStringLiteral("d")) != QLatin1String("0"); + path.truncate(queryPos); + } + if (!path.startsWith(QLatin1Char(':'))) + path.prepend(QLatin1Char(':')); + + // 与 ThemeManager::getAdaptiveIcon 相同的目标色(Slate 300 / Slate 600) + const QColor target = dark ? ColorTokens::Slate::k300 : ColorTokens::Slate::k600; + + const int side = requestedSize.width() > 0 ? requestedSize.width() : 64; + QImage image(side, side, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + + QSvgRenderer renderer(path); + if (renderer.isValid()) + { + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + renderer.render(&painter); + painter.setCompositionMode(QPainter::CompositionMode_SourceIn); + painter.fillRect(image.rect(), target); + } + + if (size) + *size = image.size(); + return image; +} diff --git a/src/qEmbyApp/quick/embyimageprovider.h b/src/qEmbyApp/quick/embyimageprovider.h new file mode 100644 index 0000000..99149e4 --- /dev/null +++ b/src/qEmbyApp/quick/embyimageprovider.h @@ -0,0 +1,55 @@ +#ifndef EMBYIMAGEPROVIDER_H +#define EMBYIMAGEPROVIDER_H + +#include +#include +#include +#include +#include + +class QEmbyCore; + +// image://emby/<候选1>;<候选2>;... +// 每个候选为 "itemId,imageType,imageTag",按顺序尝试直到取到图片, +// 与 NowPlayingCard::loadImage 的回退顺序一致。 +// 实际下载复用 MediaService::fetchImage(鉴权、磁盘缓存、画质设置)。 +// 期望尺寸通过 QML 的 sourceSize.width 传入。 +class EmbyImageResponse : public QQuickImageResponse +{ + Q_OBJECT +public: + QQuickTextureFactory *textureFactory() const override; + + // 只能在主线程调用;空图片表示所有候选都失败 + void complete(const QImage &image); + +private: + QImage m_image; +}; + +class EmbyImageProvider : public QQuickAsyncImageProvider +{ +public: + explicit EmbyImageProvider(QEmbyCore *core); + + QQuickImageResponse *requestImageResponse(const QString &id, + const QSize &requestedSize) override; + +private: + QPointer m_core; +}; + +// image://adaptiveicon/?d=0|1 +// 复刻 ThemeManager::getAdaptiveIcon 的 SourceIn 换色,但用 QImage 实现, +// 可以安全地在 Quick 的图片加载线程执行。查询串只用来让 QML 在主题 +// 切换时强制刷新缓存。 +class AdaptiveIconProvider : public QQuickImageProvider +{ +public: + AdaptiveIconProvider(); + + QImage requestImage(const QString &id, QSize *size, + const QSize &requestedSize) override; +}; + +#endif // EMBYIMAGEPROVIDER_H diff --git a/src/qEmbyApp/quick/loginviewmodel.cpp b/src/qEmbyApp/quick/loginviewmodel.cpp new file mode 100644 index 0000000..36b4c48 --- /dev/null +++ b/src/qEmbyApp/quick/loginviewmodel.cpp @@ -0,0 +1,313 @@ +#include "loginviewmodel.h" + +#include "../components/modernmessagebox.h" +#include "../components/proxysettingsdialog.h" +#include "../components/webdavsyncdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +LoginServerListModel::LoginServerListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int LoginServerListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_servers.size(); +} + +QVariant LoginServerListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_servers.size()) + return {}; + const auto &server = m_servers.at(index.row()); + switch (role) { + case ServerIdRole: return server.id; + case NameRole: return server.name; + case UrlRole: return server.url; + case IconSourceRole: + if (server.type == ServerProfile::Jellyfin) + return QStringLiteral("qrc:/svg/jellyfin.svg"); + if (!server.iconBase64.isEmpty()) + return QStringLiteral("data:image/png;base64,") + server.iconBase64; + return QStringLiteral("qrc:/svg/emby.svg"); + default: return {}; + } +} + +QHash LoginServerListModel::roleNames() const +{ + return {{ServerIdRole, "serverId"}, {NameRole, "name"}, + {UrlRole, "serverUrl"}, {IconSourceRole, "iconSource"}}; +} + +void LoginServerListModel::resetServers(const QList &servers) +{ + beginResetModel(); + m_servers = servers; + endResetModel(); +} + +LoginViewModel::LoginViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent) + : QObject(parent), m_core(core), m_dialogParent(dialogParent), m_servers(this) +{ + QString organization = qApp->organizationName(); + QString version = qApp->applicationVersion(); + if (organization.isEmpty()) organization = QStringLiteral("AlanHJ"); + if (version.isEmpty()) version = QStringLiteral("1.0.0"); + m_versionText = QStringLiteral("%1 v%2").arg(organization, version); +} + +void LoginViewModel::setProtocol(const QString &value) +{ + const QString normalized = value.startsWith(QStringLiteral("https"), Qt::CaseInsensitive) + ? QStringLiteral("https://") : QStringLiteral("http://"); + if (m_protocol == normalized) return; + m_protocol = normalized; + if (m_protocol != QStringLiteral("https://")) m_ignoreSsl = false; + emit formChanged(); +} + +void LoginViewModel::setAddress(const QString &value) +{ + if (m_address == value) return; + m_address = value; + if (value.trimmed().startsWith(QStringLiteral("https://"), Qt::CaseInsensitive)) + m_protocol = QStringLiteral("https://"); + else if (value.trimmed().startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)) + m_protocol = QStringLiteral("http://"); + emit formChanged(); +} + +void LoginViewModel::setPort(const QString &value) +{ if (m_port != value) { m_port = value; emit formChanged(); } } +void LoginViewModel::setUsername(const QString &value) +{ if (m_username != value) { m_username = value; emit formChanged(); } } +void LoginViewModel::setPassword(const QString &value) +{ if (m_password != value) { m_password = value; emit formChanged(); } } +void LoginViewModel::setIgnoreSsl(bool value) +{ if (m_ignoreSsl != value) { m_ignoreSsl = value; emit formChanged(); } } + +void LoginViewModel::setBusy(bool value) +{ if (m_busy != value) { m_busy = value; emit busyChanged(); } } + +void LoginViewModel::setError(const QString &value) +{ if (m_errorText != value) { m_errorText = value; emit errorTextChanged(); } } + +void LoginViewModel::setEditorVisible(bool value) +{ if (m_editorVisible != value) { m_editorVisible = value; emit editorVisibleChanged(); } } + +void LoginViewModel::refreshServers() +{ + const auto values = m_core->serverManager()->servers(); + m_servers.resetServers(values); + const bool hasServersNow = !values.isEmpty(); + if (m_hasServers != hasServersNow) { + m_hasServers = hasServersNow; + emit serversChanged(); + } + if (!m_hasServers) showAdd(); +} + +void LoginViewModel::activate() +{ + setBusy(false); + refreshServers(); + if (m_autoLoginAttempted) return; + m_autoLoginAttempted = true; + const bool remember = ConfigStore::instance()->get(ConfigKeys::RememberServer, false); + const QString lastId = ConfigStore::instance()->get(ConfigKeys::LastSelectedServerId); + if (!remember || lastId.isEmpty()) return; + const auto values = m_core->serverManager()->servers(); + const auto it = std::find_if(values.cbegin(), values.cend(), + [&lastId](const ServerProfile &s) { return s.id == lastId; }); + if (it != values.cend()) + QTimer::singleShot(800, this, [this, lastId] { selectServer(lastId); }); +} + +void LoginViewModel::clearForm() +{ + m_protocol = QStringLiteral("http://"); + m_address.clear(); m_port.clear(); m_username.clear(); m_password.clear(); + m_ignoreSsl = false; m_editingServerId.clear(); + m_pendingProxy = ProxyConfig{}; m_pendingUseGlobalProxy = false; + setError({}); + emit formChanged(); emit proxySummaryChanged(); +} + +void LoginViewModel::showAdd() +{ + clearForm(); + setEditorVisible(true); +} + +void LoginViewModel::cancelEdit() +{ + if (!m_hasServers) return; + clearForm(); + setEditorVisible(false); +} + +void LoginViewModel::editServer(const QString &serverId) +{ + const auto values = m_core->serverManager()->servers(); + for (const auto &server : values) { + if (server.id != serverId) continue; + m_editingServerId = server.id; + const QUrl url(server.url); + m_protocol = url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0 + ? QStringLiteral("https://") : QStringLiteral("http://"); + QString host = url.host(QUrl::FullyDecoded); + if (host.contains(':') && !host.startsWith('[')) host = QStringLiteral("[%1]").arg(host); + if (!url.path().isEmpty() && url.path() != QStringLiteral("/")) host += url.path(); + m_address = host.isEmpty() ? server.url : host; + m_port = url.port(-1) > 0 ? QString::number(url.port()) : QString(); + m_username = server.userName; m_password.clear(); + m_ignoreSsl = server.ignoreSslVerification; + m_pendingProxy = server.proxy; m_pendingUseGlobalProxy = server.useGlobalProxy; + setError({}); emit formChanged(); emit proxySummaryChanged(); + setEditorVisible(true); + return; + } +} + +void LoginViewModel::removeServer(const QString &serverId) +{ + if (!m_dialogParent) return; + const bool confirmed = ModernMessageBox::question( + m_dialogParent, tr("Remove Server"), + tr("Are you sure you want to remove this server?\nThis action cannot be undone."), + tr("Remove"), tr("Cancel"), ModernMessageBox::Danger); + if (confirmed) { m_core->serverManager()->removeServer(serverId); refreshServers(); } +} + +QUrl LoginViewModel::normalizedUrl(QString *error) const +{ + QString candidate = m_address.trimmed(); + if (candidate.isEmpty()) { *error = tr("Please enter a valid server address."); return {}; } + if (!candidate.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive) && + !candidate.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive)) + candidate.prepend(m_protocol); + QUrl url(candidate, QUrl::TolerantMode); + if (!url.isValid() || url.host().isEmpty() || + (url.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0 && + url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) != 0)) { + *error = tr("Please enter a valid HTTP or HTTPS server address."); return {}; + } + if (!m_port.trimmed().isEmpty()) { + bool ok = false; const int value = m_port.toInt(&ok); + if (!ok || value < 1 || value > 65535) { *error = tr("Please enter a valid port number."); return {}; } + url.setPort(value); + } + url.setUserName(QString()); + url.setPassword(QString()); + url.setQuery(QString()); + url.setFragment(QString()); + QString path = url.path(); + while (path.size() > 1 && path.endsWith('/')) path.chop(1); + if (path == QStringLiteral("/")) path.clear(); + url.setPath(path); + return url; +} + +void LoginViewModel::selectServer(const QString &serverId) +{ if (!m_busy) validateServer(serverId); } + +QCoro::Task LoginViewModel::validateServer(QString serverId) +{ + QPointer guard(this); setError({}); setBusy(true); + try { + co_await m_core->authService()->validateSession(serverId); + if (!guard) co_return; + setBusy(false); + ConfigStore::instance()->set(ConfigKeys::LastSelectedServerId, serverId); + emit loginCompleted(); + } catch (const std::exception &e) { + if (!guard) co_return; + setBusy(false); refreshServers(); editServer(serverId); + setError(tr("Failed to connect or session expired. Please log in again.\nError: %1") + .arg(QString::fromUtf8(e.what()))); + } +} + +void LoginViewModel::submit() +{ if (!m_busy) performLogin(); } + +QCoro::Task LoginViewModel::performLogin() +{ + if (m_address.trimmed().isEmpty() || m_username.trimmed().isEmpty()) { + setError(tr("Server address and username cannot be empty.")); co_return; + } + QString error; const QUrl url = normalizedUrl(&error); + if (!error.isEmpty()) { setError(error); co_return; } + const bool ignoreSsl = url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0 && m_ignoreSsl; + QPointer guard(this); setError({}); setBusy(true); + try { + ServerProfile profile = co_await m_core->authService()->login( + url.toString(QUrl::FullyEncoded), m_username.trimmed(), m_password, ignoreSsl); + if (!guard) co_return; + ConfigStore::instance()->set(ConfigKeys::LastSelectedServerId, profile.id); + if (!m_editingServerId.isEmpty()) m_core->serverManager()->removeServer(m_editingServerId); + if (m_pendingProxy != ProxyConfig{} || m_pendingUseGlobalProxy) + m_core->serverManager()->updateServerProxy(profile.id, m_pendingProxy, m_pendingUseGlobalProxy); + setBusy(false); emit loginCompleted(); + } catch (const std::exception &e) { + if (!guard) co_return; + setBusy(false); setError(QString::fromUtf8(e.what())); + } +} + +QString LoginViewModel::proxySummary() const +{ + if (m_pendingUseGlobalProxy) return tr("Using global proxy"); + switch (m_pendingProxy.mode) { + case ProxyConfig::None: return tr("No proxy"); + case ProxyConfig::System: return tr("System proxy"); + case ProxyConfig::Custom: + if (m_pendingProxy.host.isEmpty() || m_pendingProxy.port == 0) return tr("Custom (incomplete)"); + return QStringLiteral("%1 %2:%3").arg( + m_pendingProxy.type == ProxyConfig::Socks5 ? QStringLiteral("SOCKS5") : QStringLiteral("HTTP"), + m_pendingProxy.host, QString::number(m_pendingProxy.port)); + } + return {}; +} + +void LoginViewModel::configureProxy() +{ + if (!m_dialogParent) return; + ProxySettingsDialog *dialog = nullptr; + if (!m_editingServerId.isEmpty()) + dialog = ProxySettingsDialog::createForServer(m_core->serverManager(), m_editingServerId, m_dialogParent); + else + dialog = ProxySettingsDialog::createForDraft(m_pendingProxy, m_pendingUseGlobalProxy, m_dialogParent); + if (dialog->exec() == QDialog::Accepted) { + if (!m_editingServerId.isEmpty()) { + for (const auto &server : m_core->serverManager()->servers()) + if (server.id == m_editingServerId) { m_pendingProxy = server.proxy; m_pendingUseGlobalProxy = server.useGlobalProxy; break; } + } else { + m_pendingProxy = dialog->resultConfig(); m_pendingUseGlobalProxy = dialog->resultUseGlobal(); + } + emit proxySummaryChanged(); + } + dialog->deleteLater(); +} + +void LoginViewModel::cloudSync() +{ + if (!m_dialogParent) return; + if (!m_webdavStore) { m_webdavStore = new WebdavProfileStore(this); m_webdavStore->load(); } + WebdavSyncDialog dialog(m_webdavStore, m_core->serverManager(), m_dialogParent); + dialog.exec(); refreshServers(); +} diff --git a/src/qEmbyApp/quick/loginviewmodel.h b/src/qEmbyApp/quick/loginviewmodel.h new file mode 100644 index 0000000..dd3a29e --- /dev/null +++ b/src/qEmbyApp/quick/loginviewmodel.h @@ -0,0 +1,124 @@ +#ifndef LOGINVIEWMODEL_H +#define LOGINVIEWMODEL_H + +#include +#include +#include +#include +#include + +#include +#include + +class QEmbyCore; +class QWidget; +class WebdavProfileStore; + +class LoginServerListModel final : public QAbstractListModel +{ + Q_OBJECT +public: + enum Role { ServerIdRole = Qt::UserRole + 1, NameRole, UrlRole, IconSourceRole }; + explicit LoginServerListModel(QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + void resetServers(const QList &servers); +private: + QList m_servers; +}; + +class LoginViewModel final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QAbstractListModel *servers READ servers CONSTANT) + Q_PROPERTY(bool editorVisible READ editorVisible NOTIFY editorVisibleChanged) + Q_PROPERTY(bool hasServers READ hasServers NOTIFY serversChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) + Q_PROPERTY(QString protocol READ protocol WRITE setProtocol NOTIFY formChanged) + Q_PROPERTY(QString address READ address WRITE setAddress NOTIFY formChanged) + Q_PROPERTY(QString port READ port WRITE setPort NOTIFY formChanged) + Q_PROPERTY(QString username READ username WRITE setUsername NOTIFY formChanged) + Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY formChanged) + Q_PROPERTY(bool ignoreSsl READ ignoreSsl WRITE setIgnoreSsl NOTIFY formChanged) + Q_PROPERTY(bool editing READ editing NOTIFY formChanged) + Q_PROPERTY(QString proxySummary READ proxySummary NOTIFY proxySummaryChanged) + Q_PROPERTY(QString versionText READ versionText CONSTANT) + +public: + LoginViewModel(QEmbyCore *core, QWidget *dialogParent, QObject *parent = nullptr); + + QAbstractListModel *servers() { return &m_servers; } + bool editorVisible() const { return m_editorVisible; } + bool hasServers() const { return m_hasServers; } + bool busy() const { return m_busy; } + QString errorText() const { return m_errorText; } + QString protocol() const { return m_protocol; } + QString address() const { return m_address; } + QString port() const { return m_port; } + QString username() const { return m_username; } + QString password() const { return m_password; } + bool ignoreSsl() const { return m_ignoreSsl; } + bool editing() const { return !m_editingServerId.isEmpty(); } + QString proxySummary() const; + QString versionText() const { return m_versionText; } + + void setProtocol(const QString &value); + void setAddress(const QString &value); + void setPort(const QString &value); + void setUsername(const QString &value); + void setPassword(const QString &value); + void setIgnoreSsl(bool value); + + void activate(); + Q_INVOKABLE void showAdd(); + Q_INVOKABLE void cancelEdit(); + Q_INVOKABLE void editServer(const QString &serverId); + Q_INVOKABLE void removeServer(const QString &serverId); + Q_INVOKABLE void selectServer(const QString &serverId); + Q_INVOKABLE void submit(); + Q_INVOKABLE void configureProxy(); + Q_INVOKABLE void cloudSync(); + +signals: + void loginCompleted(); + void editorVisibleChanged(); + void serversChanged(); + void busyChanged(); + void errorTextChanged(); + void formChanged(); + void proxySummaryChanged(); + +private: + void refreshServers(); + void setBusy(bool value); + void setError(const QString &value); + void setEditorVisible(bool value); + void clearForm(); + QUrl normalizedUrl(QString *error) const; + QCoro::Task validateServer(QString serverId); + QCoro::Task performLogin(); + + QEmbyCore *m_core = nullptr; + QPointer m_dialogParent; + LoginServerListModel m_servers; + WebdavProfileStore *m_webdavStore = nullptr; + ProxyConfig m_pendingProxy; + bool m_pendingUseGlobalProxy = false; + bool m_editorVisible = false; + bool m_hasServers = false; + bool m_busy = false; + bool m_ignoreSsl = false; + bool m_autoLoginAttempted = false; + QString m_errorText; + QString m_protocol = QStringLiteral("http://"); + QString m_address; + QString m_port; + QString m_username; + QString m_password; + QString m_editingServerId; + QString m_versionText; +}; + +#endif // LOGINVIEWMODEL_H diff --git a/src/qEmbyApp/quick/logsviewmodel.cpp b/src/qEmbyApp/quick/logsviewmodel.cpp new file mode 100644 index 0000000..b09a429 --- /dev/null +++ b/src/qEmbyApp/quick/logsviewmodel.cpp @@ -0,0 +1,295 @@ +#include "logsviewmodel.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +QDateTime parseDate(QString value) { + QDateTime result = QDateTime::fromString(value, Qt::ISODateWithMs); + if (!result.isValid()) + result = QDateTime::fromString(value, Qt::ISODate); + return result; +} + +QString displayDate(const QString &value, const QString &format) { + const QDateTime date = parseDate(value); + return date.isValid() ? date.toLocalTime().toString(format) : value; +} +} // namespace + +LogsListModel::LogsListModel(QObject *parent) : QAbstractListModel(parent) {} + +int LogsListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_logs.size(); +} + +QVariant LogsListModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_logs.size()) + return {}; + const LogFileInfo &log = m_logs.at(index.row()); + switch (role) { + case NameRole: + return log.name; + case ModifiedRole: + return displayDate(log.dateModified, QStringLiteral("yyyy-MM-dd HH:mm:ss")); + case ModifiedCompactRole: + return displayDate(log.dateModified, QStringLiteral("MM-dd HH:mm")); + case SizeRole: + return log.readableSize(); + } + return {}; +} + +QHash LogsListModel::roleNames() const { + return {{NameRole, "name"}, + {ModifiedRole, "modified"}, + {ModifiedCompactRole, "modifiedCompact"}, + {SizeRole, "sizeText"}}; +} + +void LogsListModel::resetLogs(QList logs) { + std::sort(logs.begin(), logs.end(), [](const auto &left, const auto &right) { + const QDateTime leftDate = parseDate(left.dateModified); + const QDateTime rightDate = parseDate(right.dateModified); + if (leftDate.isValid() != rightDate.isValid()) + return leftDate.isValid(); + if (leftDate.isValid() && leftDate != rightDate) + return leftDate > rightDate; + return QString::localeAwareCompare(left.name, right.name) < 0; + }); + beginResetModel(); + m_logs = std::move(logs); + endResetModel(); +} + +QString LogsListModel::firstName() const { + return m_logs.isEmpty() ? QString() : m_logs.constFirst().name; +} + +const LogFileInfo *LogsListModel::find(const QString &name) const { + for (const LogFileInfo &log : m_logs) { + if (log.name == name) + return &log; + } + return nullptr; +} + +LogsViewModel::LogsViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent) + : QObject(parent), m_core(core), m_dialogParent(dialogParent), + m_model(this) {} + +QString LogsViewModel::selectedMeta() const { + const LogFileInfo *log = m_model.find(m_selectedName); + if (!log) + return tr( + "Choose a log file from the list to inspect the latest server output."); + QStringList parts{log->readableSize()}; + const QString modified = + displayDate(log->dateModified, QStringLiteral("yyyy-MM-dd HH:mm:ss")); + if (!modified.isEmpty()) + parts.append(tr("Modified %1").arg(modified)); + return parts.join(QStringLiteral(" · ")); +} + +void LogsViewModel::refresh() { + if (m_active) + loadFiles(true); +} + +void LogsViewModel::selectLog(const QString &name) { + if (!m_active || name.isEmpty() || !m_model.find(name)) + return; + if (name == m_selectedName) { + if (!m_contentLoading && !m_contentError.isEmpty()) + loadContent(name); + return; + } + m_selectedName = name; + m_content.clear(); + m_contentError.clear(); + emit selectionChanged(); + emit contentChanged(); + loadContent(name); +} + +void LogsViewModel::saveLog(const QString &name) { + if (!m_active || m_saving || name.isEmpty() || !m_model.find(name) || + !m_dialogParent) + return; + QString directory = + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation); + if (directory.isEmpty()) + directory = QDir::homePath(); + const QString path = QFileDialog::getSaveFileName( + m_dialogParent, tr("Save Log File"), QDir(directory).filePath(name), + tr("Text Files (*.txt);;All Files (*)")); + if (!path.isEmpty()) + saveContent(name, path); +} + +void LogsViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + ++m_activation; + ++m_listRequest; + ++m_contentRequest; + if (active) { + if (!m_hasLoaded) + loadFiles(false); + return; + } + m_loading = false; + m_refreshing = false; + m_contentLoading = false; + m_saving = false; + emit stateChanged(); + emit contentChanged(); + emit savingChanged(); +} + +QCoro::Task LogsViewModel::loadFiles(bool manual) { + if (!m_active || (m_loading && !manual)) + co_return; + const quint64 activation = m_activation; + const quint64 request = ++m_listRequest; + QPointer guard(this); + m_loading = true; + m_refreshing = manual; + if (!m_hasLoaded) + m_errorMessage.clear(); + emit stateChanged(); + + try { + QList logs = co_await m_core->adminService()->getLogFiles(); + if (!guard || !listRequestIsCurrent(activation, request)) + co_return; + const QString preferred = m_selectedName; + m_model.resetLogs(std::move(logs)); + m_hasLoaded = true; + m_errorMessage.clear(); + m_statsText = m_model.rowCount() == 0 + ? tr("No files") + : tr("%n file(s)", "", m_model.rowCount()); + emit summaryChanged(); + + const QString selected = + m_model.find(preferred) ? preferred : m_model.firstName(); + if (selected.isEmpty()) { + clearSelection(); + } else { + const bool selectionChangedValue = selected != m_selectedName; + m_selectedName = selected; + if (selectionChangedValue) + emit selectionChanged(); + loadContent(selected); + } + } catch (const std::exception &error) { + if (!guard || !listRequestIsCurrent(activation, request)) + co_return; + m_errorMessage = + tr("Failed to load log files: %1").arg(QString::fromUtf8(error.what())); + if (m_hasLoaded) + emit toastRequested(m_errorMessage, QStringLiteral("error")); + } + + if (!guard || !listRequestIsCurrent(activation, request)) + co_return; + m_loading = false; + m_refreshing = false; + emit stateChanged(); +} + +QCoro::Task LogsViewModel::loadContent(QString name) { + const quint64 activation = m_activation; + const quint64 request = ++m_contentRequest; + QPointer guard(this); + m_contentLoading = true; + m_contentError.clear(); + emit contentChanged(); + try { + const QString result = + co_await m_core->adminService()->getLogFileContent(name); + if (!guard || !contentRequestIsCurrent(activation, request, name)) + co_return; + m_content = result; + } catch (const std::exception &error) { + if (!guard || !contentRequestIsCurrent(activation, request, name)) + co_return; + m_content.clear(); + m_contentError = + tr("Failed to load: %1").arg(QString::fromUtf8(error.what())); + } + if (!guard || !contentRequestIsCurrent(activation, request, name)) + co_return; + m_contentLoading = false; + emit contentChanged(); +} + +QCoro::Task LogsViewModel::saveContent(QString name, QString path) { + const quint64 activation = m_activation; + QPointer guard(this); + m_saving = true; + emit savingChanged(); + try { + const QString content = + co_await m_core->adminService()->getLogFileContent(name); + if (!guard || !m_active || activation != m_activation) + co_return; + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + throw std::runtime_error(file.errorString().toStdString()); + const QByteArray bytes = content.toUtf8(); + if (file.write(bytes) != bytes.size() || !file.commit()) + throw std::runtime_error(file.errorString().toStdString()); + emit toastRequested( + tr("Log file \"%1\" saved").arg(QFileInfo(path).fileName()), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard || !m_active || activation != m_activation) + co_return; + emit toastRequested( + tr("Failed to save log file: %1").arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + if (!guard || !m_active || activation != m_activation) + co_return; + m_saving = false; + emit savingChanged(); +} + +bool LogsViewModel::listRequestIsCurrent(quint64 activation, + quint64 request) const { + return m_active && activation == m_activation && request == m_listRequest; +} + +bool LogsViewModel::contentRequestIsCurrent(quint64 activation, quint64 request, + const QString &name) const { + return m_active && activation == m_activation && + request == m_contentRequest && name == m_selectedName; +} + +void LogsViewModel::clearSelection() { + ++m_contentRequest; + m_selectedName.clear(); + m_content.clear(); + m_contentError.clear(); + m_contentLoading = false; + emit selectionChanged(); + emit contentChanged(); +} diff --git a/src/qEmbyApp/quick/logsviewmodel.h b/src/qEmbyApp/quick/logsviewmodel.h new file mode 100644 index 0000000..52b2971 --- /dev/null +++ b/src/qEmbyApp/quick/logsviewmodel.h @@ -0,0 +1,114 @@ +#ifndef LOGSVIEWMODEL_H +#define LOGSVIEWMODEL_H + +#include +#include + +#include +#include + +class QEmbyCore; +class QWidget; + +class LogsListModel : public QAbstractListModel { + Q_OBJECT +public: + enum Role { + NameRole = Qt::UserRole + 1, + ModifiedRole, + ModifiedCompactRole, + SizeRole, + }; + + explicit LogsListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void resetLogs(QList logs); + QString firstName() const; + const LogFileInfo *find(const QString &name) const; + +private: + QList m_logs; +}; + +class LogsViewModel : public QObject { + Q_OBJECT + Q_PROPERTY(QAbstractListModel *model READ model CONSTANT) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(bool hasLoaded READ hasLoaded NOTIFY stateChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY stateChanged) + Q_PROPERTY(int totalCount READ totalCount NOTIFY summaryChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY summaryChanged) + Q_PROPERTY(QString selectedName READ selectedName NOTIFY selectionChanged) + Q_PROPERTY(QString selectedMeta READ selectedMeta NOTIFY selectionChanged) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + Q_PROPERTY(bool contentLoading READ contentLoading NOTIFY contentChanged) + Q_PROPERTY(QString contentError READ contentError NOTIFY contentChanged) + Q_PROPERTY(bool saving READ saving NOTIFY savingChanged) + +public: + explicit LogsViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent = nullptr); + + QAbstractListModel *model() { return &m_model; } + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + bool hasLoaded() const { return m_hasLoaded; } + QString errorMessage() const { return m_errorMessage; } + int totalCount() const { return m_model.rowCount(); } + QString statsText() const { return m_statsText; } + QString selectedName() const { return m_selectedName; } + QString selectedMeta() const; + QString content() const { return m_content; } + bool contentLoading() const { return m_contentLoading; } + QString contentError() const { return m_contentError; } + bool saving() const { return m_saving; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void selectLog(const QString &name); + Q_INVOKABLE void saveLog(const QString &name); + +public slots: + void setActive(bool active); + +signals: + void stateChanged(); + void summaryChanged(); + void selectionChanged(); + void contentChanged(); + void savingChanged(); + void toastRequested(const QString &message, const QString &kind); + +private: + QCoro::Task loadFiles(bool manual); + QCoro::Task loadContent(QString name); + QCoro::Task saveContent(QString name, QString path); + bool listRequestIsCurrent(quint64 activation, quint64 request) const; + bool contentRequestIsCurrent(quint64 activation, quint64 request, + const QString &name) const; + void clearSelection(); + + QEmbyCore *m_core = nullptr; + QPointer m_dialogParent; + LogsListModel m_model; + bool m_active = false; + bool m_loading = false; + bool m_refreshing = false; + bool m_hasLoaded = false; + bool m_contentLoading = false; + bool m_saving = false; + QString m_errorMessage; + QString m_statsText; + QString m_selectedName; + QString m_content; + QString m_contentError; + quint64 m_activation = 0; + quint64 m_listRequest = 0; + quint64 m_contentRequest = 0; +}; + +#endif // LOGSVIEWMODEL_H diff --git a/src/qEmbyApp/quick/navigationcontroller.cpp b/src/qEmbyApp/quick/navigationcontroller.cpp new file mode 100644 index 0000000..780d607 --- /dev/null +++ b/src/qEmbyApp/quick/navigationcontroller.cpp @@ -0,0 +1,315 @@ +#include "navigationcontroller.h" + +#include +#include +#include + +namespace +{ +const QSet kRouteNames = { + QStringLiteral("login"), QStringLiteral("home"), + QStringLiteral("favorites"), QStringLiteral("detail"), + QStringLiteral("category"), QStringLiteral("library"), + QStringLiteral("person"), QStringLiteral("search"), + QStringLiteral("filtered"), QStringLiteral("season"), + QStringLiteral("player"), QStringLiteral("settings"), + QStringLiteral("manage"), QStringLiteral("navigationError")}; + +const QSet kSettingsSections = { + QStringLiteral("general"), QStringLiteral("appearance"), + QStringLiteral("library"), QStringLiteral("player"), + QStringLiteral("about")}; + +const QSet kManageSections = { + QStringLiteral("dashboard"), QStringLiteral("libraries"), + QStringLiteral("collections"), QStringLiteral("transcoding"), + QStringLiteral("users"), QStringLiteral("tasks"), + QStringLiteral("logs")}; + +bool isSafeValue(const QVariant &value, int depth = 0) +{ + if (depth > 8 || value.metaType().flags().testFlag(QMetaType::PointerToQObject)) + return false; + + switch (value.metaType().id()) + { + case QMetaType::UnknownType: + case QMetaType::Bool: + case QMetaType::Int: + case QMetaType::UInt: + case QMetaType::LongLong: + case QMetaType::ULongLong: + case QMetaType::Double: + case QMetaType::QString: + return true; + case QMetaType::QVariantList: + for (const QVariant &entry : value.toList()) + if (!isSafeValue(entry, depth + 1)) + return false; + return true; + case QMetaType::QVariantMap: + for (const QVariant &entry : value.toMap()) + if (!isSafeValue(entry, depth + 1)) + return false; + return true; + default: + return false; + } +} + +QString requiredString(const QVariantMap ¶ms, const QString &name) +{ + return params.value(name).toString().trimmed(); +} + +QString encoded(const QString &value) +{ + return QString::fromLatin1(QUrl::toPercentEncoding(value)); +} +} // namespace + +NavigationController::NavigationController(QObject *parent) : QObject(parent) +{ + NavigationRoute login; + login.name = QStringLiteral("login"); + login.key = QStringLiteral("login"); + login.cachePolicy = QStringLiteral("singleton"); + m_stack.append(login); +} + +QVariantMap NavigationController::currentRoute() const +{ + return m_stack.isEmpty() ? QVariantMap{} : m_stack.constLast().toVariantMap(); +} + +bool NavigationController::canGoBack() const +{ + return m_stack.size() > 1; +} + +int NavigationController::depth() const +{ + return m_stack.size(); +} + +bool NavigationController::normalize(const QVariantMap &request, + NavigationRoute *route, + QString *errorCode, + QString *errorMessage) const +{ + const QString name = request.value(QStringLiteral("name")).toString().trimmed(); + if (!kRouteNames.contains(name)) + { + *errorCode = QStringLiteral("unknownRoute"); + *errorMessage = QStringLiteral("Unknown route: %1").arg(name); + return false; + } + + const QVariant paramsValue = request.value(QStringLiteral("params"), QVariantMap{}); + if (paramsValue.metaType().id() != QMetaType::QVariantMap || !isSafeValue(paramsValue)) + { + *errorCode = QStringLiteral("invalidParams"); + *errorMessage = QStringLiteral("Route params must contain copy-safe values"); + return false; + } + QVariantMap params = paramsValue.toMap(); + + QStringList required; + if (name == QLatin1String("detail")) + required << QStringLiteral("itemId"); + else if (name == QLatin1String("player")) + required << QStringLiteral("mediaId"); + else if (name == QLatin1String("category")) + required << QStringLiteral("categoryId"); + else if (name == QLatin1String("library")) + required << QStringLiteral("libraryId"); + else if (name == QLatin1String("person")) + required << QStringLiteral("personId"); + else if (name == QLatin1String("search")) + required << QStringLiteral("query"); + else if (name == QLatin1String("filtered")) + required << QStringLiteral("filterType") << QStringLiteral("filterValue"); + else if (name == QLatin1String("season")) + required << QStringLiteral("seriesId") << QStringLiteral("seasonId"); + else if (name == QLatin1String("navigationError")) + required << QStringLiteral("code"); + + for (const QString &field : required) + { + const QString value = requiredString(params, field); + if (value.isEmpty() || value.size() > 2048) + { + *errorCode = QStringLiteral("invalidParams"); + *errorMessage = QStringLiteral("Missing or invalid route parameter: %1").arg(field); + return false; + } + params.insert(field, value); + } + + if (!m_authenticated && name != QLatin1String("login") && + name != QLatin1String("navigationError")) + { + *errorCode = QStringLiteral("authenticationRequired"); + *errorMessage = QStringLiteral("Authentication is required for this route"); + return false; + } + if (name == QLatin1String("manage") && !m_administrator) + { + *errorCode = QStringLiteral("permissionDenied"); + *errorMessage = QStringLiteral("Administrator permission is required"); + return false; + } + + const QString section = params.value(QStringLiteral("section")).toString(); + if (name == QLatin1String("settings") && !section.isEmpty() && + !kSettingsSections.contains(section)) + { + *errorCode = QStringLiteral("invalidParams"); + *errorMessage = QStringLiteral("Unknown settings section: %1").arg(section); + return false; + } + if (name == QLatin1String("manage") && !section.isEmpty() && + !kManageSections.contains(section)) + { + *errorCode = QStringLiteral("invalidParams"); + *errorMessage = QStringLiteral("Unknown manage section: %1").arg(section); + return false; + } + + route->name = name; + route->params = params; + route->title = request.value(QStringLiteral("title"), params.value(QStringLiteral("title"))).toString(); + route->presentation = name == QLatin1String("player") ? QStringLiteral("immersive") + : QStringLiteral("page"); + route->cachePolicy = (name == QLatin1String("login") || name == QLatin1String("home") || + name == QLatin1String("favorites")) + ? QStringLiteral("singleton") + : (name == QLatin1String("player") || name == QLatin1String("navigationError")) + ? QStringLiteral("destroyOnPop") + : QStringLiteral("lru"); + + QStringList identity; + for (const QString &field : required) + identity << encoded(params.value(field).toString()); + route->key = identity.isEmpty() ? name : name + QLatin1Char(':') + identity.join(QLatin1Char(':')); + return true; +} + +bool NavigationController::reject(const QString &code, const QString &message, + const QVariantMap &request) +{ + Q_EMIT navigationRejected(code, message, request); + return false; +} + +void NavigationController::notifyRouteAndStackChanged() +{ + Q_EMIT currentRouteChanged(); + Q_EMIT stackChanged(); +} + +bool NavigationController::push(const QVariantMap &request) +{ + NavigationRoute route; + QString code; + QString message; + if (!normalize(request, &route, &code, &message)) + return reject(code, message, request); + m_stack.append(route); + notifyRouteAndStackChanged(); + return true; +} + +bool NavigationController::replace(const QVariantMap &request) +{ + NavigationRoute route; + QString code; + QString message; + if (!normalize(request, &route, &code, &message)) + return reject(code, message, request); + if (m_stack.isEmpty()) + m_stack.append(route); + else + m_stack.last() = route; + notifyRouteAndStackChanged(); + return true; +} + +bool NavigationController::back() +{ + if (!canGoBack()) + return false; + m_stack.removeLast(); + notifyRouteAndStackChanged(); + return true; +} + +void NavigationController::home() +{ + if (m_authenticated && m_stack.size() == 1 && + m_stack.constLast().name == QLatin1String("home")) + { + Q_EMIT refreshRequested(m_stack.constLast().key); + return; + } + resetToRoot(QStringLiteral("home")); +} + +bool NavigationController::resetToRoot(const QString &name) +{ + if (name != QLatin1String("home") && name != QLatin1String("favorites")) + return reject(QStringLiteral("invalidRoot"), QStringLiteral("Invalid session root"), + {{QStringLiteral("name"), name}}); + + NavigationRoute route; + QString code; + QString message; + const QVariantMap request{{QStringLiteral("name"), name}}; + if (!normalize(request, &route, &code, &message)) + return reject(code, message, request); + m_stack = {route}; + notifyRouteAndStackChanged(); + return true; +} + +void NavigationController::updateUiState(const QVariantMap &patch) +{ + if (m_stack.isEmpty()) + return; + QVariantMap &state = m_stack.last().uiState; + for (const QString &key : {QStringLiteral("scrollY"), QStringLiteral("section"), + QStringLiteral("sections")}) + { + const QVariant value = patch.value(key); + if (value.isValid() && isSafeValue(value)) + state.insert(key, value); + } + Q_EMIT currentRouteChanged(); +} + +void NavigationController::enterAuthenticatedSession(bool administrator) +{ + m_authenticated = true; + m_administrator = administrator; + m_stack.clear(); + resetToRoot(QStringLiteral("home")); + Q_EMIT sessionRoutesCleared(); +} + +void NavigationController::leaveAuthenticatedSession() +{ + m_authenticated = false; + m_administrator = false; + NavigationRoute login; + QString code; + QString message; + normalize({{QStringLiteral("name"), QStringLiteral("login")}}, &login, &code, &message); + m_stack = {login}; + notifyRouteAndStackChanged(); + Q_EMIT sessionRoutesCleared(); +} + +void NavigationController::setAdministrator(bool administrator) +{ + m_administrator = administrator; +} diff --git a/src/qEmbyApp/quick/navigationcontroller.h b/src/qEmbyApp/quick/navigationcontroller.h new file mode 100644 index 0000000..a06e3be --- /dev/null +++ b/src/qEmbyApp/quick/navigationcontroller.h @@ -0,0 +1,51 @@ +#pragma once + +#include "navigationroute.h" + +#include +#include + +class NavigationController final : 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: + explicit NavigationController(QObject *parent = nullptr); + + QVariantMap currentRoute() const; + bool canGoBack() const; + int depth() const; + + 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); + + bool resetToRoot(const QString &name); + void enterAuthenticatedSession(bool administrator); + void leaveAuthenticatedSession(); + void setAdministrator(bool administrator); + +Q_SIGNALS: + void currentRouteChanged(); + void stackChanged(); + void navigationRejected(const QString &code, const QString &message, + const QVariantMap &request); + void sessionRoutesCleared(); + void refreshRequested(const QString &key); + +private: + bool normalize(const QVariantMap &request, NavigationRoute *route, + QString *errorCode, QString *errorMessage) const; + bool reject(const QString &code, const QString &message, + const QVariantMap &request); + void notifyRouteAndStackChanged(); + + QVector m_stack; + bool m_authenticated = false; + bool m_administrator = false; +}; diff --git a/src/qEmbyApp/quick/navigationroute.cpp b/src/qEmbyApp/quick/navigationroute.cpp new file mode 100644 index 0000000..fec8a42 --- /dev/null +++ b/src/qEmbyApp/quick/navigationroute.cpp @@ -0,0 +1,15 @@ +#include "navigationroute.h" + +QVariantMap NavigationRoute::toVariantMap() const +{ + return { + {QStringLiteral("name"), name}, + {QStringLiteral("key"), key}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), params}, + {QStringLiteral("presentation"), presentation}, + {QStringLiteral("cachePolicy"), cachePolicy}, + {QStringLiteral("uiState"), uiState}, + {QStringLiteral("version"), version}, + }; +} diff --git a/src/qEmbyApp/quick/navigationroute.h b/src/qEmbyApp/quick/navigationroute.h new file mode 100644 index 0000000..6030f1a --- /dev/null +++ b/src/qEmbyApp/quick/navigationroute.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +struct NavigationRoute +{ + QString name; + QString key; + QString title; + QVariantMap params; + QString presentation = QStringLiteral("page"); + QString cachePolicy = QStringLiteral("lru"); + QVariantMap uiState; + int version = 1; + + QVariantMap toVariantMap() const; +}; diff --git a/src/qEmbyApp/quick/qmltask.cpp b/src/qEmbyApp/quick/qmltask.cpp new file mode 100644 index 0000000..dcc94ff --- /dev/null +++ b/src/qEmbyApp/quick/qmltask.cpp @@ -0,0 +1,8 @@ +#include "qmltask.h" + +QmlTask::QmlTask(QObject *parent) + : QObject(parent) +{ +} + +QmlTask::~QmlTask() = default; diff --git a/src/qEmbyApp/quick/qmltask.h b/src/qEmbyApp/quick/qmltask.h new file mode 100644 index 0000000..ae7d5fd --- /dev/null +++ b/src/qEmbyApp/quick/qmltask.h @@ -0,0 +1,73 @@ +#ifndef QMLTASK_H +#define QMLTASK_H + +#include +#include +#include +#include + +#include + +#include + +// QmlTask — 将 QCoro::Task 桥接为 QML 可用的信号。 +// +// 用法示例: +// QmlTask* t = QmlTask::from(viewModel->loadData(), this); +// connect(t, &QmlTask::finished, [](QVariant v) { /* ... */ }); +// connect(t, &QmlTask::error, [](QString msg) { /* ... */ }); +// +// QmlTask 生命周期由 context 管理(作为 child),不需要手动 delete。 +class QmlTask : public QObject +{ + Q_OBJECT + +public: + explicit QmlTask(QObject *parent = nullptr); + ~QmlTask() override; + + // 从 QCoro::Task 创建桥接实例。 + // context 作为 QmlTask 的 parent,控制生命周期。 + template + static QmlTask* from(QCoro::Task task, QObject *context) + { + auto *self = new QmlTask(context); + launchBridge(self, std::move(task)); + return self; + } + +signals: + // 协程成功完成时发射。void 任务发射空 QVariant。 + void finished(QVariant result); + // 协程抛出异常时发射。 + void error(QString message); + +private: + // 协程桥接体:等待 task 完成后向 QmlTask 发射信号。 + template + static QCoro::Task launchBridge(QmlTask *self, QCoro::Task task) + { + QPointer guard(self); + try { + if constexpr (std::is_same_v) { + co_await task; + if (guard) + emit guard->finished(QVariant{}); + } else { + auto value = co_await task; + if (guard) + emit guard->finished(QVariant::fromValue(value)); + } + } catch (const std::exception &e) { + if (guard) + emit guard->error(QString::fromUtf8(e.what())); + } catch (...) { + if (guard) + emit guard->error(QStringLiteral("Unknown error")); + } + } + + Q_DISABLE_COPY(QmlTask) +}; + +#endif // QMLTASK_H diff --git a/src/qEmbyApp/quick/quickpagehost.cpp b/src/qEmbyApp/quick/quickpagehost.cpp new file mode 100644 index 0000000..1f77bba --- /dev/null +++ b/src/qEmbyApp/quick/quickpagehost.cpp @@ -0,0 +1,73 @@ +#include "quickpagehost.h" + +#include "../managers/thememanager.h" +#include "../tokens/ColorTokens.h" + +#include +#include +#include +#include + +#include + +QuickPageHost::QuickPageHost(QWidget *parent) : QWidget(parent) +{ + m_quickWidget = new QQuickWidget(this); + m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + + // QQuickWidget 的透明 FBO 在部分窗口系统/显卡组合下会合成为黑色。 + // 使用与 Widgets 主题一致的不透明底色,避免页面出现黑底和文字边缘伪影。 + const auto updateClearColor = [this] + { + const bool dark = ThemeManager::instance()->isDarkMode(); + m_quickWidget->setClearColor(dark ? ColorTokens::Dark::kBgBase + : ColorTokens::Light::kBgBase); + }; + updateClearColor(); + connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, + [updateClearColor](ThemeManager::Theme) { updateClearColor(); }); + + m_quickWidget->setFocusPolicy(Qt::StrongFocus); + setFocusProxy(m_quickWidget); + + connect(m_quickWidget, &QQuickWidget::statusChanged, this, + [this](QQuickWidget::Status status) + { + if (status != QQuickWidget::Error) + return; + const auto errors = m_quickWidget->errors(); + for (const auto &error : errors) + spdlog::error("[QuickPageHost] {}", error.toString().toStdString()); + }); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_quickWidget); +} + +void QuickPageHost::setContextProperty(const QString &name, QObject *object) +{ + m_quickWidget->rootContext()->setContextProperty(name, object); +} + +void QuickPageHost::addImageProvider(const QString &providerId, QQmlImageProviderBase *provider) +{ + m_quickWidget->engine()->addImageProvider(providerId, provider); +} + +void QuickPageHost::load(const QUrl &source) +{ + m_quickWidget->setSource(source); +} + +void QuickPageHost::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + emit pageShown(); +} + +void QuickPageHost::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + emit pageHidden(); +} diff --git a/src/qEmbyApp/quick/quickpagehost.h b/src/qEmbyApp/quick/quickpagehost.h new file mode 100644 index 0000000..398d1df --- /dev/null +++ b/src/qEmbyApp/quick/quickpagehost.h @@ -0,0 +1,39 @@ +#ifndef QUICKPAGEHOST_H +#define QUICKPAGEHOST_H + +#include +#include + +class QQuickWidget; +class QQmlImageProviderBase; + +// Widgets 页面栈中承载 QML 页面的统一容器。 +// 负责 QQuickWidget 的透明背景、尺寸同步、加载错误上报, +// 并把 show/hide 转成信号供 ViewModel 控制轮询等激活逻辑。 +class QuickPageHost : public QWidget +{ + Q_OBJECT +public: + explicit QuickPageHost(QWidget *parent = nullptr); + + // 以下两个注入接口必须在 load() 之前调用 + void setContextProperty(const QString &name, QObject *object); + void addImageProvider(const QString &providerId, QQmlImageProviderBase *provider); + + void load(const QUrl &source); + + QQuickWidget *quickWidget() const { return m_quickWidget; } + +signals: + void pageShown(); + void pageHidden(); + +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + +private: + QQuickWidget *m_quickWidget; +}; + +#endif // QUICKPAGEHOST_H diff --git a/src/qEmbyApp/quick/quickrouterhost.cpp b/src/qEmbyApp/quick/quickrouterhost.cpp new file mode 100644 index 0000000..b9dbbaf --- /dev/null +++ b/src/qEmbyApp/quick/quickrouterhost.cpp @@ -0,0 +1,164 @@ +#include "quickrouterhost.h" + +#include "embyimageprovider.h" +#include "navigationcontroller.h" +#include "../managers/thememanager.h" +#include "../tokens/ColorTokens.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +const QUrl kRouterUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/Router.qml")); +} + +QuickRouterHost::QuickRouterHost(QEmbyCore *core, + NavigationController *navigation, + QWidget *parent) + : QWidget(parent), m_quickWidget(new QQuickWidget(this)) +{ + m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_quickWidget->setFocusPolicy(Qt::StrongFocus); + setFocusProxy(m_quickWidget); + + const auto updateClearColor = [this] + { + const bool dark = ThemeManager::instance()->isDarkMode(); + m_quickWidget->setClearColor(dark ? ColorTokens::Dark::kBgBase + : ColorTokens::Light::kBgBase); + }; + updateClearColor(); + connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, + [updateClearColor](ThemeManager::Theme) { updateClearColor(); }); + + m_quickWidget->rootContext()->setContextProperty( + QStringLiteral("navigation"), navigation); + registerImageProviders(core); + + connect(m_quickWidget, &QQuickWidget::statusChanged, this, + [this](QQuickWidget::Status status) + { + if (status == QQuickWidget::Ready) + { + setReady(true); + if (auto *root = m_quickWidget->rootObject()) + { + const int activeProperty = + root->metaObject()->indexOfProperty("active"); + if (activeProperty >= 0) + root->setProperty("active", m_active); + } + return; + } + + setReady(false); + if (status == QQuickWidget::Error) + reportLoadErrors(); + }); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(m_quickWidget); +} + +bool QuickRouterHost::setContextProperty(const QString &name, QObject *object) +{ + if (!m_quickWidget->source().isEmpty()) + { + spdlog::warn( + "[QuickRouterHost] refusing late context property injection: {}", + name.toStdString()); + return false; + } + + m_quickWidget->rootContext()->setContextProperty(name, object); + return true; +} + +void QuickRouterHost::loadRouter() +{ + if (m_quickWidget->source() == kRouterUrl && + m_quickWidget->status() == QQuickWidget::Ready) + { + return; + } + + setReady(false); + m_quickWidget->setSource(kRouterUrl); +} + +void QuickRouterHost::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + setActive(true); +} + +void QuickRouterHost::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + setActive(false); +} + +void QuickRouterHost::registerImageProviders(QEmbyCore *core) +{ + if (m_imageProvidersRegistered) + return; + + m_quickWidget->engine()->addImageProvider( + QStringLiteral("emby"), new EmbyImageProvider(core)); + m_quickWidget->engine()->addImageProvider( + QStringLiteral("adaptiveicon"), new AdaptiveIconProvider()); + m_imageProvidersRegistered = true; +} + +void QuickRouterHost::setActive(bool active) +{ + if (m_active == active) + return; + + m_active = active; + if (auto *root = m_quickWidget->rootObject()) + { + const int activeProperty = root->metaObject()->indexOfProperty("active"); + if (activeProperty >= 0) + root->setProperty("active", active); + } + + emit activeChanged(active); + if (active) + emit activated(); + else + emit deactivated(); +} + +void QuickRouterHost::setReady(bool ready) +{ + if (m_ready == ready) + return; + + m_ready = ready; + emit readyChanged(ready); +} + +void QuickRouterHost::reportLoadErrors() +{ + QStringList messages; + const auto errors = m_quickWidget->errors(); + messages.reserve(errors.size()); + for (const auto &error : errors) + { + const QString message = error.toString(); + messages.append(message); + spdlog::error("[QuickRouterHost] {}", message.toStdString()); + } + emit loadFailed(messages); +} diff --git a/src/qEmbyApp/quick/quickrouterhost.h b/src/qEmbyApp/quick/quickrouterhost.h new file mode 100644 index 0000000..ed0fa01 --- /dev/null +++ b/src/qEmbyApp/quick/quickrouterhost.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +class NavigationController; +class QEmbyCore; +class QQuickWidget; +class QShowEvent; +class QHideEvent; + +// Hosts the application-level QML Router in one QQuickWidget/QQmlEngine. +// Unlike QuickPageHost, this class owns the shared image-provider registration +// and exposes the host visibility as an application-router lifecycle. +class QuickRouterHost final : public QWidget +{ + Q_OBJECT + Q_PROPERTY(bool active READ isActive NOTIFY activeChanged) + Q_PROPERTY(bool ready READ isReady NOTIFY readyChanged) + +public: + explicit QuickRouterHost(QEmbyCore *core, + NavigationController *navigation, + QWidget *parent = nullptr); + + // Context properties must be installed before loadRouter() creates the + // root object. Returns false when the Router source has already been set. + bool setContextProperty(const QString &name, QObject *object); + void loadRouter(); + + QQuickWidget *quickWidget() const { return m_quickWidget; } + bool isActive() const { return m_active; } + bool isReady() const { return m_ready; } + +Q_SIGNALS: + void activated(); + void deactivated(); + void activeChanged(bool active); + void readyChanged(bool ready); + void loadFailed(const QStringList &errors); + +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + +private: + void registerImageProviders(QEmbyCore *core); + void setActive(bool active); + void setReady(bool ready); + void reportLoadErrors(); + + QQuickWidget *m_quickWidget = nullptr; + bool m_imageProvidersRegistered = false; + bool m_active = false; + bool m_ready = false; +}; diff --git a/src/qEmbyApp/quick/quicktheme.cpp b/src/qEmbyApp/quick/quicktheme.cpp new file mode 100644 index 0000000..e74ae0e --- /dev/null +++ b/src/qEmbyApp/quick/quicktheme.cpp @@ -0,0 +1,81 @@ +#include "quicktheme.h" + +#include "../managers/thememanager.h" +#include "../tokens/AnimationTokens.h" +#include "../tokens/ColorTokens.h" +#include "../tokens/DesignTokens.h" + +namespace { + +inline int scaledFont(int px) +{ + return qRound(px * ThemeManager::fontScaleFactor()); +} + +} // namespace + +QuickTheme::QuickTheme(QObject *parent) : QObject(parent) +{ + connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, + &QuickTheme::themeChanged); +} + +bool QuickTheme::dark() const +{ + return ThemeManager::instance()->isDarkMode(); +} + +#define QUICKTHEME_SEMANTIC(getter, token) \ + QColor QuickTheme::getter() const \ + { \ + return dark() ? ColorTokens::Dark::token : ColorTokens::Light::token; \ + } + +QUICKTHEME_SEMANTIC(bgBase, kBgBase) +QUICKTHEME_SEMANTIC(bgSurface, kBgSurface) +QUICKTHEME_SEMANTIC(bgRaised, kBgRaised) +QUICKTHEME_SEMANTIC(border, kBorder) +QUICKTHEME_SEMANTIC(borderSoft, kBorderSoft) +QUICKTHEME_SEMANTIC(textPrimary, kTextPrimary) +QUICKTHEME_SEMANTIC(textBody, kTextBody) +QUICKTHEME_SEMANTIC(textMuted, kTextMuted) +QUICKTHEME_SEMANTIC(accent, kAccent) +QUICKTHEME_SEMANTIC(accentHover, kAccentHover) + +#undef QUICKTHEME_SEMANTIC + +QColor QuickTheme::danger() const { return ColorTokens::Red::k500; } +QColor QuickTheme::warning() const { return ColorTokens::Amber::k500; } +QColor QuickTheme::success() const { return ColorTokens::Green::k500; } + +int QuickTheme::radiusXs() const { return DesignTokens::Radius::kXs; } +int QuickTheme::radiusSm() const { return DesignTokens::Radius::kSm; } +int QuickTheme::radiusMd() const { return DesignTokens::Radius::kMd; } +int QuickTheme::radiusLg() const { return DesignTokens::Radius::kLg; } +int QuickTheme::radiusXl() const { return DesignTokens::Radius::kXl; } + +int QuickTheme::spacingXs() const { return DesignTokens::Spacing::kXs; } +int QuickTheme::spacingSm() const { return DesignTokens::Spacing::kSm; } +int QuickTheme::spacingMd() const { return DesignTokens::Spacing::kMd; } +int QuickTheme::spacingLg() const { return DesignTokens::Spacing::kLg; } +int QuickTheme::spacingXl() const { return DesignTokens::Spacing::kXl; } + +int QuickTheme::fontCaption() const { return scaledFont(DesignTokens::FontSize::kCaption); } +int QuickTheme::fontSmall() const { return scaledFont(DesignTokens::FontSize::kSmall); } +int QuickTheme::fontBody() const { return scaledFont(DesignTokens::FontSize::kBody); } +int QuickTheme::fontBodyPlus() const { return scaledFont(DesignTokens::FontSize::kBodyPlus); } +int QuickTheme::fontHeading() const { return scaledFont(DesignTokens::FontSize::kHeading); } +int QuickTheme::fontSubtitle() const { return scaledFont(DesignTokens::FontSize::kSubtitle); } +int QuickTheme::fontTitle() const { return scaledFont(DesignTokens::FontSize::kTitle); } +int QuickTheme::fontDisplay() const { return scaledFont(DesignTokens::FontSize::kDisplay); } +int QuickTheme::fontHero() const { return scaledFont(DesignTokens::FontSize::kHero); } + +int QuickTheme::animationFast() const { return AnimationTokens::DurationMs::kFast; } +int QuickTheme::animationNormal() const { return AnimationTokens::DurationMs::kNormal; } + +QColor QuickTheme::alpha(const QColor &color, qreal alpha) const +{ + QColor c = color; + c.setAlphaF(alpha); + return c; +} diff --git a/src/qEmbyApp/quick/quicktheme.h b/src/qEmbyApp/quick/quicktheme.h new file mode 100644 index 0000000..51f8adb --- /dev/null +++ b/src/qEmbyApp/quick/quicktheme.h @@ -0,0 +1,113 @@ +#ifndef QUICKTHEME_H +#define QUICKTHEME_H + +#include +#include +#include + +// QML 侧的设计令牌单例(import qEmby 后以 Theme 访问)。 +// 颜色/尺寸全部取自 tokens/ColorTokens.h 与 tokens/DesignTokens.h, +// 保证 QML 页面与 QSS 主题使用同一套 Slate/Blue 刻度。 +class QuickTheme : public QObject +{ + Q_OBJECT + QML_NAMED_ELEMENT(Theme) + QML_SINGLETON + + Q_PROPERTY(bool dark READ dark NOTIFY themeChanged) + + // 语义色,跟随 ThemeManager 明暗切换 + Q_PROPERTY(QColor bgBase READ bgBase NOTIFY themeChanged) + Q_PROPERTY(QColor bgSurface READ bgSurface NOTIFY themeChanged) + Q_PROPERTY(QColor bgRaised READ bgRaised NOTIFY themeChanged) + Q_PROPERTY(QColor border READ border NOTIFY themeChanged) + Q_PROPERTY(QColor borderSoft READ borderSoft NOTIFY themeChanged) + Q_PROPERTY(QColor textPrimary READ textPrimary NOTIFY themeChanged) + Q_PROPERTY(QColor textBody READ textBody NOTIFY themeChanged) + Q_PROPERTY(QColor textMuted READ textMuted NOTIFY themeChanged) + Q_PROPERTY(QColor accent READ accent NOTIFY themeChanged) + Q_PROPERTY(QColor accentHover READ accentHover NOTIFY themeChanged) + + // 状态色(不随主题变化) + Q_PROPERTY(QColor danger READ danger CONSTANT) + Q_PROPERTY(QColor warning READ warning CONSTANT) + Q_PROPERTY(QColor success READ success CONSTANT) + + // 圆角 / 间距(DesignTokens) + Q_PROPERTY(int radiusXs READ radiusXs CONSTANT) + Q_PROPERTY(int radiusSm READ radiusSm CONSTANT) + Q_PROPERTY(int radiusMd READ radiusMd CONSTANT) + Q_PROPERTY(int radiusLg READ radiusLg CONSTANT) + Q_PROPERTY(int radiusXl READ radiusXl CONSTANT) + Q_PROPERTY(int spacingXs READ spacingXs CONSTANT) + Q_PROPERTY(int spacingSm READ spacingSm CONSTANT) + Q_PROPERTY(int spacingMd READ spacingMd CONSTANT) + Q_PROPERTY(int spacingLg READ spacingLg CONSTANT) + Q_PROPERTY(int spacingXl READ spacingXl CONSTANT) + + // 字号(已按 ThemeManager::fontScaleFactor 缩放,与 QSS 行为一致) + Q_PROPERTY(int fontCaption READ fontCaption NOTIFY themeChanged) + Q_PROPERTY(int fontSmall READ fontSmall NOTIFY themeChanged) + Q_PROPERTY(int fontBody READ fontBody NOTIFY themeChanged) + Q_PROPERTY(int fontBodyPlus READ fontBodyPlus NOTIFY themeChanged) + Q_PROPERTY(int fontHeading READ fontHeading NOTIFY themeChanged) + Q_PROPERTY(int fontSubtitle READ fontSubtitle NOTIFY themeChanged) + Q_PROPERTY(int fontTitle READ fontTitle NOTIFY themeChanged) + Q_PROPERTY(int fontDisplay READ fontDisplay NOTIFY themeChanged) + Q_PROPERTY(int fontHero READ fontHero NOTIFY themeChanged) + + // 动画时长(AnimationTokens) + Q_PROPERTY(int animationFast READ animationFast CONSTANT) + Q_PROPERTY(int animationNormal READ animationNormal CONSTANT) + +public: + explicit QuickTheme(QObject *parent = nullptr); + + bool dark() const; + + QColor bgBase() const; + QColor bgSurface() const; + QColor bgRaised() const; + QColor border() const; + QColor borderSoft() const; + QColor textPrimary() const; + QColor textBody() const; + QColor textMuted() const; + QColor accent() const; + QColor accentHover() const; + + QColor danger() const; + QColor warning() const; + QColor success() const; + + int radiusXs() const; + int radiusSm() const; + int radiusMd() const; + int radiusLg() const; + int radiusXl() const; + int spacingXs() const; + int spacingSm() const; + int spacingMd() const; + int spacingLg() const; + int spacingXl() const; + + int fontCaption() const; + int fontSmall() const; + int fontBody() const; + int fontBodyPlus() const; + int fontHeading() const; + int fontSubtitle() const; + int fontTitle() const; + int fontDisplay() const; + int fontHero() const; + + int animationFast() const; + int animationNormal() const; + + Q_INVOKABLE QColor alpha(const QColor &color, qreal alpha) const; + +signals: + void themeChanged(); +}; + +#endif // QUICKTHEME_H diff --git a/src/qEmbyApp/quick/settingsviewmodel.cpp b/src/qEmbyApp/quick/settingsviewmodel.cpp new file mode 100644 index 0000000..621696c --- /dev/null +++ b/src/qEmbyApp/quick/settingsviewmodel.cpp @@ -0,0 +1,1081 @@ +#include "settingsviewmodel.h" + +#include "config/config_keys.h" +#include "config/configstore.h" + +// ===== SettingsViewModel implementation ===== +// Constructor connects ConfigStore::valueChanged → dispatcher that emits +// the correct per-group signal based on key prefix. + +SettingsViewModel::SettingsViewModel(QObject *parent) + : QObject(parent), m_store(ConfigStore::instance()) +{ + connect(m_store, &ConfigStore::valueChanged, + this, &SettingsViewModel::onValueChanged); +} + +// ── Dispatcher ────────────────────────────────────────────────────── + +void SettingsViewModel::onValueChanged(const QString &key, const QVariant & /*newValue*/) +{ + if (key.startsWith("general/")) { + emit generalChanged(); + } else if (key.startsWith("network/")) { + emit networkChanged(); + } else if (key.startsWith("appearance/")) { + emit appearanceChanged(); + } else if (key.startsWith("library/") || key.startsWith("cache/")) { + emit libraryChanged(); + } else if (key.startsWith("player/") || key.startsWith("ext_player/") + || key.startsWith("danmaku/")) { + emit playerChanged(); + } +} + +// ── General ───────────────────────────────────────────────────────── + +QString SettingsViewModel::language() const +{ + return m_store->get(ConfigKeys::Language, "system"); +} + +bool SettingsViewModel::rememberServer() const +{ + return m_store->get(ConfigKeys::RememberServer, true); +} + +QString SettingsViewModel::lastSelectedServerId() const +{ + return m_store->get(ConfigKeys::LastSelectedServerId); +} + +bool SettingsViewModel::closeToTray() const +{ + return m_store->get(ConfigKeys::CloseToTray, false); +} + +bool SettingsViewModel::logEnable() const +{ + return m_store->get(ConfigKeys::LogEnable, false); +} + +int SettingsViewModel::apiTimeout() const +{ + return m_store->get(ConfigKeys::ApiTimeout, 30); +} + +int SettingsViewModel::imageCacheLimit() const +{ + return m_store->get(ConfigKeys::ImageCacheLimit, 0); +} + +void SettingsViewModel::setLanguage(const QString &v) +{ + m_store->set(ConfigKeys::Language, v); +} + +void SettingsViewModel::setRememberServer(bool v) +{ + m_store->set(ConfigKeys::RememberServer, v); +} + +void SettingsViewModel::setLastSelectedServerId(const QString &v) +{ + m_store->set(ConfigKeys::LastSelectedServerId, v); +} + +void SettingsViewModel::setCloseToTray(bool v) +{ + m_store->set(ConfigKeys::CloseToTray, v); +} + +void SettingsViewModel::setLogEnable(bool v) +{ + m_store->set(ConfigKeys::LogEnable, v); +} + +void SettingsViewModel::setApiTimeout(int v) +{ + m_store->set(ConfigKeys::ApiTimeout, v); +} + +void SettingsViewModel::setImageCacheLimit(int v) +{ + m_store->set(ConfigKeys::ImageCacheLimit, v); +} + +// ── Network ───────────────────────────────────────────────────────── + +int SettingsViewModel::proxyMode() const +{ + return m_store->get(ConfigKeys::ProxyMode, 0); +} + +int SettingsViewModel::proxyType() const +{ + return m_store->get(ConfigKeys::ProxyType, 0); +} + +QString SettingsViewModel::proxyHost() const +{ + return m_store->get(ConfigKeys::ProxyHost); +} + +int SettingsViewModel::proxyPort() const +{ + return m_store->get(ConfigKeys::ProxyPort, 0); +} + +QString SettingsViewModel::proxyUsername() const +{ + return m_store->get(ConfigKeys::ProxyUsername); +} + +QString SettingsViewModel::proxyPassword() const +{ + return m_store->get(ConfigKeys::ProxyPassword); +} + +bool SettingsViewModel::proxyBypassLocalhost() const +{ + return m_store->get(ConfigKeys::ProxyBypassLocalhost, true); +} + +void SettingsViewModel::setProxyMode(int v) +{ + m_store->set(ConfigKeys::ProxyMode, v); +} + +void SettingsViewModel::setProxyType(int v) +{ + m_store->set(ConfigKeys::ProxyType, v); +} + +void SettingsViewModel::setProxyHost(const QString &v) +{ + m_store->set(ConfigKeys::ProxyHost, v); +} + +void SettingsViewModel::setProxyPort(int v) +{ + m_store->set(ConfigKeys::ProxyPort, v); +} + +void SettingsViewModel::setProxyUsername(const QString &v) +{ + m_store->set(ConfigKeys::ProxyUsername, v); +} + +void SettingsViewModel::setProxyPassword(const QString &v) +{ + m_store->set(ConfigKeys::ProxyPassword, v); +} + +void SettingsViewModel::setProxyBypassLocalhost(bool v) +{ + m_store->set(ConfigKeys::ProxyBypassLocalhost, v); +} + +// ── Appearance ────────────────────────────────────────────────────── + +QString SettingsViewModel::themeMode() const +{ + return m_store->get(ConfigKeys::ThemeMode, "system"); +} + +QString SettingsViewModel::defaultLibraryView() const +{ + return m_store->get(ConfigKeys::DefaultLibraryView, "poster"); +} + +QString SettingsViewModel::fontSize() const +{ + return m_store->get(ConfigKeys::FontSize, "medium"); +} + +QString SettingsViewModel::sidebarPosition() const +{ + return m_store->get(ConfigKeys::SidebarPosition, "left"); +} + +bool SettingsViewModel::sidebarPinned() const +{ + return m_store->get(ConfigKeys::SidebarPinned, true); +} + +bool SettingsViewModel::sidebarCustomEnabled() const +{ + return m_store->get(ConfigKeys::SidebarCustomEnabled, false); +} + +bool SettingsViewModel::sidebarHideSearch() const +{ + return m_store->get(ConfigKeys::SidebarHideSearch, false); +} + +bool SettingsViewModel::sidebarHideHome() const +{ + return m_store->get(ConfigKeys::SidebarHideHome, false); +} + +bool SettingsViewModel::sidebarHideFavorites() const +{ + return m_store->get(ConfigKeys::SidebarHideFavorites, false); +} + +QString SettingsViewModel::startupWindowState() const +{ + return m_store->get(ConfigKeys::StartupWindowState, "normal"); +} + +bool SettingsViewModel::uiAnimations() const +{ + return m_store->get(ConfigKeys::UiAnimations, false); +} + +bool SettingsViewModel::snapshotNavigation() const +{ + return m_store->get(ConfigKeys::SnapshotNavigation, false); +} + +bool SettingsViewModel::searchHistoryEnabled() const +{ + return m_store->get(ConfigKeys::SearchHistoryEnabled, false); +} + +bool SettingsViewModel::searchAutocompleteEnabled() const +{ + return m_store->get(ConfigKeys::SearchAutocompleteEnabled, false); +} + +void SettingsViewModel::setThemeMode(const QString &v) +{ + m_store->set(ConfigKeys::ThemeMode, v); +} + +void SettingsViewModel::setDefaultLibraryView(const QString &v) +{ + m_store->set(ConfigKeys::DefaultLibraryView, v); +} + +void SettingsViewModel::setFontSize(const QString &v) +{ + m_store->set(ConfigKeys::FontSize, v); +} + +void SettingsViewModel::setSidebarPosition(const QString &v) +{ + m_store->set(ConfigKeys::SidebarPosition, v); +} + +void SettingsViewModel::setSidebarPinned(bool v) +{ + m_store->set(ConfigKeys::SidebarPinned, v); +} + +void SettingsViewModel::setSidebarCustomEnabled(bool v) +{ + m_store->set(ConfigKeys::SidebarCustomEnabled, v); +} + +void SettingsViewModel::setSidebarHideSearch(bool v) +{ + m_store->set(ConfigKeys::SidebarHideSearch, v); +} + +void SettingsViewModel::setSidebarHideHome(bool v) +{ + m_store->set(ConfigKeys::SidebarHideHome, v); +} + +void SettingsViewModel::setSidebarHideFavorites(bool v) +{ + m_store->set(ConfigKeys::SidebarHideFavorites, v); +} + +void SettingsViewModel::setStartupWindowState(const QString &v) +{ + m_store->set(ConfigKeys::StartupWindowState, v); +} + +void SettingsViewModel::setUiAnimations(bool v) +{ + m_store->set(ConfigKeys::UiAnimations, v); +} + +void SettingsViewModel::setSnapshotNavigation(bool v) +{ + m_store->set(ConfigKeys::SnapshotNavigation, v); +} + +void SettingsViewModel::setSearchHistoryEnabled(bool v) +{ + m_store->set(ConfigKeys::SearchHistoryEnabled, v); +} + +void SettingsViewModel::setSearchAutocompleteEnabled(bool v) +{ + m_store->set(ConfigKeys::SearchAutocompleteEnabled, v); +} + +// ── Library ───────────────────────────────────────────────────────── + +bool SettingsViewModel::showRecommended() const +{ + return m_store->get(ConfigKeys::ShowRecommended, true); +} + +bool SettingsViewModel::showContinueWatching() const +{ + return m_store->get(ConfigKeys::ShowContinueWatching, true); +} + +bool SettingsViewModel::showLatestAdded() const +{ + return m_store->get(ConfigKeys::ShowLatestAdded, true); +} + +bool SettingsViewModel::showCompletedWatching() const +{ + return m_store->get(ConfigKeys::ShowCompletedWatching, false); +} + +int SettingsViewModel::continueWatchingRequestLimit() const +{ + return m_store->get(ConfigKeys::ContinueWatchingRequestLimit, 0); +} + +int SettingsViewModel::latestMediaRequestLimit() const +{ + return m_store->get(ConfigKeys::LatestMediaRequestLimit, 1000); +} + +int SettingsViewModel::recommendedRequestLimit() const +{ + return m_store->get(ConfigKeys::RecommendedRequestLimit, 1000); +} + +int SettingsViewModel::completedWatchingRequestLimit() const +{ + return m_store->get(ConfigKeys::CompletedWatchingRequestLimit, 0); +} + +bool SettingsViewModel::showMediaLibraries() const +{ + return m_store->get(ConfigKeys::ShowMediaLibraries, true); +} + +bool SettingsViewModel::showEachLibrary() const +{ + return m_store->get(ConfigKeys::ShowEachLibrary, true); +} + +bool SettingsViewModel::showFavoriteFolders() const +{ + return m_store->get(ConfigKeys::ShowFavoriteFolders, false); +} + +QString SettingsViewModel::imageQuality() const +{ + return m_store->get(ConfigKeys::ImageQuality, "high"); +} + +bool SettingsViewModel::shimmerAnimation() const +{ + return m_store->get(ConfigKeys::ShimmerAnimation, false); +} + +bool SettingsViewModel::adaptiveImages() const +{ + return m_store->get(ConfigKeys::AdaptiveImages, true); +} + +bool SettingsViewModel::showMediaTooltips() const +{ + return m_store->get(ConfigKeys::ShowMediaTooltips, true); +} + +QString SettingsViewModel::dataCacheDuration() const +{ + return m_store->get(ConfigKeys::DataCacheDuration, "24"); +} + +QString SettingsViewModel::imageCacheDuration() const +{ + return m_store->get(ConfigKeys::ImageCacheDuration, "7"); +} + +void SettingsViewModel::setShowRecommended(bool v) +{ + m_store->set(ConfigKeys::ShowRecommended, v); +} + +void SettingsViewModel::setShowContinueWatching(bool v) +{ + m_store->set(ConfigKeys::ShowContinueWatching, v); +} + +void SettingsViewModel::setShowLatestAdded(bool v) +{ + m_store->set(ConfigKeys::ShowLatestAdded, v); +} + +void SettingsViewModel::setShowCompletedWatching(bool v) +{ + m_store->set(ConfigKeys::ShowCompletedWatching, v); +} + +void SettingsViewModel::setContinueWatchingRequestLimit(int v) +{ + m_store->set(ConfigKeys::ContinueWatchingRequestLimit, v); +} + +void SettingsViewModel::setLatestMediaRequestLimit(int v) +{ + m_store->set(ConfigKeys::LatestMediaRequestLimit, v); +} + +void SettingsViewModel::setRecommendedRequestLimit(int v) +{ + m_store->set(ConfigKeys::RecommendedRequestLimit, v); +} + +void SettingsViewModel::setCompletedWatchingRequestLimit(int v) +{ + m_store->set(ConfigKeys::CompletedWatchingRequestLimit, v); +} + +void SettingsViewModel::setShowMediaLibraries(bool v) +{ + m_store->set(ConfigKeys::ShowMediaLibraries, v); +} + +void SettingsViewModel::setShowEachLibrary(bool v) +{ + m_store->set(ConfigKeys::ShowEachLibrary, v); +} + +void SettingsViewModel::setShowFavoriteFolders(bool v) +{ + m_store->set(ConfigKeys::ShowFavoriteFolders, v); +} + +void SettingsViewModel::setImageQuality(const QString &v) +{ + m_store->set(ConfigKeys::ImageQuality, v); +} + +void SettingsViewModel::setShimmerAnimation(bool v) +{ + m_store->set(ConfigKeys::ShimmerAnimation, v); +} + +void SettingsViewModel::setAdaptiveImages(bool v) +{ + m_store->set(ConfigKeys::AdaptiveImages, v); +} + +void SettingsViewModel::setShowMediaTooltips(bool v) +{ + m_store->set(ConfigKeys::ShowMediaTooltips, v); +} + +void SettingsViewModel::setDataCacheDuration(const QString &v) +{ + m_store->set(ConfigKeys::DataCacheDuration, v); +} + +void SettingsViewModel::setImageCacheDuration(const QString &v) +{ + m_store->set(ConfigKeys::ImageCacheDuration, v); +} + +// ── Player — internal ─────────────────────────────────────────────── + +QString SettingsViewModel::hwDecoding() const +{ + return m_store->get(ConfigKeys::PlayerHwDec, "auto-copy"); +} + +QString SettingsViewModel::videoOutput() const +{ + return m_store->get(ConfigKeys::PlayerVo, "libmpv"); +} + +QString SettingsViewModel::videoSync() const +{ + return m_store->get(ConfigKeys::PlayerVideoSync, "display-resample"); +} + +QString SettingsViewModel::toneMapping() const +{ + return m_store->get(ConfigKeys::PlayerToneMapping, "auto"); +} + +QString SettingsViewModel::defaultScale() const +{ + return m_store->get(ConfigKeys::PlayerDefaultScale, "100"); +} + +QString SettingsViewModel::audioLang() const +{ + return m_store->get(ConfigKeys::PlayerAudioLang, "auto"); +} + +QString SettingsViewModel::subLang() const +{ + return m_store->get(ConfigKeys::PlayerSubLang, "auto"); +} + +QString SettingsViewModel::preferredVersion() const +{ + return m_store->get(ConfigKeys::PlayerPreferredVersion); +} + +bool SettingsViewModel::volNormalization() const +{ + return m_store->get(ConfigKeys::PlayerVolNormal, false); +} + +bool SettingsViewModel::continuousPlay() const +{ + return m_store->get(ConfigKeys::PlayerContinuousPlay, true); +} + +QString SettingsViewModel::seekStep() const +{ + return m_store->get(ConfigKeys::PlayerSeekStep, "10"); +} + +bool SettingsViewModel::longPressSeek() const +{ + return m_store->get(ConfigKeys::PlayerLongPressSeek, true); +} + +QString SettingsViewModel::longPressMode() const +{ + return m_store->get(ConfigKeys::PlayerLongPressMode, "seek"); +} + +QString SettingsViewModel::longPressTriggerMs() const +{ + return m_store->get(ConfigKeys::PlayerLongPressTriggerMs, "500"); +} + +bool SettingsViewModel::mouseEdgeLongPress() const +{ + return m_store->get(ConfigKeys::PlayerMouseEdgeLongPress, true); +} + +QString SettingsViewModel::mediaSwitcherMode() const +{ + return m_store->get(ConfigKeys::PlayerMediaSwitcherMode, "sidebar"); +} + +bool SettingsViewModel::clickToPause() const +{ + return m_store->get(ConfigKeys::PlayerClickToPause, true); +} + +bool SettingsViewModel::skipIntro() const +{ + return m_store->get(ConfigKeys::PlayerSkipIntro, false); +} + +bool SettingsViewModel::skipOutro() const +{ + return m_store->get(ConfigKeys::PlayerSkipOutro, false); +} + +bool SettingsViewModel::independentWindow() const +{ + return m_store->get(ConfigKeys::PlayerIndependentWindow, false); +} + +bool SettingsViewModel::useMpvConf() const +{ + return m_store->get(ConfigKeys::PlayerUseMpvConf, false); +} + +int SettingsViewModel::volumeLevel() const +{ + return m_store->get(ConfigKeys::PlayerVolumeLevel, 100); +} + +bool SettingsViewModel::volumeMuted() const +{ + return m_store->get(ConfigKeys::PlayerVolumeMuted, false); +} + +void SettingsViewModel::setHwDecoding(const QString &v) +{ + m_store->set(ConfigKeys::PlayerHwDec, v); +} + +void SettingsViewModel::setVideoOutput(const QString &v) +{ + m_store->set(ConfigKeys::PlayerVo, v); +} + +void SettingsViewModel::setVideoSync(const QString &v) +{ + m_store->set(ConfigKeys::PlayerVideoSync, v); +} + +void SettingsViewModel::setToneMapping(const QString &v) +{ + m_store->set(ConfigKeys::PlayerToneMapping, v); +} + +void SettingsViewModel::setDefaultScale(const QString &v) +{ + m_store->set(ConfigKeys::PlayerDefaultScale, v); +} + +void SettingsViewModel::setAudioLang(const QString &v) +{ + m_store->set(ConfigKeys::PlayerAudioLang, v); +} + +void SettingsViewModel::setSubLang(const QString &v) +{ + m_store->set(ConfigKeys::PlayerSubLang, v); +} + +void SettingsViewModel::setPreferredVersion(const QString &v) +{ + m_store->set(ConfigKeys::PlayerPreferredVersion, v); +} + +void SettingsViewModel::setVolNormalization(bool v) +{ + m_store->set(ConfigKeys::PlayerVolNormal, v); +} + +void SettingsViewModel::setContinuousPlay(bool v) +{ + m_store->set(ConfigKeys::PlayerContinuousPlay, v); +} + +void SettingsViewModel::setSeekStep(const QString &v) +{ + m_store->set(ConfigKeys::PlayerSeekStep, v); +} + +void SettingsViewModel::setLongPressSeek(bool v) +{ + m_store->set(ConfigKeys::PlayerLongPressSeek, v); +} + +void SettingsViewModel::setLongPressMode(const QString &v) +{ + m_store->set(ConfigKeys::PlayerLongPressMode, v); +} + +void SettingsViewModel::setLongPressTriggerMs(const QString &v) +{ + m_store->set(ConfigKeys::PlayerLongPressTriggerMs, v); +} + +void SettingsViewModel::setMouseEdgeLongPress(bool v) +{ + m_store->set(ConfigKeys::PlayerMouseEdgeLongPress, v); +} + +void SettingsViewModel::setMediaSwitcherMode(const QString &v) +{ + m_store->set(ConfigKeys::PlayerMediaSwitcherMode, v); +} + +void SettingsViewModel::setClickToPause(bool v) +{ + m_store->set(ConfigKeys::PlayerClickToPause, v); +} + +void SettingsViewModel::setSkipIntro(bool v) +{ + m_store->set(ConfigKeys::PlayerSkipIntro, v); +} + +void SettingsViewModel::setSkipOutro(bool v) +{ + m_store->set(ConfigKeys::PlayerSkipOutro, v); +} + +void SettingsViewModel::setIndependentWindow(bool v) +{ + m_store->set(ConfigKeys::PlayerIndependentWindow, v); +} + +void SettingsViewModel::setUseMpvConf(bool v) +{ + m_store->set(ConfigKeys::PlayerUseMpvConf, v); +} + +void SettingsViewModel::setVolumeLevel(int v) +{ + m_store->set(ConfigKeys::PlayerVolumeLevel, v); +} + +void SettingsViewModel::setVolumeMuted(bool v) +{ + m_store->set(ConfigKeys::PlayerVolumeMuted, v); +} + +// ── Player — subtitle ─────────────────────────────────────────────── + +QString SettingsViewModel::subtitleFont() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleFont); +} + +int SettingsViewModel::subtitleDelayMs() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleDelayMs, 0); +} + +int SettingsViewModel::subtitleFontSize() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleFontSize, 55); +} + +int SettingsViewModel::subtitlePosition() const +{ + return m_store->get(ConfigKeys::PlayerSubtitlePosition, 100); +} + +int SettingsViewModel::subtitleOutlineSize() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleOutlineSize, 3); +} + +int SettingsViewModel::subtitleShadowOffset() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleShadowOffset, 1); +} + +double SettingsViewModel::subtitleScale() const +{ + return m_store->get(ConfigKeys::PlayerSubtitleScale, 1.0); +} + +QString SettingsViewModel::lastSubtitleDir() const +{ + return m_store->get(ConfigKeys::PlayerLastSubtitleDir); +} + +QString SettingsViewModel::externalSubtitle() const +{ + return m_store->get(ConfigKeys::PlayerExternalSubtitle); +} + +void SettingsViewModel::setSubtitleFont(const QString &v) +{ + m_store->set(ConfigKeys::PlayerSubtitleFont, v); +} + +void SettingsViewModel::setSubtitleDelayMs(int v) +{ + m_store->set(ConfigKeys::PlayerSubtitleDelayMs, v); +} + +void SettingsViewModel::setSubtitleFontSize(int v) +{ + m_store->set(ConfigKeys::PlayerSubtitleFontSize, v); +} + +void SettingsViewModel::setSubtitlePosition(int v) +{ + m_store->set(ConfigKeys::PlayerSubtitlePosition, v); +} + +void SettingsViewModel::setSubtitleOutlineSize(int v) +{ + m_store->set(ConfigKeys::PlayerSubtitleOutlineSize, v); +} + +void SettingsViewModel::setSubtitleShadowOffset(int v) +{ + m_store->set(ConfigKeys::PlayerSubtitleShadowOffset, v); +} + +void SettingsViewModel::setSubtitleScale(double v) +{ + m_store->set(ConfigKeys::PlayerSubtitleScale, v); +} + +void SettingsViewModel::setLastSubtitleDir(const QString &v) +{ + m_store->set(ConfigKeys::PlayerLastSubtitleDir, v); +} + +void SettingsViewModel::setExternalSubtitle(const QString &v) +{ + m_store->set(ConfigKeys::PlayerExternalSubtitle, v); +} + +// ── Player — danmaku ──────────────────────────────────────────────── + +bool SettingsViewModel::danmakuEnabled() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuEnabled, false); +} + +QString SettingsViewModel::danmakuRenderer() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuRenderer); +} + +int SettingsViewModel::danmakuOpacity() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuOpacity, 100); +} + +int SettingsViewModel::danmakuFontScale() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuFontScale, 100); +} + +int SettingsViewModel::danmakuFontWeight() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuFontWeight, 400); +} + +int SettingsViewModel::danmakuOutlineSize() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuOutlineSize, 100); +} + +int SettingsViewModel::danmakuShadowOffset() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuShadowOffset, 0); +} + +int SettingsViewModel::danmakuAreaPercent() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuAreaPercent, 100); +} + +int SettingsViewModel::danmakuDensity() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuDensity, 100); +} + +int SettingsViewModel::danmakuSpeedScale() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuSpeedScale, 100); +} + +int SettingsViewModel::danmakuOffsetMs() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuOffsetMs, 0); +} + +bool SettingsViewModel::danmakuHideScroll() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuHideScroll, false); +} + +bool SettingsViewModel::danmakuHideTop() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuHideTop, false); +} + +bool SettingsViewModel::danmakuHideBottom() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuHideBottom, false); +} + +QString SettingsViewModel::danmakuBlockedKeywords() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuBlockedKeywords); +} + +bool SettingsViewModel::danmakuDualSubtitle() const +{ + return m_store->get(ConfigKeys::PlayerDanmakuDualSubtitle, true); +} + +void SettingsViewModel::setDanmakuEnabled(bool v) +{ + m_store->set(ConfigKeys::PlayerDanmakuEnabled, v); +} + +void SettingsViewModel::setDanmakuRenderer(const QString &v) +{ + m_store->set(ConfigKeys::PlayerDanmakuRenderer, v); +} + +void SettingsViewModel::setDanmakuOpacity(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuOpacity, v); +} + +void SettingsViewModel::setDanmakuFontScale(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuFontScale, v); +} + +void SettingsViewModel::setDanmakuFontWeight(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuFontWeight, v); +} + +void SettingsViewModel::setDanmakuOutlineSize(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuOutlineSize, v); +} + +void SettingsViewModel::setDanmakuShadowOffset(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuShadowOffset, v); +} + +void SettingsViewModel::setDanmakuAreaPercent(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuAreaPercent, v); +} + +void SettingsViewModel::setDanmakuDensity(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuDensity, v); +} + +void SettingsViewModel::setDanmakuSpeedScale(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuSpeedScale, v); +} + +void SettingsViewModel::setDanmakuOffsetMs(int v) +{ + m_store->set(ConfigKeys::PlayerDanmakuOffsetMs, v); +} + +void SettingsViewModel::setDanmakuHideScroll(bool v) +{ + m_store->set(ConfigKeys::PlayerDanmakuHideScroll, v); +} + +void SettingsViewModel::setDanmakuHideTop(bool v) +{ + m_store->set(ConfigKeys::PlayerDanmakuHideTop, v); +} + +void SettingsViewModel::setDanmakuHideBottom(bool v) +{ + m_store->set(ConfigKeys::PlayerDanmakuHideBottom, v); +} + +void SettingsViewModel::setDanmakuBlockedKeywords(const QString &v) +{ + m_store->set(ConfigKeys::PlayerDanmakuBlockedKeywords, v); +} + +void SettingsViewModel::setDanmakuDualSubtitle(bool v) +{ + m_store->set(ConfigKeys::PlayerDanmakuDualSubtitle, v); +} + +// ── Player — danmaku provider ─────────────────────────────────────── + +QString SettingsViewModel::danmakuProvider() const +{ + return m_store->get(ConfigKeys::DanmakuProvider); +} + +QString SettingsViewModel::danmakuProviderBaseUrl() const +{ + return m_store->get(ConfigKeys::DanmakuProviderBaseUrl); +} + +QString SettingsViewModel::danmakuProviderAppId() const +{ + return m_store->get(ConfigKeys::DanmakuProviderAppId); +} + +QString SettingsViewModel::danmakuProviderAppSecret() const +{ + return m_store->get(ConfigKeys::DanmakuProviderAppSecret); +} + +void SettingsViewModel::setDanmakuProvider(const QString &v) +{ + m_store->set(ConfigKeys::DanmakuProvider, v); +} + +void SettingsViewModel::setDanmakuProviderBaseUrl(const QString &v) +{ + m_store->set(ConfigKeys::DanmakuProviderBaseUrl, v); +} + +void SettingsViewModel::setDanmakuProviderAppId(const QString &v) +{ + m_store->set(ConfigKeys::DanmakuProviderAppId, v); +} + +void SettingsViewModel::setDanmakuProviderAppSecret(const QString &v) +{ + m_store->set(ConfigKeys::DanmakuProviderAppSecret, v); +} + +// ── Player — external ─────────────────────────────────────────────── + +bool SettingsViewModel::extPlayerEnable() const +{ + return m_store->get(ConfigKeys::ExtPlayerEnable, false); +} + +QString SettingsViewModel::extPlayerPath() const +{ + return m_store->get(ConfigKeys::ExtPlayerPath); +} + +QString SettingsViewModel::extPlayerArgs() const +{ + return m_store->get(ConfigKeys::ExtPlayerArgs); +} + +bool SettingsViewModel::extPlayerDirectStream() const +{ + return m_store->get(ConfigKeys::ExtPlayerDirectStream, false); +} + +bool SettingsViewModel::extPlayerQuickPlay() const +{ + return m_store->get(ConfigKeys::ExtPlayerQuickPlay, false); +} + +QString SettingsViewModel::extPlayerUrlReplace() const +{ + return m_store->get(ConfigKeys::ExtPlayerUrlReplace); +} + +void SettingsViewModel::setExtPlayerEnable(bool v) +{ + m_store->set(ConfigKeys::ExtPlayerEnable, v); +} + +void SettingsViewModel::setExtPlayerPath(const QString &v) +{ + m_store->set(ConfigKeys::ExtPlayerPath, v); +} + +void SettingsViewModel::setExtPlayerArgs(const QString &v) +{ + m_store->set(ConfigKeys::ExtPlayerArgs, v); +} + +void SettingsViewModel::setExtPlayerDirectStream(bool v) +{ + m_store->set(ConfigKeys::ExtPlayerDirectStream, v); +} + +void SettingsViewModel::setExtPlayerQuickPlay(bool v) +{ + m_store->set(ConfigKeys::ExtPlayerQuickPlay, v); +} + +void SettingsViewModel::setExtPlayerUrlReplace(const QString &v) +{ + m_store->set(ConfigKeys::ExtPlayerUrlReplace, v); +} diff --git a/src/qEmbyApp/quick/settingsviewmodel.h b/src/qEmbyApp/quick/settingsviewmodel.h new file mode 100644 index 0000000..ba454de --- /dev/null +++ b/src/qEmbyApp/quick/settingsviewmodel.h @@ -0,0 +1,400 @@ +#ifndef SETTINGSVIEWMODEL_H +#define SETTINGSVIEWMODEL_H + +#include +#include +#include + +// ===== QML-facing ViewModel for every global ConfigStore setting ===== +// Groups settings into 5 categories with per-group NOTIFY signals. +// QML binds to properties; C++ setters delegate to ConfigStore::instance()->set(). +// Live updates: the constructor connects ConfigStore::valueChanged to a +// dispatcher that emits the correct group signal. + +class SettingsViewModel : public QObject +{ + Q_OBJECT + QML_ELEMENT + + // ── General ────────────────────────────────────────────────────── + Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY generalChanged) + Q_PROPERTY(bool rememberServer READ rememberServer WRITE setRememberServer NOTIFY generalChanged) + Q_PROPERTY(QString lastSelectedServerId READ lastSelectedServerId WRITE setLastSelectedServerId NOTIFY generalChanged) + Q_PROPERTY(bool closeToTray READ closeToTray WRITE setCloseToTray NOTIFY generalChanged) + Q_PROPERTY(bool logEnable READ logEnable WRITE setLogEnable NOTIFY generalChanged) + Q_PROPERTY(int apiTimeout READ apiTimeout WRITE setApiTimeout NOTIFY generalChanged) + Q_PROPERTY(int imageCacheLimit READ imageCacheLimit WRITE setImageCacheLimit NOTIFY generalChanged) + + // ── Network ────────────────────────────────────────────────────── + Q_PROPERTY(int proxyMode READ proxyMode WRITE setProxyMode NOTIFY networkChanged) + Q_PROPERTY(int proxyType READ proxyType WRITE setProxyType NOTIFY networkChanged) + Q_PROPERTY(QString proxyHost READ proxyHost WRITE setProxyHost NOTIFY networkChanged) + Q_PROPERTY(int proxyPort READ proxyPort WRITE setProxyPort NOTIFY networkChanged) + Q_PROPERTY(QString proxyUsername READ proxyUsername WRITE setProxyUsername NOTIFY networkChanged) + Q_PROPERTY(QString proxyPassword READ proxyPassword WRITE setProxyPassword NOTIFY networkChanged) + Q_PROPERTY(bool proxyBypassLocalhost READ proxyBypassLocalhost WRITE setProxyBypassLocalhost NOTIFY networkChanged) + + // ── Appearance ─────────────────────────────────────────────────── + Q_PROPERTY(QString themeMode READ themeMode WRITE setThemeMode NOTIFY appearanceChanged) + Q_PROPERTY(QString defaultLibraryView READ defaultLibraryView WRITE setDefaultLibraryView NOTIFY appearanceChanged) + Q_PROPERTY(QString fontSize READ fontSize WRITE setFontSize NOTIFY appearanceChanged) + Q_PROPERTY(QString sidebarPosition READ sidebarPosition WRITE setSidebarPosition NOTIFY appearanceChanged) + Q_PROPERTY(bool sidebarPinned READ sidebarPinned WRITE setSidebarPinned NOTIFY appearanceChanged) + Q_PROPERTY(bool sidebarCustomEnabled READ sidebarCustomEnabled WRITE setSidebarCustomEnabled NOTIFY appearanceChanged) + Q_PROPERTY(bool sidebarHideSearch READ sidebarHideSearch WRITE setSidebarHideSearch NOTIFY appearanceChanged) + Q_PROPERTY(bool sidebarHideHome READ sidebarHideHome WRITE setSidebarHideHome NOTIFY appearanceChanged) + Q_PROPERTY(bool sidebarHideFavorites READ sidebarHideFavorites WRITE setSidebarHideFavorites NOTIFY appearanceChanged) + Q_PROPERTY(QString startupWindowState READ startupWindowState WRITE setStartupWindowState NOTIFY appearanceChanged) + Q_PROPERTY(bool uiAnimations READ uiAnimations WRITE setUiAnimations NOTIFY appearanceChanged) + Q_PROPERTY(bool snapshotNavigation READ snapshotNavigation WRITE setSnapshotNavigation NOTIFY appearanceChanged) + Q_PROPERTY(bool searchHistoryEnabled READ searchHistoryEnabled WRITE setSearchHistoryEnabled NOTIFY appearanceChanged) + Q_PROPERTY(bool searchAutocompleteEnabled READ searchAutocompleteEnabled WRITE setSearchAutocompleteEnabled NOTIFY appearanceChanged) + + // ── Library ────────────────────────────────────────────────────── + Q_PROPERTY(bool showRecommended READ showRecommended WRITE setShowRecommended NOTIFY libraryChanged) + Q_PROPERTY(bool showContinueWatching READ showContinueWatching WRITE setShowContinueWatching NOTIFY libraryChanged) + Q_PROPERTY(bool showLatestAdded READ showLatestAdded WRITE setShowLatestAdded NOTIFY libraryChanged) + Q_PROPERTY(bool showCompletedWatching READ showCompletedWatching WRITE setShowCompletedWatching NOTIFY libraryChanged) + Q_PROPERTY(int continueWatchingRequestLimit READ continueWatchingRequestLimit WRITE setContinueWatchingRequestLimit NOTIFY libraryChanged) + Q_PROPERTY(int latestMediaRequestLimit READ latestMediaRequestLimit WRITE setLatestMediaRequestLimit NOTIFY libraryChanged) + Q_PROPERTY(int recommendedRequestLimit READ recommendedRequestLimit WRITE setRecommendedRequestLimit NOTIFY libraryChanged) + Q_PROPERTY(int completedWatchingRequestLimit READ completedWatchingRequestLimit WRITE setCompletedWatchingRequestLimit NOTIFY libraryChanged) + Q_PROPERTY(bool showMediaLibraries READ showMediaLibraries WRITE setShowMediaLibraries NOTIFY libraryChanged) + Q_PROPERTY(bool showEachLibrary READ showEachLibrary WRITE setShowEachLibrary NOTIFY libraryChanged) + Q_PROPERTY(bool showFavoriteFolders READ showFavoriteFolders WRITE setShowFavoriteFolders NOTIFY libraryChanged) + Q_PROPERTY(QString imageQuality READ imageQuality WRITE setImageQuality NOTIFY libraryChanged) + Q_PROPERTY(bool shimmerAnimation READ shimmerAnimation WRITE setShimmerAnimation NOTIFY libraryChanged) + Q_PROPERTY(bool adaptiveImages READ adaptiveImages WRITE setAdaptiveImages NOTIFY libraryChanged) + Q_PROPERTY(bool showMediaTooltips READ showMediaTooltips WRITE setShowMediaTooltips NOTIFY libraryChanged) + Q_PROPERTY(QString dataCacheDuration READ dataCacheDuration WRITE setDataCacheDuration NOTIFY libraryChanged) + Q_PROPERTY(QString imageCacheDuration READ imageCacheDuration WRITE setImageCacheDuration NOTIFY libraryChanged) + + // ── Player — internal ──────────────────────────────────────────── + Q_PROPERTY(QString hwDecoding READ hwDecoding WRITE setHwDecoding NOTIFY playerChanged) + Q_PROPERTY(QString videoOutput READ videoOutput WRITE setVideoOutput NOTIFY playerChanged) + Q_PROPERTY(QString videoSync READ videoSync WRITE setVideoSync NOTIFY playerChanged) + Q_PROPERTY(QString toneMapping READ toneMapping WRITE setToneMapping NOTIFY playerChanged) + Q_PROPERTY(QString defaultScale READ defaultScale WRITE setDefaultScale NOTIFY playerChanged) + Q_PROPERTY(QString audioLang READ audioLang WRITE setAudioLang NOTIFY playerChanged) + Q_PROPERTY(QString subLang READ subLang WRITE setSubLang NOTIFY playerChanged) + Q_PROPERTY(QString preferredVersion READ preferredVersion WRITE setPreferredVersion NOTIFY playerChanged) + Q_PROPERTY(bool volNormalization READ volNormalization WRITE setVolNormalization NOTIFY playerChanged) + Q_PROPERTY(bool continuousPlay READ continuousPlay WRITE setContinuousPlay NOTIFY playerChanged) + Q_PROPERTY(QString seekStep READ seekStep WRITE setSeekStep NOTIFY playerChanged) + Q_PROPERTY(bool longPressSeek READ longPressSeek WRITE setLongPressSeek NOTIFY playerChanged) + Q_PROPERTY(QString longPressMode READ longPressMode WRITE setLongPressMode NOTIFY playerChanged) + Q_PROPERTY(QString longPressTriggerMs READ longPressTriggerMs WRITE setLongPressTriggerMs NOTIFY playerChanged) + Q_PROPERTY(bool mouseEdgeLongPress READ mouseEdgeLongPress WRITE setMouseEdgeLongPress NOTIFY playerChanged) + Q_PROPERTY(QString mediaSwitcherMode READ mediaSwitcherMode WRITE setMediaSwitcherMode NOTIFY playerChanged) + Q_PROPERTY(bool clickToPause READ clickToPause WRITE setClickToPause NOTIFY playerChanged) + Q_PROPERTY(bool skipIntro READ skipIntro WRITE setSkipIntro NOTIFY playerChanged) + Q_PROPERTY(bool skipOutro READ skipOutro WRITE setSkipOutro NOTIFY playerChanged) + Q_PROPERTY(bool independentWindow READ independentWindow WRITE setIndependentWindow NOTIFY playerChanged) + Q_PROPERTY(bool useMpvConf READ useMpvConf WRITE setUseMpvConf NOTIFY playerChanged) + Q_PROPERTY(int volumeLevel READ volumeLevel WRITE setVolumeLevel NOTIFY playerChanged) + Q_PROPERTY(bool volumeMuted READ volumeMuted WRITE setVolumeMuted NOTIFY playerChanged) + + // ── Player — subtitle ──────────────────────────────────────────── + Q_PROPERTY(QString subtitleFont READ subtitleFont WRITE setSubtitleFont NOTIFY playerChanged) + Q_PROPERTY(int subtitleDelayMs READ subtitleDelayMs WRITE setSubtitleDelayMs NOTIFY playerChanged) + Q_PROPERTY(int subtitleFontSize READ subtitleFontSize WRITE setSubtitleFontSize NOTIFY playerChanged) + Q_PROPERTY(int subtitlePosition READ subtitlePosition WRITE setSubtitlePosition NOTIFY playerChanged) + Q_PROPERTY(int subtitleOutlineSize READ subtitleOutlineSize WRITE setSubtitleOutlineSize NOTIFY playerChanged) + Q_PROPERTY(int subtitleShadowOffset READ subtitleShadowOffset WRITE setSubtitleShadowOffset NOTIFY playerChanged) + Q_PROPERTY(double subtitleScale READ subtitleScale WRITE setSubtitleScale NOTIFY playerChanged) + Q_PROPERTY(QString lastSubtitleDir READ lastSubtitleDir WRITE setLastSubtitleDir NOTIFY playerChanged) + Q_PROPERTY(QString externalSubtitle READ externalSubtitle WRITE setExternalSubtitle NOTIFY playerChanged) + + // ── Player — danmaku ───────────────────────────────────────────── + Q_PROPERTY(bool danmakuEnabled READ danmakuEnabled WRITE setDanmakuEnabled NOTIFY playerChanged) + Q_PROPERTY(QString danmakuRenderer READ danmakuRenderer WRITE setDanmakuRenderer NOTIFY playerChanged) + Q_PROPERTY(int danmakuOpacity READ danmakuOpacity WRITE setDanmakuOpacity NOTIFY playerChanged) + Q_PROPERTY(int danmakuFontScale READ danmakuFontScale WRITE setDanmakuFontScale NOTIFY playerChanged) + Q_PROPERTY(int danmakuFontWeight READ danmakuFontWeight WRITE setDanmakuFontWeight NOTIFY playerChanged) + Q_PROPERTY(int danmakuOutlineSize READ danmakuOutlineSize WRITE setDanmakuOutlineSize NOTIFY playerChanged) + Q_PROPERTY(int danmakuShadowOffset READ danmakuShadowOffset WRITE setDanmakuShadowOffset NOTIFY playerChanged) + Q_PROPERTY(int danmakuAreaPercent READ danmakuAreaPercent WRITE setDanmakuAreaPercent NOTIFY playerChanged) + Q_PROPERTY(int danmakuDensity READ danmakuDensity WRITE setDanmakuDensity NOTIFY playerChanged) + Q_PROPERTY(int danmakuSpeedScale READ danmakuSpeedScale WRITE setDanmakuSpeedScale NOTIFY playerChanged) + Q_PROPERTY(int danmakuOffsetMs READ danmakuOffsetMs WRITE setDanmakuOffsetMs NOTIFY playerChanged) + Q_PROPERTY(bool danmakuHideScroll READ danmakuHideScroll WRITE setDanmakuHideScroll NOTIFY playerChanged) + Q_PROPERTY(bool danmakuHideTop READ danmakuHideTop WRITE setDanmakuHideTop NOTIFY playerChanged) + Q_PROPERTY(bool danmakuHideBottom READ danmakuHideBottom WRITE setDanmakuHideBottom NOTIFY playerChanged) + Q_PROPERTY(QString danmakuBlockedKeywords READ danmakuBlockedKeywords WRITE setDanmakuBlockedKeywords NOTIFY playerChanged) + Q_PROPERTY(bool danmakuDualSubtitle READ danmakuDualSubtitle WRITE setDanmakuDualSubtitle NOTIFY playerChanged) + + // ── Player — danmaku provider ──────────────────────────────────── + Q_PROPERTY(QString danmakuProvider READ danmakuProvider WRITE setDanmakuProvider NOTIFY playerChanged) + Q_PROPERTY(QString danmakuProviderBaseUrl READ danmakuProviderBaseUrl WRITE setDanmakuProviderBaseUrl NOTIFY playerChanged) + Q_PROPERTY(QString danmakuProviderAppId READ danmakuProviderAppId WRITE setDanmakuProviderAppId NOTIFY playerChanged) + Q_PROPERTY(QString danmakuProviderAppSecret READ danmakuProviderAppSecret WRITE setDanmakuProviderAppSecret NOTIFY playerChanged) + + // ── Player — external ──────────────────────────────────────────── + Q_PROPERTY(bool extPlayerEnable READ extPlayerEnable WRITE setExtPlayerEnable NOTIFY playerChanged) + Q_PROPERTY(QString extPlayerPath READ extPlayerPath WRITE setExtPlayerPath NOTIFY playerChanged) + Q_PROPERTY(QString extPlayerArgs READ extPlayerArgs WRITE setExtPlayerArgs NOTIFY playerChanged) + Q_PROPERTY(bool extPlayerDirectStream READ extPlayerDirectStream WRITE setExtPlayerDirectStream NOTIFY playerChanged) + Q_PROPERTY(bool extPlayerQuickPlay READ extPlayerQuickPlay WRITE setExtPlayerQuickPlay NOTIFY playerChanged) + Q_PROPERTY(QString extPlayerUrlReplace READ extPlayerUrlReplace WRITE setExtPlayerUrlReplace NOTIFY playerChanged) + +public: + explicit SettingsViewModel(QObject *parent = nullptr); + + // ── General getters ────────────────────────────────────────────── + QString language() const; + bool rememberServer() const; + QString lastSelectedServerId() const; + bool closeToTray() const; + bool logEnable() const; + int apiTimeout() const; + int imageCacheLimit() const; + + // ── Network getters ────────────────────────────────────────────── + int proxyMode() const; + int proxyType() const; + QString proxyHost() const; + int proxyPort() const; + QString proxyUsername() const; + QString proxyPassword() const; + bool proxyBypassLocalhost() const; + + // ── Appearance getters ─────────────────────────────────────────── + QString themeMode() const; + QString defaultLibraryView() const; + QString fontSize() const; + QString sidebarPosition() const; + bool sidebarPinned() const; + bool sidebarCustomEnabled() const; + bool sidebarHideSearch() const; + bool sidebarHideHome() const; + bool sidebarHideFavorites() const; + QString startupWindowState() const; + bool uiAnimations() const; + bool snapshotNavigation() const; + bool searchHistoryEnabled() const; + bool searchAutocompleteEnabled() const; + + // ── Library getters ────────────────────────────────────────────── + bool showRecommended() const; + bool showContinueWatching() const; + bool showLatestAdded() const; + bool showCompletedWatching() const; + int continueWatchingRequestLimit() const; + int latestMediaRequestLimit() const; + int recommendedRequestLimit() const; + int completedWatchingRequestLimit() const; + bool showMediaLibraries() const; + bool showEachLibrary() const; + bool showFavoriteFolders() const; + QString imageQuality() const; + bool shimmerAnimation() const; + bool adaptiveImages() const; + bool showMediaTooltips() const; + QString dataCacheDuration() const; + QString imageCacheDuration() const; + + // ── Player — internal getters ──────────────────────────────────── + QString hwDecoding() const; + QString videoOutput() const; + QString videoSync() const; + QString toneMapping() const; + QString defaultScale() const; + QString audioLang() const; + QString subLang() const; + QString preferredVersion() const; + bool volNormalization() const; + bool continuousPlay() const; + QString seekStep() const; + bool longPressSeek() const; + QString longPressMode() const; + QString longPressTriggerMs() const; + bool mouseEdgeLongPress() const; + QString mediaSwitcherMode() const; + bool clickToPause() const; + bool skipIntro() const; + bool skipOutro() const; + bool independentWindow() const; + bool useMpvConf() const; + int volumeLevel() const; + bool volumeMuted() const; + + // ── Player — subtitle getters ──────────────────────────────────── + QString subtitleFont() const; + int subtitleDelayMs() const; + int subtitleFontSize() const; + int subtitlePosition() const; + int subtitleOutlineSize() const; + int subtitleShadowOffset() const; + double subtitleScale() const; + QString lastSubtitleDir() const; + QString externalSubtitle() const; + + // ── Player — danmaku getters ───────────────────────────────────── + bool danmakuEnabled() const; + QString danmakuRenderer() const; + int danmakuOpacity() const; + int danmakuFontScale() const; + int danmakuFontWeight() const; + int danmakuOutlineSize() const; + int danmakuShadowOffset() const; + int danmakuAreaPercent() const; + int danmakuDensity() const; + int danmakuSpeedScale() const; + int danmakuOffsetMs() const; + bool danmakuHideScroll() const; + bool danmakuHideTop() const; + bool danmakuHideBottom() const; + QString danmakuBlockedKeywords() const; + bool danmakuDualSubtitle() const; + + // ── Player — danmaku provider getters ──────────────────────────── + QString danmakuProvider() const; + QString danmakuProviderBaseUrl() const; + QString danmakuProviderAppId() const; + QString danmakuProviderAppSecret() const; + + // ── Player — external getters ──────────────────────────────────── + bool extPlayerEnable() const; + QString extPlayerPath() const; + QString extPlayerArgs() const; + bool extPlayerDirectStream() const; + bool extPlayerQuickPlay() const; + QString extPlayerUrlReplace() const; + + // ── Q_INVOKABLE setters ────────────────────────────────────────── + // General + Q_INVOKABLE void setLanguage(const QString &v); + Q_INVOKABLE void setRememberServer(bool v); + Q_INVOKABLE void setLastSelectedServerId(const QString &v); + Q_INVOKABLE void setCloseToTray(bool v); + Q_INVOKABLE void setLogEnable(bool v); + Q_INVOKABLE void setApiTimeout(int v); + Q_INVOKABLE void setImageCacheLimit(int v); + + // Network + Q_INVOKABLE void setProxyMode(int v); + Q_INVOKABLE void setProxyType(int v); + Q_INVOKABLE void setProxyHost(const QString &v); + Q_INVOKABLE void setProxyPort(int v); + Q_INVOKABLE void setProxyUsername(const QString &v); + Q_INVOKABLE void setProxyPassword(const QString &v); + Q_INVOKABLE void setProxyBypassLocalhost(bool v); + + // Appearance + Q_INVOKABLE void setThemeMode(const QString &v); + Q_INVOKABLE void setDefaultLibraryView(const QString &v); + Q_INVOKABLE void setFontSize(const QString &v); + Q_INVOKABLE void setSidebarPosition(const QString &v); + Q_INVOKABLE void setSidebarPinned(bool v); + Q_INVOKABLE void setSidebarCustomEnabled(bool v); + Q_INVOKABLE void setSidebarHideSearch(bool v); + Q_INVOKABLE void setSidebarHideHome(bool v); + Q_INVOKABLE void setSidebarHideFavorites(bool v); + Q_INVOKABLE void setStartupWindowState(const QString &v); + Q_INVOKABLE void setUiAnimations(bool v); + Q_INVOKABLE void setSnapshotNavigation(bool v); + Q_INVOKABLE void setSearchHistoryEnabled(bool v); + Q_INVOKABLE void setSearchAutocompleteEnabled(bool v); + + // Library + Q_INVOKABLE void setShowRecommended(bool v); + Q_INVOKABLE void setShowContinueWatching(bool v); + Q_INVOKABLE void setShowLatestAdded(bool v); + Q_INVOKABLE void setShowCompletedWatching(bool v); + Q_INVOKABLE void setContinueWatchingRequestLimit(int v); + Q_INVOKABLE void setLatestMediaRequestLimit(int v); + Q_INVOKABLE void setRecommendedRequestLimit(int v); + Q_INVOKABLE void setCompletedWatchingRequestLimit(int v); + Q_INVOKABLE void setShowMediaLibraries(bool v); + Q_INVOKABLE void setShowEachLibrary(bool v); + Q_INVOKABLE void setShowFavoriteFolders(bool v); + Q_INVOKABLE void setImageQuality(const QString &v); + Q_INVOKABLE void setShimmerAnimation(bool v); + Q_INVOKABLE void setAdaptiveImages(bool v); + Q_INVOKABLE void setShowMediaTooltips(bool v); + Q_INVOKABLE void setDataCacheDuration(const QString &v); + Q_INVOKABLE void setImageCacheDuration(const QString &v); + + // Player — internal + Q_INVOKABLE void setHwDecoding(const QString &v); + Q_INVOKABLE void setVideoOutput(const QString &v); + Q_INVOKABLE void setVideoSync(const QString &v); + Q_INVOKABLE void setToneMapping(const QString &v); + Q_INVOKABLE void setDefaultScale(const QString &v); + Q_INVOKABLE void setAudioLang(const QString &v); + Q_INVOKABLE void setSubLang(const QString &v); + Q_INVOKABLE void setPreferredVersion(const QString &v); + Q_INVOKABLE void setVolNormalization(bool v); + Q_INVOKABLE void setContinuousPlay(bool v); + Q_INVOKABLE void setSeekStep(const QString &v); + Q_INVOKABLE void setLongPressSeek(bool v); + Q_INVOKABLE void setLongPressMode(const QString &v); + Q_INVOKABLE void setLongPressTriggerMs(const QString &v); + Q_INVOKABLE void setMouseEdgeLongPress(bool v); + Q_INVOKABLE void setMediaSwitcherMode(const QString &v); + Q_INVOKABLE void setClickToPause(bool v); + Q_INVOKABLE void setSkipIntro(bool v); + Q_INVOKABLE void setSkipOutro(bool v); + Q_INVOKABLE void setIndependentWindow(bool v); + Q_INVOKABLE void setUseMpvConf(bool v); + Q_INVOKABLE void setVolumeLevel(int v); + Q_INVOKABLE void setVolumeMuted(bool v); + + // Player — subtitle + Q_INVOKABLE void setSubtitleFont(const QString &v); + Q_INVOKABLE void setSubtitleDelayMs(int v); + Q_INVOKABLE void setSubtitleFontSize(int v); + Q_INVOKABLE void setSubtitlePosition(int v); + Q_INVOKABLE void setSubtitleOutlineSize(int v); + Q_INVOKABLE void setSubtitleShadowOffset(int v); + Q_INVOKABLE void setSubtitleScale(double v); + Q_INVOKABLE void setLastSubtitleDir(const QString &v); + Q_INVOKABLE void setExternalSubtitle(const QString &v); + + // Player — danmaku + Q_INVOKABLE void setDanmakuEnabled(bool v); + Q_INVOKABLE void setDanmakuRenderer(const QString &v); + Q_INVOKABLE void setDanmakuOpacity(int v); + Q_INVOKABLE void setDanmakuFontScale(int v); + Q_INVOKABLE void setDanmakuFontWeight(int v); + Q_INVOKABLE void setDanmakuOutlineSize(int v); + Q_INVOKABLE void setDanmakuShadowOffset(int v); + Q_INVOKABLE void setDanmakuAreaPercent(int v); + Q_INVOKABLE void setDanmakuDensity(int v); + Q_INVOKABLE void setDanmakuSpeedScale(int v); + Q_INVOKABLE void setDanmakuOffsetMs(int v); + Q_INVOKABLE void setDanmakuHideScroll(bool v); + Q_INVOKABLE void setDanmakuHideTop(bool v); + Q_INVOKABLE void setDanmakuHideBottom(bool v); + Q_INVOKABLE void setDanmakuBlockedKeywords(const QString &v); + Q_INVOKABLE void setDanmakuDualSubtitle(bool v); + + // Player — danmaku provider + Q_INVOKABLE void setDanmakuProvider(const QString &v); + Q_INVOKABLE void setDanmakuProviderBaseUrl(const QString &v); + Q_INVOKABLE void setDanmakuProviderAppId(const QString &v); + Q_INVOKABLE void setDanmakuProviderAppSecret(const QString &v); + + // Player — external + Q_INVOKABLE void setExtPlayerEnable(bool v); + Q_INVOKABLE void setExtPlayerPath(const QString &v); + Q_INVOKABLE void setExtPlayerArgs(const QString &v); + Q_INVOKABLE void setExtPlayerDirectStream(bool v); + Q_INVOKABLE void setExtPlayerQuickPlay(bool v); + Q_INVOKABLE void setExtPlayerUrlReplace(const QString &v); + +signals: + void generalChanged(); + void networkChanged(); + void appearanceChanged(); + void libraryChanged(); + void playerChanged(); + +private slots: + void onValueChanged(const QString &key, const QVariant &newValue); + +private: + class ConfigStore *m_store; +}; + +#endif // SETTINGSVIEWMODEL_H diff --git a/src/qEmbyApp/quick/tasksviewmodel.cpp b/src/qEmbyApp/quick/tasksviewmodel.cpp new file mode 100644 index 0000000..07ef0cd --- /dev/null +++ b/src/qEmbyApp/quick/tasksviewmodel.cpp @@ -0,0 +1,329 @@ +#include "tasksviewmodel.h" + +#include "../utils/taskutils.h" + +#include +#include + +#include +#include +#include + +#include + +namespace { +constexpr int AutoRefreshIntervalMs = 3000; + +QString sectionFor(const ScheduledTaskInfo &task) { + if (task.isRunning()) + return TasksViewModel::tr("Running Now"); + if (task.isHidden) + return TasksViewModel::tr("System Tasks"); + if (task.hasTriggers()) + return TasksViewModel::tr("Scheduled Tasks"); + return TasksViewModel::tr("Manual Tasks"); +} + +QString sectionSubtitleFor(const ScheduledTaskInfo &task) { + if (task.isRunning()) + return TasksViewModel::tr("Tasks that are currently executing. Progress " + "refreshes automatically."); + if (task.isHidden) + return TasksViewModel::tr( + "Hidden or built-in tasks reported by the server."); + if (task.hasTriggers()) + return TasksViewModel::tr("Tasks with one or more configured triggers."); + return TasksViewModel::tr("Tasks that only run when started manually."); +} +} // namespace + +TasksListModel::TasksListModel(QObject *parent) : QAbstractListModel(parent) {} + +int TasksListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_tasks.size(); +} + +QVariant TasksListModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_tasks.size()) + return {}; + + const ScheduledTaskInfo &task = m_tasks.at(index.row()); + switch (role) { + case TaskIdRole: + return task.id; + case NameRole: + return task.name; + case DescriptionRole: + return task.description; + case CategoryRole: + return task.category.isEmpty() ? TasksViewModel::tr("Other") + : task.category; + case ModeRole: + return task.isHidden ? TasksViewModel::tr("System") + : task.hasTriggers() ? TasksViewModel::tr("Scheduled") + : TasksViewModel::tr("Manual"); + case SectionRole: + return sectionFor(task); + case SectionSubtitleRole: + return sectionSubtitleFor(task); + case StateRole: + return TaskUtils::localizedTaskState(task.state); + case ScheduleRole: + return TaskUtils::formatTriggerSummary(task.triggers, 2); + case StatusRole: { + QStringList parts; + parts.append(TasksViewModel::tr("State: %1") + .arg(TaskUtils::localizedTaskState(task.state))); + if (!task.lastExecutionResult.isEmpty()) { + QString result = + TasksViewModel::tr("Last result: %1") + .arg(TaskUtils::localizedTaskResult(task.lastExecutionResult)); + if (!task.lastEndTime.isEmpty()) + result += QStringLiteral(" · %1").arg( + TaskUtils::formatIsoDateTime(task.lastEndTime)); + parts.append(result); + } + return parts.join(QStringLiteral(" · ")); + } + case RunningRole: + return task.isRunning(); + case ProgressRole: + return task.currentProgressPercentage; + case ActionBusyRole: + return m_busyTaskIds.contains(task.id); + } + return {}; +} + +QHash TasksListModel::roleNames() const { + return {{TaskIdRole, "taskId"}, + {NameRole, "name"}, + {DescriptionRole, "description"}, + {CategoryRole, "category"}, + {ModeRole, "mode"}, + {SectionRole, "section"}, + {SectionSubtitleRole, "sectionSubtitle"}, + {StateRole, "state"}, + {ScheduleRole, "schedule"}, + {StatusRole, "status"}, + {RunningRole, "running"}, + {ProgressRole, "progress"}, + {ActionBusyRole, "actionBusy"}}; +} + +void TasksListModel::resetTasks(QList tasks) { + std::sort(tasks.begin(), tasks.end(), + [](const ScheduledTaskInfo &left, const ScheduledTaskInfo &right) { + if (left.isRunning() != right.isRunning()) + return left.isRunning(); + if (left.isHidden != right.isHidden) + return !left.isHidden; + if (left.hasTriggers() != right.hasTriggers()) + return left.hasTriggers(); + + const QString leftCategory = + left.category.isEmpty() ? QStringLiteral("~") : left.category; + const QString rightCategory = right.category.isEmpty() + ? QStringLiteral("~") + : right.category; + const int categoryOrder = + QString::localeAwareCompare(leftCategory, rightCategory); + return categoryOrder == 0 + ? QString::localeAwareCompare(left.name, right.name) < + 0 + : categoryOrder < 0; + }); + + beginResetModel(); + m_tasks = std::move(tasks); + endResetModel(); +} + +void TasksListModel::setActionBusy(const QString &taskId, bool busy) { + if (busy) + m_busyTaskIds.insert(taskId); + else + m_busyTaskIds.remove(taskId); + + for (int row = 0; row < m_tasks.size(); ++row) { + if (m_tasks.at(row).id == taskId) { + const QModelIndex changed = index(row); + emit dataChanged(changed, changed, {ActionBusyRole}); + return; + } + } +} + +void TasksListModel::clearActionBusy() { + if (m_busyTaskIds.isEmpty()) + return; + m_busyTaskIds.clear(); + if (!m_tasks.isEmpty()) + emit dataChanged(index(0), index(m_tasks.size() - 1), {ActionBusyRole}); +} + +QString TasksListModel::taskName(const QString &taskId) const { + for (const ScheduledTaskInfo &task : m_tasks) { + if (task.id == taskId) + return task.name; + } + return {}; +} + +TasksViewModel::TasksViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_model(this) { + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(AutoRefreshIntervalMs); + connect(m_refreshTimer, &QTimer::timeout, this, + [this]() { loadData(false); }); +} + +void TasksViewModel::refresh() { + if (m_active) + loadData(true); +} + +void TasksViewModel::runTask(const QString &taskId) { + if (m_active && !taskId.isEmpty()) + changeTaskState(taskId, false); +} + +void TasksViewModel::stopTask(const QString &taskId) { + if (m_active && !taskId.isEmpty()) + changeTaskState(taskId, true); +} + +void TasksViewModel::editTask(const QString &taskId) { + if (!m_active || taskId.isEmpty()) + return; + emit editRequested(taskId, m_model.taskName(taskId)); +} + +void TasksViewModel::setActive(bool active) { + if (m_active == active) + return; + + m_active = active; + ++m_activation; + ++m_requestSerial; + + if (active) { + m_refreshTimer->start(); + loadData(false); + return; + } + + m_refreshTimer->stop(); + m_model.clearActionBusy(); + m_loading = false; + m_refreshing = false; + emit stateChanged(); +} + +QCoro::Task TasksViewModel::loadData(bool manual) { + if (!m_active || (m_loading && !manual)) + co_return; + + const quint64 activation = m_activation; + const quint64 request = ++m_requestSerial; + QPointer guard(this); + + m_loading = true; + m_refreshing = manual; + if (!m_hasLoaded) + m_errorMessage.clear(); + emit stateChanged(); + + try { + QList tasks = + co_await m_core->adminService()->getScheduledTasks(); + if (!guard || !requestIsCurrent(activation, request)) + co_return; + + m_model.resetTasks(tasks); + updateSummary(tasks); + m_hasLoaded = true; + m_errorMessage.clear(); + } catch (const std::exception &error) { + if (!guard || !requestIsCurrent(activation, request)) + co_return; + + m_errorMessage = + tr("Failed to load tasks: %1").arg(QString::fromUtf8(error.what())); + if (m_hasLoaded) + emit toastRequested(m_errorMessage, QStringLiteral("error")); + } + + if (!guard || !requestIsCurrent(activation, request)) + co_return; + m_loading = false; + m_refreshing = false; + emit stateChanged(); +} + +QCoro::Task TasksViewModel::changeTaskState(QString taskId, bool stop) { + const quint64 activation = m_activation; + QPointer guard(this); + const QString taskName = m_model.taskName(taskId); + m_model.setActionBusy(taskId, true); + + try { + if (stop) + co_await m_core->adminService()->stopScheduledTask(taskId); + else + co_await m_core->adminService()->runScheduledTask(taskId); + + if (!guard || !m_active || activation != m_activation) + co_return; + emit toastRequested(stop ? tr("Task \"%1\" stopped").arg(taskName) + : tr("Task \"%1\" started").arg(taskName), + QStringLiteral("success")); + m_model.setActionBusy(taskId, false); + loadData(false); + } catch (const std::exception &error) { + if (!guard || !m_active || activation != m_activation) + co_return; + m_model.setActionBusy(taskId, false); + emit toastRequested( + stop + ? tr("Failed to stop task: %1").arg(QString::fromUtf8(error.what())) + : tr("Failed to run task: %1").arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } +} + +bool TasksViewModel::requestIsCurrent(quint64 activation, + quint64 request) const { + return m_active && activation == m_activation && request == m_requestSerial; +} + +void TasksViewModel::updateSummary(const QList &tasks) { + int systemCount = 0; + m_totalCount = tasks.size(); + m_runningCount = 0; + m_scheduledCount = 0; + m_manualCount = 0; + + for (const ScheduledTaskInfo &task : tasks) { + if (task.isRunning()) + ++m_runningCount; + if (task.isHidden) + ++systemCount; + else if (task.hasTriggers()) + ++m_scheduledCount; + else + ++m_manualCount; + } + + if (tasks.isEmpty()) { + m_statsText = tr("No tasks"); + } else { + QStringList parts{tr("%n task(s)", "", tasks.size())}; + if (m_runningCount > 0) + parts.append(tr("%n running", "", m_runningCount)); + if (systemCount > 0) + parts.append(tr("%n system", "", systemCount)); + m_statsText = parts.join(QStringLiteral(" · ")); + } + emit summaryChanged(); +} diff --git a/src/qEmbyApp/quick/tasksviewmodel.h b/src/qEmbyApp/quick/tasksviewmodel.h new file mode 100644 index 0000000..f94b1c4 --- /dev/null +++ b/src/qEmbyApp/quick/tasksviewmodel.h @@ -0,0 +1,116 @@ +#ifndef TASKSVIEWMODEL_H +#define TASKSVIEWMODEL_H + +#include +#include + +#include +#include + +class QEmbyCore; +class QTimer; + +class TasksListModel : public QAbstractListModel { + Q_OBJECT +public: + enum Role { + TaskIdRole = Qt::UserRole + 1, + NameRole, + DescriptionRole, + CategoryRole, + ModeRole, + SectionRole, + SectionSubtitleRole, + StateRole, + ScheduleRole, + StatusRole, + RunningRole, + ProgressRole, + ActionBusyRole, + }; + + explicit TasksListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void resetTasks(QList tasks); + void setActionBusy(const QString &taskId, bool busy); + void clearActionBusy(); + QString taskName(const QString &taskId) const; + +private: + QList m_tasks; + QSet m_busyTaskIds; +}; + +class TasksViewModel : public QObject { + Q_OBJECT + Q_PROPERTY(QAbstractListModel *model READ model CONSTANT) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(bool hasLoaded READ hasLoaded NOTIFY stateChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY stateChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY summaryChanged) + Q_PROPERTY(int totalCount READ totalCount NOTIFY summaryChanged) + Q_PROPERTY(int scheduledCount READ scheduledCount NOTIFY summaryChanged) + Q_PROPERTY(int runningCount READ runningCount NOTIFY summaryChanged) + Q_PROPERTY(int manualCount READ manualCount NOTIFY summaryChanged) + +public: + explicit TasksViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QAbstractListModel *model() { return &m_model; } + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + bool hasLoaded() const { return m_hasLoaded; } + QString errorMessage() const { return m_errorMessage; } + QString statsText() const { return m_statsText; } + int totalCount() const { return m_totalCount; } + int scheduledCount() const { return m_scheduledCount; } + int runningCount() const { return m_runningCount; } + int manualCount() const { return m_manualCount; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void runTask(const QString &taskId); + Q_INVOKABLE void stopTask(const QString &taskId); + Q_INVOKABLE void editTask(const QString &taskId); + +public slots: + void setActive(bool active); + +signals: + void stateChanged(); + void summaryChanged(); + void toastRequested(const QString &message, const QString &kind); + void editRequested(const QString &taskId, const QString &taskName); + +private: + QCoro::Task loadData(bool manual); + QCoro::Task changeTaskState(QString taskId, bool stop); + bool requestIsCurrent(quint64 activation, quint64 request) const; + void updateSummary(const QList &tasks); + + QEmbyCore *m_core = nullptr; + QTimer *m_refreshTimer = nullptr; + TasksListModel m_model; + + bool m_active = false; + bool m_loading = false; + bool m_refreshing = false; + bool m_hasLoaded = false; + QString m_errorMessage; + QString m_statsText; + int m_totalCount = 0; + int m_scheduledCount = 0; + int m_runningCount = 0; + int m_manualCount = 0; + + // Incrementing activation invalidates every coroutine started before a + // show/hide transition. requestSerial makes a newer manual request win. + quint64 m_activation = 0; + quint64 m_requestSerial = 0; +}; + +#endif // TASKSVIEWMODEL_H diff --git a/src/qEmbyApp/resources/qss/dark-style.qss b/src/qEmbyApp/resources/qss/dark-style.qss index f35f4c4..dbd41ff 100644 --- a/src/qEmbyApp/resources/qss/dark-style.qss +++ b/src/qEmbyApp/resources/qss/dark-style.qss @@ -98,6 +98,7 @@ QAbstractScrollArea::corner { QWK--WindowBar[bar-active=true], QWK--WindowBar[bar-active=false] { background-color: transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); } QWK--WindowBar>QLabel#win-title-slot, @@ -129,17 +130,19 @@ QLabel#mac-title-label { /* 标题栏居中全局搜索框 */ QLineEdit#titlebar-search { background-color: #1E293B; - border: 1px solid transparent; + border: 1px solid #334155; border-radius: 14px; padding: 0px 18px 0px 8px; font-size: 13px; font-weight: 500; color: #F8FAFC; + selection-background-color: #3B82F6; margin: 0; } QLineEdit#titlebar-search:hover { - background-color: #334155; + background-color: #1E293B; + border: 1px solid #475569; } QLineEdit#titlebar-search:focus { @@ -333,6 +336,17 @@ QWK--WindowBar QAbstractButton[system-button=true] { background-color: transparent; } +/* 导航/主题按钮:悬浮圆角胶囊,区别于右侧全高的系统窗口按钮 */ +QWK--WindowBar QAbstractButton#back-button, +QWK--WindowBar QAbstractButton#home-button, +QWK--WindowBar QAbstractButton#fav-button, +QWK--WindowBar QAbstractButton#theme-button, +QWK--WindowBar QAbstractButton#pin-button { + min-width: 38px; + margin: 4px 2px; + border-radius: 6px; +} + QWK--WindowBar QAbstractButton#pin-button { qproperty-iconNormal: url(":/svg/dark/pin.svg"); qproperty-iconChecked: url(":/svg/dark/pin-fill.svg"); @@ -354,11 +368,11 @@ QWK--WindowBar QAbstractButton#max-button:pressed { } QWK--WindowBar QAbstractButton#fav-button { - margin-right: 10px; + margin-right: 12px; } QWidget#mac-titlebar-nav QAbstractButton#fav-button { - margin-right: 0px; + margin-right: 2px; } QWK--WindowBar QAbstractButton#back-button:pressed, @@ -373,14 +387,17 @@ QWK--WindowBar QAbstractButton#min-button { QWK--WindowBar QAbstractButton#back-button { qproperty-iconNormal: url(":/svg/dark/arrow-left.svg"); + qproperty-iconSize: 14px 14px; } QWK--WindowBar QAbstractButton#home-button { qproperty-iconNormal: url(":/svg/dark/bar-home.svg"); + qproperty-iconSize: 14px 14px; } QWK--WindowBar QAbstractButton#fav-button { qproperty-iconNormal: url(":/svg/dark/bar-heart.svg"); + qproperty-iconSize: 14px 14px; } QWK--WindowBar QAbstractButton#max-button { diff --git a/src/qEmbyApp/resources/qss/light-style.qss b/src/qEmbyApp/resources/qss/light-style.qss index 22397e1..9164f21 100644 --- a/src/qEmbyApp/resources/qss/light-style.qss +++ b/src/qEmbyApp/resources/qss/light-style.qss @@ -97,6 +97,7 @@ QAbstractScrollArea::corner { QWK--WindowBar[bar-active=true], QWK--WindowBar[bar-active=false] { background-color: transparent; + border-bottom: 1px solid #E2E8F0; } QWK--WindowBar>QLabel#win-title-slot, @@ -127,8 +128,8 @@ QLabel#mac-title-label { ========================================= */ /* 标题栏居中全局搜索框 */ QLineEdit#titlebar-search { - background-color: #F1F5F9; - border: 1px solid transparent; + background-color: #FFFFFF; + border: 1px solid #E2E8F0; border-radius: 14px; /* 32px 高度对应的完美胶囊圆角 */ padding: 0px 18px 0px 8px; @@ -136,12 +137,13 @@ QLineEdit#titlebar-search { font-size: 13px; font-weight: 500; color: #1E293B; + selection-background-color: #3B82F6; margin: 0; } QLineEdit#titlebar-search:hover { - background-color: #E2E8F0; - /* 悬浮微量加深 */ + background-color: #FFFFFF; + border: 1px solid #CBD5E1; } QLineEdit#titlebar-search:focus { @@ -339,6 +341,17 @@ QWK--WindowBar QAbstractButton[system-button=true] { background-color: transparent; } +/* 导航/主题按钮:悬浮圆角胶囊,区别于右侧全高的系统窗口按钮 */ +QWK--WindowBar QAbstractButton#back-button, +QWK--WindowBar QAbstractButton#home-button, +QWK--WindowBar QAbstractButton#fav-button, +QWK--WindowBar QAbstractButton#theme-button, +QWK--WindowBar QAbstractButton#pin-button { + min-width: 38px; + margin: 4px 2px; + border-radius: 6px; +} + QWK--WindowBar QAbstractButton#pin-button { qproperty-iconNormal: url(":/svg/light/pin.svg"); qproperty-iconChecked: url(":/svg/light/pin-fill.svg"); @@ -360,18 +373,18 @@ QWK--WindowBar QAbstractButton#max-button:pressed { } QWK--WindowBar QAbstractButton#fav-button { - margin-right: 10px; + margin-right: 12px; } QWidget#mac-titlebar-nav QAbstractButton#fav-button { - margin-right: 0px; + margin-right: 2px; } QWK--WindowBar QAbstractButton#back-button:pressed, QWK--WindowBar QAbstractButton#home-button:pressed, QWK--WindowBar QAbstractButton#fav-button:pressed { - background-color: rgba(0, 0, 0, 18%); + background-color: rgba(0, 0, 0, 12%); } QWK--WindowBar QAbstractButton#min-button { @@ -380,14 +393,17 @@ QWK--WindowBar QAbstractButton#min-button { QWK--WindowBar QAbstractButton#back-button { qproperty-iconNormal: url(":/svg/light/arrow-left.svg"); + qproperty-iconSize: 14px 14px; } QWK--WindowBar QAbstractButton#home-button { qproperty-iconNormal: url(":/svg/light/bar-home.svg"); + qproperty-iconSize: 14px 14px; } QWK--WindowBar QAbstractButton#fav-button { qproperty-iconNormal: url(":/svg/light/bar-heart.svg"); + qproperty-iconSize: 14px 14px; } diff --git a/src/qEmbyApp/tests/navigationcontroller_test.cpp b/src/qEmbyApp/tests/navigationcontroller_test.cpp new file mode 100644 index 0000000..6f4cd52 --- /dev/null +++ b/src/qEmbyApp/tests/navigationcontroller_test.cpp @@ -0,0 +1,75 @@ +#include "navigationcontroller.h" + +#include +#include + +namespace +{ +bool check(bool condition, const char *message) +{ + if (!condition) + qCritical() << "navigationcontroller_test:" << message; + return condition; +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + NavigationController navigation; + + bool ok = true; + ok &= check(navigation.currentRoute().value("name") == "login", "starts at login"); + ok &= check(!navigation.push({{"name", "detail"}, + {"params", QVariantMap{{"itemId", "42"}}}}), + "rejects protected routes before authentication"); + ok &= check(navigation.depth() == 1, "rejection is atomic"); + + navigation.enterAuthenticatedSession(false); + ok &= check(navigation.currentRoute().value("name") == "home", "session starts at home"); + ok &= check(navigation.push({{"name", "library"}, + {"params", QVariantMap{{"libraryId", "lib/1"}, + {"title", "Movies"}}}}), + "accepts a valid library route"); + ok &= check(navigation.push({{"name", "detail"}, + {"params", QVariantMap{{"itemId", "item 2"}}}}), + "accepts a valid detail route"); + ok &= check(navigation.depth() == 3 && navigation.canGoBack(), "push grows the stack"); + ok &= check(navigation.currentRoute().value("key") == "detail:item%202", + "controller generates the route key"); + + const int depthBeforeInvalidPush = navigation.depth(); + ok &= check(!navigation.push({{"name", "season"}, + {"params", QVariantMap{{"seasonId", "s1"}}}}), + "rejects missing required parameters"); + ok &= check(navigation.depth() == depthBeforeInvalidPush, + "invalid push leaves the stack unchanged"); + + navigation.updateUiState({{"scrollY", 128.0}, {"secret", "ignored"}}); + const QVariantMap uiState = navigation.currentRoute().value("uiState").toMap(); + ok &= check(uiState.value("scrollY").toDouble() == 128.0 && !uiState.contains("secret"), + "ui state uses an allowlist"); + ok &= check(navigation.back() && navigation.currentRoute().value("name") == "library", + "back restores the previous route"); + + ok &= check(!navigation.push({{"name", "manage"}}), + "non-admin session cannot enter manage"); + navigation.setAdministrator(true); + ok &= check(navigation.push({{"name", "manage"}, + {"params", QVariantMap{{"section", "collections"}}}}), + "admin can enter an allowlisted manage section"); + ok &= check(!navigation.replace({{"name", "manage"}, + {"params", QVariantMap{{"section", "lists"}}}}), + "unknown manage section is rejected"); + + ok &= check(navigation.resetToRoot("favorites") && navigation.depth() == 1, + "favorites resets the session stack"); + navigation.home(); + ok &= check(navigation.currentRoute().value("name") == "home" && navigation.depth() == 1, + "home resets the session stack"); + navigation.leaveAuthenticatedSession(); + ok &= check(navigation.currentRoute().value("name") == "login" && navigation.depth() == 1, + "logout clears session routes"); + + return ok ? 0 : 1; +} diff --git a/src/qEmbyApp/tokens/ColorTokens.h b/src/qEmbyApp/tokens/ColorTokens.h index db70051..082bc7d 100644 --- a/src/qEmbyApp/tokens/ColorTokens.h +++ b/src/qEmbyApp/tokens/ColorTokens.h @@ -41,6 +41,10 @@ namespace Red { inline const QColor k600 {"#DC2626"}; } +namespace Amber { + inline const QColor k500 {"#F59E0B"}; +} + namespace Green { inline const QColor k500 {"#22C55E"}; } diff --git a/src/qEmbyApp/views/admin/collectionsviewmodel.cpp b/src/qEmbyApp/views/admin/collectionsviewmodel.cpp new file mode 100644 index 0000000..9740c65 --- /dev/null +++ b/src/qEmbyApp/views/admin/collectionsviewmodel.cpp @@ -0,0 +1,383 @@ +#include "collectionsviewmodel.h" + +#include +#include + +#include +#include + +#include + +CollectionsListModel::CollectionsListModel(QObject *parent) + : QAbstractListModel(parent) {} + +int CollectionsListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_entries.size(); +} + +QVariant CollectionsListModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) + return {}; + const CollectionListEntry &item = m_entries.at(index.row()); + switch (role) { + case ItemIdRole: + return item.id; + case NameRole: + return item.name; + case ChildCountRole: + return item.childCount; + case SubtitleRole: + return tr("%n item(s)", "", item.childCount); + case ImageSourceRole: + return item.id.isEmpty() + ? QString() + : QStringLiteral("image://emby/%1,Primary").arg(item.id); + case SelectedRole: + return m_selectedIds.contains(item.id); + default: + return {}; + } +} + +QHash CollectionsListModel::roleNames() const { + return {{ItemIdRole, "itemId"}, {NameRole, "name"}, + {ChildCountRole, "childCount"}, {SubtitleRole, "subtitle"}, + {ImageSourceRole, "imageSource"}, {SelectedRole, "selected"}}; +} + +void CollectionsListModel::setEntries(QList entries) { + QSet retained; + for (const CollectionListEntry &entry : entries) { + if (m_selectedIds.contains(entry.id)) + retained.insert(entry.id); + } + beginResetModel(); + m_entries = std::move(entries); + m_selectedIds = std::move(retained); + endResetModel(); +} + +const CollectionListEntry * +CollectionsListModel::entry(const QString &itemId) const { + const int row = indexOf(itemId); + return row < 0 ? nullptr : &m_entries.at(row); +} + +QStringList CollectionsListModel::orderedIds() const { + QStringList ids; + ids.reserve(m_entries.size()); + for (const auto &entry : m_entries) + ids.append(entry.id); + return ids; +} + +QStringList CollectionsListModel::selectedIds() const { + QStringList ids; + for (const auto &entry : m_entries) { + if (m_selectedIds.contains(entry.id)) + ids.append(entry.id); + } + return ids; +} + +bool CollectionsListModel::allSelected() const { + return !m_entries.isEmpty() && m_selectedIds.size() == m_entries.size(); +} + +void CollectionsListModel::toggleSelected(const QString &itemId) { + const int row = indexOf(itemId); + if (row < 0) + return; + if (m_selectedIds.contains(itemId)) + m_selectedIds.remove(itemId); + else + m_selectedIds.insert(itemId); + emit dataChanged(index(row), index(row), {SelectedRole}); +} + +void CollectionsListModel::toggleSelectAll() { + beginResetModel(); + if (allSelected()) { + m_selectedIds.clear(); + } else { + m_selectedIds.clear(); + for (const auto &entry : m_entries) + m_selectedIds.insert(entry.id); + } + endResetModel(); +} + +void CollectionsListModel::clearSelection() { + if (m_selectedIds.isEmpty()) + return; + beginResetModel(); + m_selectedIds.clear(); + endResetModel(); +} + +bool CollectionsListModel::move(int from, int to) { + if (from < 0 || from >= m_entries.size() || to < 0 || + to >= m_entries.size() || from == to) + return false; + const int destination = to > from ? to + 1 : to; + if (!beginMoveRows({}, from, from, {}, destination)) + return false; + m_entries.move(from, to); + endMoveRows(); + return true; +} + +int CollectionsListModel::indexOf(const QString &itemId) const { + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries.at(row).id == itemId) + return row; + } + return -1; +} + +CollectionsViewModel::CollectionsViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_collections(this) {} + +void CollectionsViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_loadGeneration; + m_orderBeforeDrag.clear(); + if (m_loading || m_refreshing) { + m_loaded = false; + m_loading = false; + m_refreshing = false; + emit stateChanged(); + } + return; + } + if (!m_loaded) + QCoro::connect(load(false), this, [] {}); +} + +void CollectionsViewModel::refresh() { + if (m_active && !m_loading && !m_refreshing && !m_busy) + QCoro::connect(load(true), this, [] {}); +} + +void CollectionsViewModel::retry() { refresh(); } + +void CollectionsViewModel::toggleSelected(const QString &itemId) { + if (m_busy) + return; + m_collections.toggleSelected(itemId); + emit stateChanged(); +} + +void CollectionsViewModel::toggleSelectAll() { + if (m_busy) + return; + m_collections.toggleSelectAll(); + emit stateChanged(); +} + +void CollectionsViewModel::createCollection(const QString &name) { + const QString trimmed = name.trimmed(); + if (!trimmed.isEmpty() && !m_busy) + QCoro::connect(create(trimmed), this, [] {}); +} + +void CollectionsViewModel::renameCollection(const QString &itemId, + const QString &name) { + const CollectionListEntry *item = m_collections.entry(itemId); + const QString trimmed = name.trimmed(); + if (item && !trimmed.isEmpty() && trimmed != item->name && !m_busy) + QCoro::connect(rename(itemId, trimmed), this, [] {}); +} + +void CollectionsViewModel::deleteCollection(const QString &itemId) { + if (m_collections.entry(itemId) && !m_busy) + QCoro::connect(remove({itemId}), this, [] {}); +} + +void CollectionsViewModel::deleteSelected() { + const QStringList ids = m_collections.selectedIds(); + if (!ids.isEmpty() && !m_busy) + QCoro::connect(remove(ids), this, [] {}); +} + +void CollectionsViewModel::moveCollection(int from, int to) { + if (!m_active || m_busy) + return; + if (m_orderBeforeDrag.isEmpty()) + m_orderBeforeDrag = m_collections.orderedIds(); + if (!m_collections.move(from, to)) + return; + emit stateChanged(); +} + +void CollectionsViewModel::saveCollectionOrder() { + if (!m_active || m_busy || m_orderBeforeDrag.isEmpty()) + return; + const QStringList oldOrder = std::exchange(m_orderBeforeDrag, {}); + QCoro::connect(saveOrder(oldOrder, m_collections.orderedIds()), this, [] {}); +} + +QCoro::Task CollectionsViewModel::load(bool manualRefresh) { + if (!m_core || !m_core->adminService() || !m_active) + co_return; + const quint64 generation = ++m_loadGeneration; + m_loading = !manualRefresh && !m_loaded; + m_refreshing = manualRefresh || m_loaded; + m_errorText.clear(); + emit stateChanged(); + + QPointer guard(this); + try { + const QJsonArray values = + co_await m_core->adminService()->getUserPlaylists(); + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + QList entries; + entries.reserve(values.size()); + for (const QJsonValue &value : values) { + const QJsonObject object = value.toObject(); + CollectionListEntry entry; + entry.id = object.value(QStringLiteral("Id")).toString(); + entry.name = object.value(QStringLiteral("Name")).toString(); + entry.childCount = object.value(QStringLiteral("ChildCount")).toInt(); + entry.dateCreated = + object.value(QStringLiteral("DateCreated")).toString(); + if (!entry.id.isEmpty()) + entries.append(std::move(entry)); + } + m_collections.setEntries(std::move(entries)); + m_orderBeforeDrag.clear(); + m_loaded = true; + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + m_errorText = + tr("Failed to load playlists: %1").arg(QString::fromUtf8(error.what())); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + m_loading = false; + m_refreshing = false; + emit stateChanged(); +} + +QCoro::Task CollectionsViewModel::create(QString name) { + setBusy(true); + QPointer guard(this); + try { + co_await m_core->adminService()->createPlaylist(name); + if (!guard) + co_return; + emit notificationRequested(tr("Playlist created"), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested(tr("Failed to create playlist: %1") + .arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + if (!guard) + co_return; + setBusy(false); + if (m_active) + co_await load(false); + else + m_loaded = false; +} + +QCoro::Task CollectionsViewModel::rename(QString itemId, QString name) { + setBusy(true); + QPointer guard(this); + try { + co_await m_core->adminService()->renameItem(itemId, name); + if (!guard) + co_return; + emit notificationRequested(tr("Renamed successfully"), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested( + tr("Rename failed: %1").arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + if (!guard) + co_return; + setBusy(false); + if (m_active) + co_await load(false); + else + m_loaded = false; + m_orderBeforeDrag.clear(); +} + +QCoro::Task CollectionsViewModel::remove(QStringList itemIds) { + setBusy(true); + int deleted = 0; + QString failure; + QPointer guard(this); + for (const QString &itemId : itemIds) { + try { + co_await m_core->adminService()->deleteItem(itemId); + if (!guard) + co_return; + ++deleted; + } catch (const std::exception &error) { + failure = QString::fromUtf8(error.what()); + break; + } + } + if (!guard) + co_return; + m_collections.clearSelection(); + if (failure.isEmpty()) + emit notificationRequested(tr("Deleted %n playlist(s)", "", deleted), + QStringLiteral("success")); + else + emit notificationRequested( + tr("Deleted %1 playlist(s), then failed: %2").arg(deleted).arg(failure), + QStringLiteral("error")); + setBusy(false); + if (m_active) + co_await load(false); + else + m_loaded = false; +} + +QCoro::Task CollectionsViewModel::saveOrder(QStringList oldOrder, + QStringList newOrder) { + if (oldOrder == newOrder || !m_core || !m_core->adminService()) + co_return; + setBusy(true); + QPointer guard(this); + try { + co_await m_core->adminService()->updatePlaylistOrderByDateCreated(newOrder); + if (!guard) + co_return; + emit notificationRequested(tr("Playlist order updated"), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested(tr("Failed to save playlist order: %1") + .arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + m_loaded = false; + } + if (!guard) + co_return; + setBusy(false); + if (!m_loaded && m_active) + co_await load(false); +} + +void CollectionsViewModel::setBusy(bool busy) { + if (m_busy == busy) + return; + m_busy = busy; + emit stateChanged(); +} diff --git a/src/qEmbyApp/views/admin/collectionsviewmodel.h b/src/qEmbyApp/views/admin/collectionsviewmodel.h new file mode 100644 index 0000000..c25525e --- /dev/null +++ b/src/qEmbyApp/views/admin/collectionsviewmodel.h @@ -0,0 +1,119 @@ +#ifndef COLLECTIONSVIEWMODEL_H +#define COLLECTIONSVIEWMODEL_H + +#include +#include +#include +#include + +#include + +class QEmbyCore; + +struct CollectionListEntry { + QString id; + QString name; + int childCount = 0; + QString dateCreated; +}; + +class CollectionsListModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + ChildCountRole, + SubtitleRole, + ImageSourceRole, + SelectedRole, + }; + + explicit CollectionsListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + void setEntries(QList entries); + const CollectionListEntry *entry(const QString &itemId) const; + QStringList orderedIds() const; + QStringList selectedIds() const; + int selectedCount() const { return m_selectedIds.size(); } + bool allSelected() const; + void toggleSelected(const QString &itemId); + void toggleSelectAll(); + void clearSelection(); + bool move(int from, int to); + +private: + int indexOf(const QString &itemId) const; + + QList m_entries; + QSet m_selectedIds; +}; + +class CollectionsViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(int collectionCount READ collectionCount NOTIFY stateChanged) + Q_PROPERTY(int selectedCount READ selectedCount NOTIFY stateChanged) + Q_PROPERTY(bool allSelected READ allSelected NOTIFY stateChanged) + Q_PROPERTY( + QAbstractItemModel *collectionsModel READ collectionsModel CONSTANT) + +public: + explicit CollectionsViewModel(QEmbyCore *core, QObject *parent = nullptr); + + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + bool busy() const { return m_busy; } + QString errorText() const { return m_errorText; } + int collectionCount() const { return m_collections.rowCount(); } + int selectedCount() const { return m_collections.selectedCount(); } + bool allSelected() const { return m_collections.allSelected(); } + QAbstractItemModel *collectionsModel() { return &m_collections; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void toggleSelected(const QString &itemId); + Q_INVOKABLE void toggleSelectAll(); + Q_INVOKABLE void createCollection(const QString &name); + Q_INVOKABLE void renameCollection(const QString &itemId, const QString &name); + Q_INVOKABLE void deleteCollection(const QString &itemId); + Q_INVOKABLE void deleteSelected(); + Q_INVOKABLE void moveCollection(int from, int to); + Q_INVOKABLE void saveCollectionOrder(); + +public slots: + void setActive(bool active); + +signals: + void stateChanged(); + void notificationRequested(const QString &message, const QString &kind); + +private: + QCoro::Task load(bool manualRefresh); + QCoro::Task create(QString name); + QCoro::Task rename(QString itemId, QString name); + QCoro::Task remove(QStringList itemIds); + QCoro::Task saveOrder(QStringList oldOrder, QStringList newOrder); + void setBusy(bool busy); + + QPointer m_core; + CollectionsListModel m_collections; + QString m_errorText; + quint64 m_loadGeneration = 0; + bool m_loading = false; + bool m_refreshing = false; + bool m_busy = false; + bool m_active = false; + bool m_loaded = false; + QStringList m_orderBeforeDrag; +}; + +#endif // COLLECTIONSVIEWMODEL_H diff --git a/src/qEmbyApp/views/admin/manageview.cpp b/src/qEmbyApp/views/admin/manageview.cpp index d2aff18..9e8a402 100644 --- a/src/qEmbyApp/views/admin/manageview.cpp +++ b/src/qEmbyApp/views/admin/manageview.cpp @@ -1,6 +1,8 @@ #include "manageview.h" #include "managepagebase.h" -#include "pagedashboard.h" +#include "../../quick/dashboardviewmodel.h" +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" #include "pagecollections.h" #include "pagelibraries.h" #include "pagelogs.h" @@ -142,8 +144,20 @@ void ManageView::setupUi() { return scroll; }; - - m_stack->addWidget(wrapInScrollArea(new PageDashboard(m_core, m_stack))); + + // Dashboard 已迁移为 QML 试点页:QuickPageHost + DashboardViewModel。 + // QML 页面自带 Flickable 滚动,不再套 QScrollArea。 + auto *dashboardHost = new QuickPageHost(m_stack); + auto *dashboardVm = new DashboardViewModel(m_core, dashboardHost, dashboardHost); + dashboardHost->setContextProperty(QStringLiteral("dashboard"), dashboardVm); + dashboardHost->addImageProvider(QStringLiteral("emby"), new EmbyImageProvider(m_core)); + dashboardHost->addImageProvider(QStringLiteral("adaptiveicon"), new AdaptiveIconProvider()); + dashboardHost->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/ManageDashboardPage.qml"))); + connect(dashboardHost, &QuickPageHost::pageShown, dashboardVm, + [dashboardVm]() { dashboardVm->setActive(true); }); + connect(dashboardHost, &QuickPageHost::pageHidden, dashboardVm, + [dashboardVm]() { dashboardVm->setActive(false); }); + m_stack->addWidget(dashboardHost); auto *libPage = new PageLibraries(m_core, m_stack); diff --git a/src/qEmbyApp/views/admin/pagecollections.cpp b/src/qEmbyApp/views/admin/pagecollections.cpp index 5a78a2a..25f4625 100644 --- a/src/qEmbyApp/views/admin/pagecollections.cpp +++ b/src/qEmbyApp/views/admin/pagecollections.cpp @@ -1,1077 +1,40 @@ #include "pagecollections.h" -#include "../../components/collectioncard.h" -#include "../../components/collectiongrid.h" -#include "../../components/loadingoverlay.h" -#include "../../components/modernmessagebox.h" -#include "../../components/moderntoast.h" -#include "../../components/textinputdialog.h" -#include "../../managers/thememanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "collectionsviewmodel.h" + +#include #include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" -#include - -namespace { - -QDateTime parsePlaylistDateCreated(QString rawDateCreated) { - if (rawDateCreated.isEmpty()) { - return {}; - } - - QDateTime parsed = QDateTime::fromString(rawDateCreated, Qt::ISODate); - if (!parsed.isValid()) { - parsed = QDateTime::fromString(rawDateCreated, Qt::ISODateWithMs); - } - return parsed; -} - -QString findPlaylistViewId(const QList &views, QString *viewName = nullptr) { - for (const MediaItem &view : views) { - if (view.collectionType.compare(QStringLiteral("playlists"), - Qt::CaseInsensitive) == 0 && - !view.id.isEmpty()) { - if (viewName) { - *viewName = view.name; - } - return view.id; - } - } - - if (viewName) { - viewName->clear(); - } - return {}; -} - -} - - - +#include PageCollections::PageCollections(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, tr("Lists"), parent) { - - m_mainLayout->setContentsMargins(16, 30, 0, 0); - - - auto *titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, CollectionGrid::RightPadding + 12, 0); - - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addSpacing(6); - - m_btnCreate = new QPushButton(this); - m_btnCreate->setObjectName("ManageLibCreateBtn"); - m_btnCreate->setCursor(Qt::PointingHandCursor); - m_btnCreate->setFixedSize(32, 32); - m_btnCreate->setToolTip(tr("Create playlist")); - titleRow->addWidget(m_btnCreate); - - titleRow->addSpacing(6); - - m_btnSelectAll = new QPushButton(this); - m_btnSelectAll->setObjectName("ManageListSelectBtn"); - m_btnSelectAll->setCursor(Qt::PointingHandCursor); - m_btnSelectAll->setFixedSize(32, 32); - m_btnSelectAll->setToolTip(tr("Select all playlists")); - m_btnSelectAll->setEnabled(false); - titleRow->addWidget(m_btnSelectAll); - - titleRow->addSpacing(6); - - m_btnDeleteSelected = new QPushButton(this); - m_btnDeleteSelected->setObjectName("ManageListDeleteBtn"); - m_btnDeleteSelected->setCursor(Qt::PointingHandCursor); - m_btnDeleteSelected->setFixedSize(32, 32); - m_btnDeleteSelected->setToolTip(tr("Delete selected playlists")); - m_btnDeleteSelected->setEnabled(false); - titleRow->addWidget(m_btnDeleteSelected); - - titleRow->addSpacing(12); - titleRow->addStretch(2); - - m_statsLabel = new QLabel(this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); - - - auto *scrollContent = new QWidget(this); - scrollContent->setAttribute(Qt::WA_StyledBackground, true); - auto *scrollLayout = new QVBoxLayout(scrollContent); - scrollLayout->setContentsMargins(0, 0, 0, 40); - scrollLayout->setSpacing(16); - - m_playlistGrid = new CollectionGrid(scrollContent); - scrollLayout->addWidget(m_playlistGrid, 1); - - - m_emptyStateContainer = new QWidget(scrollContent); - m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Expanding); - auto *emptyLayout = new QVBoxLayout(m_emptyStateContainer); - emptyLayout->setContentsMargins(0, 0, 0, 0); - emptyLayout->setSpacing(0); - emptyLayout->addStretch(1); - - m_emptyLabel = new QLabel(tr("No playlists found"), m_emptyStateContainer); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - m_emptyLabel->hide(); - emptyLayout->addWidget(m_emptyLabel); - emptyLayout->addStretch(1); - - m_emptyStateContainer->hide(); - scrollLayout->addWidget(m_emptyStateContainer, 1); + : ManagePageBase(core, QString(), parent) { + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); + // ManageView uses this viewport for its existing wheel-event coordination. m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName("SettingsScrollArea"); - m_scrollArea->setWidget(scrollContent); + m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); m_scrollArea->setWidgetResizable(true); m_scrollArea->setFrameShape(QFrame::NoFrame); + m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_scrollArea->viewport()->setAutoFillBackground(false); + m_quickHost = new QuickPageHost(m_scrollArea); + m_viewModel = new CollectionsViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("collectionsPage"), + m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminCollectionsPage.qml"))); + m_scrollArea->setWidget(m_quickHost); m_mainLayout->addWidget(m_scrollArea, 1); - m_loadingOverlay = new LoadingOverlay(m_scrollArea->viewport()); - m_loadingOverlay->setHudPanelVisible(false); - m_loadingOverlay->setSubtleOverlay(true); - updateLoadingOverlayGeometry(); - - - connect(m_btnCreate, &QPushButton::clicked, this, - [this]() { m_pendingTask = onCreateClicked(); }); - connect(m_btnSelectAll, &QPushButton::clicked, this, - [this]() { toggleSelectAll(); }); - connect(m_btnDeleteSelected, &QPushButton::clicked, this, - [this]() { m_pendingTask = onDeleteSelectedClicked(); }); - connect(m_btnRefresh, &QPushButton::clicked, this, - [this]() { m_pendingTask = loadData(true); }); - connect(m_playlistGrid, &CollectionGrid::orderChanged, this, - [this](const QStringList &ids) { m_pendingTask = onOrderChanged(ids); }); -} - -void PageCollections::showEvent(QShowEvent *event) { - ManagePageBase::showEvent(event); - updateLoadingOverlayGeometry(); - if (!m_loaded) { - m_pendingTask = loadData(); - m_loaded = true; - } -} - -void PageCollections::resizeEvent(QResizeEvent *event) { - ManagePageBase::resizeEvent(event); - updateLoadingOverlayGeometry(); -} - -void PageCollections::updateStats() { - if (m_isLoading) { - m_statsLabel->setText(tr("Loading...")); - return; - } - - const QString playlistCountText = tr("%n playlist(s)", "", m_playlists.size()); - if (m_selectedPlaylistIds.isEmpty()) { - m_statsLabel->setText(playlistCountText); - return; - } - - m_statsLabel->setText( - tr("%1 · %2") - .arg(playlistCountText, - tr("%n selected", "", m_selectedPlaylistIds.size()))); -} - -void PageCollections::updateContentState() { - const bool empty = m_playlists.isEmpty(); - m_playlistGrid->setVisible(!empty); - m_emptyStateContainer->setVisible(empty); - m_emptyLabel->setVisible(empty); - m_emptyLabel->setText(tr("No playlists found")); - updateSelectionState(); -} - -void PageCollections::updateSelectionState() { - m_btnRefresh->setEnabled(!m_isLoading); - m_btnCreate->setEnabled(!m_isLoading); - m_btnSelectAll->setEnabled(!m_isLoading && !m_playlists.isEmpty()); - m_btnDeleteSelected->setEnabled(!m_isLoading && - !m_selectedPlaylistIds.isEmpty()); - m_btnSelectAll->setToolTip(areAllPlaylistsSelected() - ? tr("Clear playlist selection") - : tr("Select all playlists")); - updateStats(); -} - -QStringList PageCollections::playlistOrderIds() const { - QStringList ids; - ids.reserve(m_playlists.size()); - for (const PlaylistEntry& playlist : m_playlists) { - ids.append(playlist.id); - } - return ids; -} - -void PageCollections::reorderPlaylists(const QStringList& orderedIds) { - if (orderedIds.isEmpty() || m_playlists.isEmpty()) { - return; - } - - QHash playlistById; - playlistById.reserve(m_playlists.size()); - for (const PlaylistEntry& playlist : m_playlists) { - playlistById.insert(playlist.id, playlist); - } - const QList originalPlaylists = m_playlists; - - QList reordered; - reordered.reserve(m_playlists.size()); - - for (const QString& itemId : orderedIds) { - auto it = playlistById.find(itemId); - if (it == playlistById.end()) { - continue; - } - - reordered.append(it.value()); - playlistById.erase(it); - } - - for (const PlaylistEntry& playlist : originalPlaylists) { - if (playlistById.contains(playlist.id)) { - reordered.append(playlist); - } - } - - m_playlists = reordered; -} - -void PageCollections::syncCardSelection() { - for (auto *card : m_playlistGrid->cards()) { - card->setSelected(m_selectedPlaylistIds.contains(card->itemId())); - } -} - -void PageCollections::onCardSelectionChanged(const QString &itemId, - bool selected) { - if (selected) { - m_selectedPlaylistIds.insert(itemId); - } else { - m_selectedPlaylistIds.remove(itemId); - } - - qDebug() << "[PageCollections] Playlist selection changed" - << "| itemId=" << itemId - << "| selected=" << selected - << "| selectedCount=" << m_selectedPlaylistIds.size(); - - updateSelectionState(); -} - -void PageCollections::toggleSelectAll() { - if (areAllPlaylistsSelected()) { - m_selectedPlaylistIds.clear(); - } else { - m_selectedPlaylistIds.clear(); - for (const PlaylistEntry &playlist : m_playlists) { - m_selectedPlaylistIds.insert(playlist.id); - } - } - - syncCardSelection(); - updateSelectionState(); -} - -bool PageCollections::areAllPlaylistsSelected() const { - return !m_playlists.isEmpty() && - m_selectedPlaylistIds.size() == m_playlists.size(); -} - -void PageCollections::setLoadingState(bool loading) { - if (m_isLoading == loading) { - return; - } - - m_isLoading = loading; - qDebug() << "[PageCollections] Loading state changed" - << "| loading=" << loading; - updateSelectionState(); - - if (!m_loadingOverlay) { - return; - } - - updateLoadingOverlayGeometry(); - if (loading) { - m_loadingOverlay->start(); - } else { - m_loadingOverlay->stop(); - } -} - -void PageCollections::updateLoadingOverlayGeometry() { - if (!m_loadingOverlay || !m_scrollArea) { - return; - } - - m_loadingOverlay->setGeometry(m_scrollArea->viewport()->rect()); -} - -QCoro::Task PageCollections::loadData(bool isManual) { - QPointer safeThis(this); - setLoadingState(true); - if (isManual) { - m_btnRefresh->setEnabled(false); - RefreshSpin::start(m_btnRefresh); - } - - try { - struct PendingImageLoad { - CollectionCard *card = nullptr; - QString itemId; - }; - - QList loadedPlaylists; - QSet retainedSelectionIds; - QList pendingImageLoads; - - QString playlistViewId; - QString playlistViewName; - bool usedLegacyFallback = false; - - const QList views = co_await m_core->mediaService()->getUserViews(); - if (!safeThis) co_return; - - playlistViewId = findPlaylistViewId(views, &playlistViewName); - if (playlistViewId.isEmpty()) { - const QList hiddenViews = - co_await m_core->mediaService()->getUserViews(true); - if (!safeThis) co_return; - - playlistViewId = findPlaylistViewId(hiddenViews, &playlistViewName); - } - - if (!playlistViewId.isEmpty()) { - qDebug() << "[PageCollections] Loading playlists via media view" - << "| playlistViewId=" << playlistViewId - << "| playlistViewName=" << playlistViewName - << "| sortBy=DateCreated" - << "| sortOrder=Descending" - << "| recursive=false"; - - const QList playlistItems = - co_await m_core->mediaService()->getLibraryItems( - playlistViewId, QStringLiteral("DateCreated"), - QStringLiteral("Descending"), QString(), QString(), 0, 0, false, - true); - if (!safeThis) co_return; - - loadedPlaylists.reserve(playlistItems.size()); - pendingImageLoads.reserve(playlistItems.size()); - - for (const MediaItem &item : playlistItems) { - PlaylistEntry playlist; - playlist.id = item.id; - playlist.name = item.name; - playlist.childCount = item.childCount > 0 ? item.childCount - : item.recursiveItemCount; - playlist.dateCreatedRaw = item.dateCreatedRaw; - loadedPlaylists.append(playlist); - } - } else { - usedLegacyFallback = true; - qWarning() << "[PageCollections] Playlist view missing in getUserViews," - << "falling back to admin playlist query"; - - const QJsonArray playlists = - co_await m_core->adminService()->getUserPlaylists(); - if (!safeThis) co_return; - - loadedPlaylists.reserve(playlists.size()); - pendingImageLoads.reserve(playlists.size()); - - for (int i = 0; i < playlists.size(); ++i) { - QJsonObject item = playlists[i].toObject(); - PlaylistEntry playlist; - playlist.id = item["Id"].toString(); - playlist.name = item["Name"].toString(); - playlist.childCount = item["ChildCount"].toInt(); - playlist.dateCreatedRaw = item["DateCreated"].toString(); - loadedPlaylists.append(playlist); - } - } - - const quint64 imageLoadGeneration = ++m_cardImageLoadGeneration; - - QHash existingCardsById; - const QList existingCards = m_playlistGrid->cards(); - existingCardsById.reserve(existingCards.size()); - for (auto *card : existingCards) { - if (!card) { - continue; - } - existingCardsById.insert(card->itemId(), card); - } - - int reusedCardCount = 0; - int createdCardCount = 0; - const int existingCardCount = existingCards.size(); - - - { - QList cards; - cards.reserve(loadedPlaylists.size()); - - for (const PlaylistEntry &playlist : loadedPlaylists) { - if (m_selectedPlaylistIds.contains(playlist.id)) { - retainedSelectionIds.insert(playlist.id); - } - - CollectionCard *card = existingCardsById.take(playlist.id); - const bool isSelected = retainedSelectionIds.contains(playlist.id); - bool shouldReloadImage = isManual; - - if (card) { - ++reusedCardCount; - card->setDisplayData(playlist.name, playlist.childCount, - QStringLiteral("Playlist")); - card->setSelected(isSelected); - shouldReloadImage = isManual || !card->hasImage(); - } else { - ++createdCardCount; - card = new CollectionCard(playlist.id, playlist.name, - playlist.childCount, "Playlist", - m_playlistGrid); - card->setSelected(isSelected); - connect(card, &CollectionCard::renameClicked, this, - [this](const QString &itemId, const QString &itemName) { - m_pendingTask = onRenameClicked(itemId, itemName); - }); - connect(card, &CollectionCard::deleteClicked, this, - [this](const QString &itemId, const QString &itemName) { - m_pendingTask = onDeleteClicked(itemId, itemName); - }); - connect(card, &CollectionCard::selectionChanged, this, - [this](const QString &itemId, bool selected) { - onCardSelectionChanged(itemId, selected); - }); - shouldReloadImage = true; - } - - cards.append(card); - if (shouldReloadImage) { - PendingImageLoad imageLoad; - imageLoad.card = card; - imageLoad.itemId = playlist.id; - pendingImageLoads.append(imageLoad); - } - } - - qDebug() << "[PageCollections] Playlist cards diff" - << "| source=" - << (usedLegacyFallback ? "AdminServiceFallback" - : "MediaServiceLibraryItems") - << "| total=" << loadedPlaylists.size() - << "| reused=" << reusedCardCount - << "| created=" << createdCardCount - << "| removed=" << (existingCardCount - reusedCardCount) - << "| manualReload=" << isManual; - - m_playlists = loadedPlaylists; - m_selectedPlaylistIds = retainedSelectionIds; - m_playlistGrid->setCards(cards); - syncCardSelection(); - - - for (const PendingImageLoad &imageLoad : pendingImageLoads) { - loadCardImage(imageLoad.card, imageLoad.itemId, imageLoadGeneration); - } - } - - updateContentState(); - - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Failed to load: %1").arg(QString::fromUtf8(e.what())), 3000); - } - - if (!safeThis) co_return; - setLoadingState(false); -} - -QCoro::Task PageCollections::onCreateClicked() { - QPointer safeThis(this); - - bool ok = false; - const QString playlistName = TextInputDialog::getText( - this, tr("Create Playlist"), tr("Playlist name"), QString(), - tr("Enter playlist name"), tr("Create"), &ok); - const QString trimmedName = playlistName.trimmed(); - - if (!ok || trimmedName.isEmpty()) co_return; - - qDebug() << "[PageCollections] Creating playlist" - << "| name=" << trimmedName; - - try { - const QString playlistId = - co_await m_core->adminService()->createPlaylist(trimmedName); - if (!safeThis) co_return; - - qDebug() << "[PageCollections] Playlist created" - << "| id=" << playlistId - << "| name=" << trimmedName; - ModernToast::showMessage(tr("Playlist created"), 2000); - co_await loadData(false); - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Failed to create playlist: %1").arg(QString::fromUtf8(e.what())), - 3000); - } -} - -QCoro::Task PageCollections::loadCardImage(CollectionCard *card, - QString itemId, - quint64 imageLoadGeneration) { - QPointer safeThis(this); - QPointer safeCard(card); - - try { - if (itemId.isEmpty()) co_return; - - QPixmap pixmap = co_await m_core->mediaService()->fetchImage( - itemId, "Primary", QString(), CollectionGrid::CardWidth * 2); - - if (!safeThis || !safeCard) co_return; - if (imageLoadGeneration != safeThis->m_cardImageLoadGeneration) co_return; - if (safeCard->itemId() != itemId) co_return; - - if (!pixmap.isNull()) { - safeCard->setImage(pixmap); - } - } catch (...) { - qDebug() << "[PageCollections] Failed to load card image" - << "itemId=" << itemId; - } -} - -QCoro::Task PageCollections::onRenameClicked(QString itemId, - QString itemName) { - QPointer safeThis(this); - - bool ok = false; - const QString newName = TextInputDialog::getText( - this, tr("Rename Playlist"), tr("Playlist name"), itemName, - tr("Enter playlist name"), tr("Save"), &ok); - const QString trimmedName = newName.trimmed(); - - if (!ok || trimmedName.isEmpty() || trimmedName == itemName) - co_return; - - qDebug() << "[PageCollections] Renaming playlist" - << "| itemId=" << itemId - << "| oldName=" << itemName - << "| newName=" << trimmedName; - - try { - co_await m_core->adminService()->renameItem(itemId, trimmedName); - if (!safeThis) co_return; - ModernToast::showMessage(tr("Renamed successfully"), 2000); - co_await loadData(false); - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Rename failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageCollections::onDeleteClicked(QString itemId, - QString itemName) { - QPointer safeThis(this); - - bool confirmed = ModernMessageBox::question( - this, tr("Delete Playlist"), - tr("Are you sure you want to delete playlist \"%1\"?\n\nThis action " - "cannot be undone.") - .arg(itemName), - tr("Delete"), tr("Cancel"), ModernMessageBox::Danger, - ModernMessageBox::Warning); - - if (!confirmed) co_return; - - qDebug() << "[PageCollections] Deleting playlist" - << "| itemId=" << itemId - << "| name=" << itemName; - - try { - co_await m_core->adminService()->deleteItem(itemId); - if (!safeThis) co_return; - ModernToast::showMessage(tr("\"%1\" deleted").arg(itemName), 2000); - co_await loadData(false); - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Delete failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageCollections::onDeleteSelectedClicked() { - QPointer safeThis(this); - const int selectedCount = m_selectedPlaylistIds.size(); - if (selectedCount <= 0) co_return; - - const bool confirmed = ModernMessageBox::question( - this, tr("Delete Selected"), - tr("Are you sure you want to delete %n selected playlist(s)?\n\nThis " - "action cannot be undone.", - "", selectedCount), - tr("Delete"), tr("Cancel"), ModernMessageBox::Danger, - ModernMessageBox::Warning); - if (!confirmed) co_return; - - int deletedCount = 0; - QString failureMessage; - - for (const PlaylistEntry &playlist : m_playlists) { - if (!m_selectedPlaylistIds.contains(playlist.id)) { - continue; - } - - qDebug() << "[PageCollections] Batch deleting playlist" - << "| itemId=" << playlist.id - << "| name=" << playlist.name; - - try { - co_await m_core->adminService()->deleteItem(playlist.id); - if (!safeThis) co_return; - ++deletedCount; - } catch (const std::exception &e) { - failureMessage = QString::fromUtf8(e.what()); - qWarning() << "[PageCollections] Failed to delete selected playlist" - << "| itemId=" << playlist.id - << "| name=" << playlist.name - << "| error=" << failureMessage; - break; - } - } - - if (!safeThis) co_return; - - m_selectedPlaylistIds.clear(); - if (!failureMessage.isEmpty()) { - if (deletedCount > 0) { - ModernToast::showMessage( - tr("Deleted %1 playlist(s), then failed: %2") - .arg(deletedCount) - .arg(failureMessage), - 3500); - } else { - ModernToast::showMessage( - tr("Failed to delete selected playlists: %1").arg(failureMessage), - 3000); - } - } else { - ModernToast::showMessage(tr("Deleted %n playlist(s)", "", deletedCount), - 2000); - } - - co_await loadData(false); -} - -QCoro::Task PageCollections::onOrderChanged(QStringList newOrderIds) { - if (newOrderIds.isEmpty()) { - co_return; - } - - QPointer safeThis(this); - const QStringList oldOrderIds = playlistOrderIds(); - if (oldOrderIds == newOrderIds) { - qDebug() << "[PageCollections] Playlist order unchanged, skip save"; - co_return; - } - - setLoadingState(true); - QElapsedTimer saveTimer; - saveTimer.start(); - bool shouldReloadAfterFailure = false; - - try { - struct PlaylistDateUpdate { - QString itemId; - QString dateCreatedRaw; - }; - - QString movedItemId; - int movedNewIndex = -1; - int firstMismatch = -1; - int lastMismatch = -1; - - for (int i = 0; i < oldOrderIds.size() && i < newOrderIds.size(); ++i) { - if (oldOrderIds[i] != newOrderIds[i]) { - if (firstMismatch < 0) { - firstMismatch = i; - } - lastMismatch = i; - } - } - - if (firstMismatch >= 0 && lastMismatch >= 0) { - if (oldOrderIds[firstMismatch] == newOrderIds[lastMismatch]) { - movedItemId = oldOrderIds[firstMismatch]; - movedNewIndex = lastMismatch; - } else if (oldOrderIds[lastMismatch] == newOrderIds[firstMismatch]) { - movedItemId = oldOrderIds[lastMismatch]; - movedNewIndex = firstMismatch; - } - } - - if (movedNewIndex >= 0) { - const auto dateAt = [&](int orderedIndex) -> QString { - if (orderedIndex < 0 || orderedIndex >= newOrderIds.size()) { - return {}; - } - - for (const PlaylistEntry& playlist : m_playlists) { - if (playlist.id == newOrderIds[orderedIndex]) { - return playlist.dateCreatedRaw; - } - } - return {}; - }; - - qDebug() << "[PageCollections] Playlist reorder neighborhood" - << "| movedItemId=" << movedItemId - << "| movedNewIndex=" << movedNewIndex - << "| prevDateCreated=" << dateAt(movedNewIndex - 1) - << "| currentDateCreated=" - << [this, &movedItemId]() { - for (const PlaylistEntry& playlist : m_playlists) { - if (playlist.id == movedItemId) { - return playlist.dateCreatedRaw; - } - } - return QString(); - }() - << "| nextDateCreated=" << dateAt(movedNewIndex + 1); - } - - auto playlistById = [this](const QString& itemId) -> const PlaylistEntry* { - for (const PlaylistEntry& playlist : m_playlists) { - if (playlist.id == itemId) { - return &playlist; - } - } - return nullptr; - }; - - auto buildSingleDateUpdatePlan = - [&](const QStringList& orderedIds, - int targetIndex) -> std::optional> { - if (movedItemId.isEmpty() || targetIndex < 0 || - targetIndex >= orderedIds.size()) { - return std::nullopt; - } - - auto playlistTimeAt = [&](int orderedIndex) -> QDateTime { - if (orderedIndex < 0 || orderedIndex >= orderedIds.size()) { - return {}; - } - - const PlaylistEntry* playlist = playlistById(orderedIds[orderedIndex]); - return playlist ? parsePlaylistDateCreated(playlist->dateCreatedRaw) - : QDateTime(); - }; - - QDateTime targetDateCreated; - if (orderedIds.size() == 1) { - targetDateCreated = QDateTime::currentDateTimeUtc(); - } else if (targetIndex == 0) { - const QDateTime nextTime = playlistTimeAt(1); - if (!nextTime.isValid()) { - return std::nullopt; - } - targetDateCreated = nextTime.addSecs(1); - } else if (targetIndex == orderedIds.size() - 1) { - const QDateTime prevTime = playlistTimeAt(targetIndex - 1); - if (!prevTime.isValid()) { - return std::nullopt; - } - targetDateCreated = prevTime.addSecs(-1); - } else { - const QDateTime prevTime = playlistTimeAt(targetIndex - 1); - const QDateTime nextTime = playlistTimeAt(targetIndex + 1); - if (!prevTime.isValid() || !nextTime.isValid()) { - return std::nullopt; - } - - const qint64 gapSecs = nextTime.secsTo(prevTime); - if (gapSecs <= 1) { - return std::nullopt; - } - - targetDateCreated = nextTime.addSecs(gapSecs / 2); - } - - PlaylistDateUpdate update; - update.itemId = movedItemId; - update.dateCreatedRaw = - targetDateCreated.toUTC().toString(Qt::ISODateWithMs); - QList updates; - updates.append(update); - return updates; - }; - - auto buildLocalDateUpdatePlan = - [&](const QStringList& orderedIds, - int initialWindowStart, - int initialWindowEnd) -> std::optional> { - if (orderedIds.isEmpty() || initialWindowStart < 0 || - initialWindowEnd < initialWindowStart || - initialWindowEnd >= orderedIds.size()) { - return std::nullopt; - } - - const int lastIndex = orderedIds.size() - 1; - int windowStart = initialWindowStart; - int windowEnd = initialWindowEnd; - bool preferExpandLeft = - movedNewIndex >= 0 && movedNewIndex <= initialWindowStart; - - auto anchorTimeAt = [&](int orderedIndex) -> QDateTime { - if (orderedIndex < 0 || orderedIndex > lastIndex) { - return {}; - } - - const PlaylistEntry* playlist = playlistById(orderedIds[orderedIndex]); - return playlist ? parsePlaylistDateCreated(playlist->dateCreatedRaw) - : QDateTime(); - }; - - auto hasEnoughRoom = [&](const QDateTime& leftAnchor, - const QDateTime& rightAnchor) -> bool { - const int windowCount = windowEnd - windowStart + 1; - const bool openTop = (windowStart == 0); - const bool openBottom = (windowEnd == lastIndex); - - if (openTop && openBottom) { - return true; - } - - if (openTop) { - return rightAnchor.isValid(); - } - - if (openBottom) { - return leftAnchor.isValid(); - } - - if (!leftAnchor.isValid() || !rightAnchor.isValid()) { - return false; - } - - const qint64 leftSecs = leftAnchor.toSecsSinceEpoch(); - const qint64 rightSecs = rightAnchor.toSecsSinceEpoch(); - return leftSecs > rightSecs && - (leftSecs - rightSecs - 1) >= windowCount; - }; - - while (true) { - const QDateTime leftAnchor = anchorTimeAt(windowStart - 1); - const QDateTime rightAnchor = anchorTimeAt(windowEnd + 1); - - if (hasEnoughRoom(leftAnchor, rightAnchor)) { - QList updates; - const int windowCount = windowEnd - windowStart + 1; - updates.reserve(windowCount); - - if (windowStart == 0 && windowEnd == lastIndex) { - qint64 baseSecs = - QDateTime::currentDateTimeUtc().toSecsSinceEpoch() + windowCount; - for (int i = 0; i < windowCount; ++i) { - const qint64 dateSecs = baseSecs - i; - PlaylistDateUpdate update; - update.itemId = orderedIds[windowStart + i]; - update.dateCreatedRaw = - QDateTime::fromSecsSinceEpoch(dateSecs, QTimeZone::UTC) - .toString(Qt::ISODateWithMs); - updates.append(update); - } - } else if (windowStart == 0) { - const qint64 rightSecs = rightAnchor.toSecsSinceEpoch(); - for (int i = 0; i < windowCount; ++i) { - const qint64 dateSecs = rightSecs + (windowCount - i); - PlaylistDateUpdate update; - update.itemId = orderedIds[windowStart + i]; - update.dateCreatedRaw = - QDateTime::fromSecsSinceEpoch(dateSecs, QTimeZone::UTC) - .toString(Qt::ISODateWithMs); - updates.append(update); - } - } else if (windowEnd == lastIndex) { - const qint64 leftSecs = leftAnchor.toSecsSinceEpoch(); - for (int i = 0; i < windowCount; ++i) { - const qint64 dateSecs = leftSecs - (i + 1); - PlaylistDateUpdate update; - update.itemId = orderedIds[windowStart + i]; - update.dateCreatedRaw = - QDateTime::fromSecsSinceEpoch(dateSecs, QTimeZone::UTC) - .toString(Qt::ISODateWithMs); - updates.append(update); - } - } else { - const qint64 leftSecs = leftAnchor.toSecsSinceEpoch(); - const qint64 rightSecs = rightAnchor.toSecsSinceEpoch(); - const qint64 availableSecs = leftSecs - rightSecs - 1; - - for (int i = 0; i < windowCount; ++i) { - const qint64 offset = - ((static_cast(i) + 1) * (availableSecs + 1)) / - (windowCount + 1); - const qint64 dateSecs = leftSecs - offset; - PlaylistDateUpdate update; - update.itemId = orderedIds[windowStart + i]; - update.dateCreatedRaw = - QDateTime::fromSecsSinceEpoch(dateSecs, QTimeZone::UTC) - .toString(Qt::ISODateWithMs); - updates.append(update); - } - } - - qDebug() << "[PageCollections] Built localized playlist date update plan" - << "| windowStart=" << windowStart - << "| windowEnd=" << windowEnd - << "| updateCount=" << updates.size(); - return updates; - } - - if (windowStart == 0 && windowEnd == lastIndex) { - return std::nullopt; - } - - bool expanded = false; - if (preferExpandLeft && windowStart > 0) { - --windowStart; - expanded = true; - } else if (!preferExpandLeft && windowEnd < lastIndex) { - ++windowEnd; - expanded = true; - } else if (windowStart > 0) { - --windowStart; - expanded = true; - } else if (windowEnd < lastIndex) { - ++windowEnd; - expanded = true; - } - - if (!expanded) { - return std::nullopt; - } - - preferExpandLeft = !preferExpandLeft; - } - }; - - std::optional> localizedUpdatePlan; - const std::optional> singleUpdatePlan = - buildSingleDateUpdatePlan(newOrderIds, movedNewIndex); - if (singleUpdatePlan.has_value()) { - localizedUpdatePlan = singleUpdatePlan; - } else if (movedNewIndex >= 0) { - localizedUpdatePlan = - buildLocalDateUpdatePlan(newOrderIds, movedNewIndex, movedNewIndex); - } else if (firstMismatch >= 0 && lastMismatch >= 0) { - localizedUpdatePlan = - buildLocalDateUpdatePlan(newOrderIds, firstMismatch, lastMismatch); - } - const bool canUseLocalizedUpdate = localizedUpdatePlan.has_value() && - !localizedUpdatePlan->isEmpty(); - - qDebug() << "[PageCollections] Saving playlist order by DateCreated" - << "| count=" << newOrderIds.size() - << "| ids=" << newOrderIds - << "| movedItemId=" << movedItemId - << "| movedNewIndex=" << movedNewIndex - << "| singleUpdate=" << singleUpdatePlan.has_value() - << "| localizedUpdate=" << canUseLocalizedUpdate - << "| localizedUpdateCount=" - << (canUseLocalizedUpdate ? localizedUpdatePlan->size() : 0); - - if (canUseLocalizedUpdate) { - for (const PlaylistDateUpdate& update : *localizedUpdatePlan) { - co_await m_core->adminService()->updatePlaylistDateCreated( - update.itemId, update.dateCreatedRaw); - - for (PlaylistEntry& playlist : m_playlists) { - if (playlist.id == update.itemId) { - playlist.dateCreatedRaw = update.dateCreatedRaw; - break; - } - } - } - } else { - qWarning() << "[PageCollections] Falling back to full playlist reindex" - << "| movedItemId=" << movedItemId - << "| movedNewIndex=" << movedNewIndex - << "| firstMismatch=" << firstMismatch - << "| lastMismatch=" << lastMismatch; - co_await m_core->adminService()->updatePlaylistOrderByDateCreated(newOrderIds); - } - if (!safeThis) co_return; - - reorderPlaylists(newOrderIds); - qDebug() << "[PageCollections] Playlist order save completed" - << "| elapsedMs=" << saveTimer.elapsed() - << "| reloaded=false"; - ModernToast::showMessage(tr("Playlist order updated"), 1500); - } catch (const std::exception &e) { - if (!safeThis) co_return; - - qWarning() << "[PageCollections] Playlist order save failed" - << "| elapsedMs=" << saveTimer.elapsed() - << "| error=" << e.what(); - ModernToast::showMessage( - tr("Failed to save playlist order: %1").arg(QString::fromUtf8(e.what())), - 3000); - - if (!oldOrderIds.isEmpty()) { - shouldReloadAfterFailure = true; - } - } - - if (!safeThis) co_return; - if (shouldReloadAfterFailure) { - co_await loadData(false); - co_return; - } - setLoadingState(false); + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this] { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this] { m_viewModel->setActive(false); }); } diff --git a/src/qEmbyApp/views/admin/pagecollections.h b/src/qEmbyApp/views/admin/pagecollections.h index 7d1766e..f9494f3 100644 --- a/src/qEmbyApp/views/admin/pagecollections.h +++ b/src/qEmbyApp/views/admin/pagecollections.h @@ -2,90 +2,24 @@ #define PAGECOLLECTIONS_H #include "managepagebase.h" -#include -#include -#include -#include -#include -#include -class CollectionGrid; -class CollectionCard; -class LoadingOverlay; -class QLabel; -class QPushButton; -class QResizeEvent; +class CollectionsViewModel; class QScrollArea; -class QShowEvent; -class QWidget; +class QuickPageHost; +// Compatibility shell: ManageView still constructs PageCollections and uses +// scrollArea(), while the page body and state are owned by QML/ViewModel. +class PageCollections final : public ManagePageBase { + Q_OBJECT +public: + explicit PageCollections(QEmbyCore *core, QWidget *parent = nullptr); + QScrollArea *scrollArea() const { return m_scrollArea; } - -class PageCollections : public ManagePageBase -{ - Q_OBJECT - public: - explicit PageCollections(QEmbyCore * core, QWidget *parent = nullptr); - QScrollArea *scrollArea() const - { - return m_scrollArea; - } - - protected: - void showEvent(QShowEvent * event) override; - void resizeEvent(QResizeEvent * event) override; - - private: - struct PlaylistEntry - { - QString id; - QString name; - int childCount = 0; - QString dateCreatedRaw; - }; - - QCoro::Task loadData(bool isManual = false); - QCoro::Task loadCardImage(CollectionCard * card, QString itemId, quint64 imageLoadGeneration); - QCoro::Task onCreateClicked(); - QCoro::Task onRenameClicked(QString itemId, QString itemName); - QCoro::Task onDeleteClicked(QString itemId, QString itemName); - QCoro::Task onDeleteSelectedClicked(); - QCoro::Task onOrderChanged(QStringList newOrderIds); - - void updateStats(); - void updateContentState(); - void updateSelectionState(); - QStringList playlistOrderIds() const; - void reorderPlaylists(const QStringList &orderedIds); - void syncCardSelection(); - void onCardSelectionChanged(const QString &itemId, bool selected); - void toggleSelectAll(); - bool areAllPlaylistsSelected() const; - void setLoadingState(bool loading); - void updateLoadingOverlayGeometry(); - - - QPushButton *m_btnRefresh; - QPushButton *m_btnCreate; - QPushButton *m_btnSelectAll; - QPushButton *m_btnDeleteSelected; - QLabel *m_statsLabel; - - - QScrollArea *m_scrollArea; - CollectionGrid *m_playlistGrid; - QWidget *m_emptyStateContainer; - QLabel *m_emptyLabel; - LoadingOverlay *m_loadingOverlay = nullptr; - - bool m_loaded = false; - bool m_isLoading = false; - QList m_playlists; - QSet m_selectedPlaylistIds; - quint64 m_cardImageLoadGeneration = 0; - - std::optional> m_pendingTask; +private: + QScrollArea *m_scrollArea = nullptr; + QuickPageHost *m_quickHost = nullptr; + CollectionsViewModel *m_viewModel = nullptr; }; -#endif +#endif // PAGECOLLECTIONS_H diff --git a/src/qEmbyApp/views/admin/pagelibraries.cpp b/src/qEmbyApp/views/admin/pagelibraries.cpp index 1396915..b7df270 100644 --- a/src/qEmbyApp/views/admin/pagelibraries.cpp +++ b/src/qEmbyApp/views/admin/pagelibraries.cpp @@ -1,1172 +1,43 @@ #include "pagelibraries.h" -#include "../../components/librarycreatedlg.h" -#include "../../components/libraryeditdlg.h" -#include "../../components/mediaimageeditdialog.h" -#include "../../components/loadingoverlay.h" -#include "../../components/modernmessagebox.h" -#include "../../components/moderntoast.h" -#include "../../managers/languagemanager.h" -#include "../../managers/thememanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "../../quick/adminlibrariesviewmodel.h" +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" + +#include #include -#include -#include -#include -#include -#include -#include +#include #include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" - - - - -namespace { - -bool isChineseUiLanguage() { - const QString currentLanguage = LanguageManager::instance()->currentLanguage(); - if (currentLanguage.startsWith("zh", Qt::CaseInsensitive)) { - return true; - } - - if (currentLanguage == "system" || currentLanguage.isEmpty()) { - const QStringList uiLanguages = QLocale::system().uiLanguages(); - for (const QString &localeName : uiLanguages) { - if (QLocale(localeName).name().startsWith("zh", Qt::CaseInsensitive)) { - return true; - } - } - } - - return false; -} - -QString localizedDeleteSelectedTitle() { - const QString text = PageLibraries::tr("Delete Selected"); - if (isChineseUiLanguage() && text == QStringLiteral("Delete Selected")) { - return PageLibraries::tr("删除已选"); - } - return text; -} - -QString localizedDeleteSelectedPrompt(int selectedCount) { - const QString text = PageLibraries::tr( - "Are you sure you want to delete %n selected library(s)?\n\nThis will " - "remove the libraries from the server, but will not delete the actual " - "media files.", - "", selectedCount); - if (isChineseUiLanguage() && - text.startsWith(QStringLiteral("Are you sure you want to delete "))) { - return PageLibraries::tr( - "确定要删除 %1 个已选媒体库吗?\n\n" - "此操作会从服务器移除媒体库,但不会删除实际媒体文件。") - .arg(selectedCount); - } - return text; -} - -} - - - PageLibraries::PageLibraries(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, tr("Libraries"), parent) { - - m_mainLayout->setContentsMargins(16, 30, 0, 0); - - - auto *titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, LibraryGrid::RightPadding + 12, 0); - - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh library list")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addSpacing(6); - - m_btnScanAll = new QPushButton(this); - m_btnScanAll->setObjectName("ManageLibScanAllBtn"); - m_btnScanAll->setCursor(Qt::PointingHandCursor); - m_btnScanAll->setFixedSize(32, 32); - m_btnScanAll->setToolTip(tr("Scan all libraries")); - titleRow->addWidget(m_btnScanAll); - - titleRow->addSpacing(6); - - m_btnCreate = new QPushButton(this); - m_btnCreate->setObjectName("ManageLibCreateBtn"); - m_btnCreate->setCursor(Qt::PointingHandCursor); - m_btnCreate->setFixedSize(32, 32); - m_btnCreate->setToolTip(tr("Create new library")); - titleRow->addWidget(m_btnCreate); - - titleRow->addSpacing(6); - - m_btnSelectAll = new QPushButton(this); - m_btnSelectAll->setObjectName("ManageListSelectBtn"); - m_btnSelectAll->setCursor(Qt::PointingHandCursor); - m_btnSelectAll->setFixedSize(32, 32); - m_btnSelectAll->setToolTip(tr("Select all libraries")); - m_btnSelectAll->setEnabled(false); - titleRow->addWidget(m_btnSelectAll); - - titleRow->addSpacing(6); - - m_btnDeleteSelected = new QPushButton(this); - m_btnDeleteSelected->setObjectName("ManageListDeleteBtn"); - m_btnDeleteSelected->setCursor(Qt::PointingHandCursor); - m_btnDeleteSelected->setFixedSize(32, 32); - m_btnDeleteSelected->setToolTip(tr("Delete selected libraries")); - m_btnDeleteSelected->setEnabled(false); - titleRow->addWidget(m_btnDeleteSelected); - - titleRow->addSpacing(12); - - m_scanProgress = new QProgressBar(this); - m_scanProgress->setObjectName("ManageLibScanProgress"); - m_scanProgress->setTextVisible(false); - m_scanProgress->setRange(0, 100); - m_scanProgress->setValue(0); - m_scanProgress->hide(); - titleRow->addWidget(m_scanProgress, 1); - - m_scanPercentLabel = new QLabel(this); - m_scanPercentLabel->setObjectName("ManageInfoValue"); - m_scanPercentLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_scanPercentLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - m_scanPercentLabel->setMinimumWidth(40); - m_scanPercentLabel->hide(); - titleRow->addWidget(m_scanPercentLabel); - - m_btnCancelScan = new QPushButton(this); - m_btnCancelScan->setObjectName("ManageLibCancelScanBtn"); - m_btnCancelScan->setCursor(Qt::PointingHandCursor); - m_btnCancelScan->setFixedSize(20, 20); - m_btnCancelScan->setToolTip(tr("Cancel scan")); - m_btnCancelScan->hide(); - titleRow->addWidget(m_btnCancelScan); - - titleRow->addStretch(2); - - m_statsLabel = new QLabel(this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); - - - m_scanTimer = new QTimer(this); - m_scanTimer->setInterval(2000); - connect(m_scanTimer, &QTimer::timeout, this, - [this]() { m_pendingTask = pollScanProgress(); }); - - - auto *scrollContent = new QWidget(this); - scrollContent->setAttribute(Qt::WA_StyledBackground, true); - auto *scrollLayout = new QVBoxLayout(scrollContent); - scrollLayout->setContentsMargins(0, 0, 0, 40); - scrollLayout->setSpacing(16); - - m_grid = new LibraryGrid(scrollContent); - scrollLayout->addWidget(m_grid, 1); - - - m_emptyStateContainer = new QWidget(scrollContent); - m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Expanding); - auto *emptyLayout = new QVBoxLayout(m_emptyStateContainer); - emptyLayout->setContentsMargins(0, 0, 0, 0); - emptyLayout->setSpacing(0); - emptyLayout->addStretch(1); - - m_emptyLabel = new QLabel(tr("No media libraries found"), m_emptyStateContainer); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - m_emptyLabel->hide(); - emptyLayout->addWidget(m_emptyLabel); - emptyLayout->addStretch(1); - - m_emptyStateContainer->hide(); - scrollLayout->addWidget(m_emptyStateContainer, 1); + : ManagePageBase(core, QString(), parent) { + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); + // ManageView currently tracks this viewport for wheel-event coordination. + // Keep that contract while making QuickPageHost the only page content. m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName("SettingsScrollArea"); - m_scrollArea->setWidget(scrollContent); + m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); m_scrollArea->setWidgetResizable(true); m_scrollArea->setFrameShape(QFrame::NoFrame); + m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_scrollArea->viewport()->setAutoFillBackground(false); - m_mainLayout->addWidget(m_scrollArea, 1); + m_quickHost = new QuickPageHost(m_scrollArea); + m_viewModel = new AdminLibrariesViewModel(core, m_quickHost, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("librariesPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminLibrariesPage.qml"))); + m_scrollArea->setWidget(m_quickHost); + m_mainLayout->addWidget(m_scrollArea); - m_loadingOverlay = new LoadingOverlay(m_scrollArea->viewport()); - m_loadingOverlay->setHudPanelVisible(false); - m_loadingOverlay->setSubtleOverlay(true); - updateLoadingOverlayGeometry(); - - - connect(m_btnRefresh, &QPushButton::clicked, this, - [this]() { m_pendingTask = loadData(true); }); - connect(m_btnScanAll, &QPushButton::clicked, this, - [this]() { m_pendingTask = onScanAllClicked(); }); - connect(m_btnCreate, &QPushButton::clicked, this, - [this]() { m_pendingTask = onCreateClicked(); }); - connect(m_btnSelectAll, &QPushButton::clicked, this, - [this]() { toggleSelectAll(); }); - connect(m_btnDeleteSelected, &QPushButton::clicked, this, - [this]() { m_pendingTask = onDeleteSelectedClicked(); }); - connect(m_btnCancelScan, &QPushButton::clicked, this, - [this]() { m_pendingTask = onCancelScanClicked(); }); - - connect( - m_grid, &LibraryGrid::orderChanged, this, - [this](const QStringList &ids) { m_pendingTask = onOrderChanged(ids); }); -} - -void PageLibraries::showEvent(QShowEvent *event) { - ManagePageBase::showEvent(event); - updateLoadingOverlayGeometry(); - if (!m_loaded) { - m_pendingTask = loadData(); - m_loaded = true; - } - - m_scanTimer->start(); - - m_pendingTask = pollScanProgress(); - - connectWebSocket(); -} - -void PageLibraries::hideEvent(QHideEvent *event) { - ManagePageBase::hideEvent(event); - m_scanTimer->stop(); - disconnectWebSocket(); -} - -void PageLibraries::resizeEvent(QResizeEvent *event) { - ManagePageBase::resizeEvent(event); - updateLoadingOverlayGeometry(); -} - -bool PageLibraries::hasActiveScan() const { - return m_isScanAll || !m_scanningItemIds.isEmpty() || - !m_runningTaskId.isEmpty(); -} - -void PageLibraries::setTotalScanProgress(int progress) { - const int boundedProgress = qBound(0, progress, 100); - m_scanProgress->setValue(boundedProgress); - m_scanPercentLabel->setText(QString("%1%").arg(boundedProgress)); - m_scanProgress->show(); - m_scanPercentLabel->show(); - m_btnCancelScan->show(); - m_btnScanAll->setEnabled(false); -} - -void PageLibraries::resetTotalScanUi() { - m_scanProgress->hide(); - m_scanPercentLabel->hide(); - m_btnCancelScan->hide(); - m_scanProgress->setValue(0); - m_scanPercentLabel->setText("0%"); -} - -void PageLibraries::setCardScanProgress(const QString &itemId, int progress) { - for (auto *card : m_grid->cards()) { - if (card->itemId() == itemId) { - card->setScanProgress(progress); - return; - } - } -} - -void PageLibraries::clearCardScanProgress(const QString &itemId) { - for (auto *card : m_grid->cards()) { - if (card->itemId() == itemId) { - card->clearScanProgress(); - return; - } - } -} - -void PageLibraries::clearAllCardScanProgress() { - for (auto *card : m_grid->cards()) { - if (card->isScanActive()) { - card->clearScanProgress(); - } - } -} - -void PageLibraries::updateTrackedCardProgressFromTask(int progress) { - if (m_isScanAll || m_scanningItemIds.size() != 1) { - return; - } - - const QString itemId = *m_scanningItemIds.constBegin(); - if (progress <= 0) { - clearCardScanProgress(itemId); - return; - } - - if (progress >= 100) { - clearCardScanProgress(itemId); - return; - } - - setCardScanProgress(itemId, progress); -} - -void PageLibraries::requestLibraryReload(bool deferIfScanActive) { - if (deferIfScanActive && hasActiveScan()) { - m_reloadDeferredUntilScanEnd = true; - qDebug() << "[PageLibraries] Deferring library reload until scan ends"; - return; - } - - qDebug() << "[PageLibraries] Reloading libraries"; - m_pendingTask = loadData(false); -} - -void PageLibraries::updateContentState() { - const bool isEmpty = m_folders.isEmpty(); - m_grid->setVisible(!isEmpty); - m_emptyStateContainer->setVisible(isEmpty); - m_emptyLabel->setVisible(isEmpty); - updateSelectionState(); -} - -void PageLibraries::updateStats() { - if (m_isLoading) { - m_statsLabel->setText(tr("Loading...")); - return; - } - - const QString libCountText = - m_folders.isEmpty() ? tr("0 libraries") - : tr("%n library(s)", "", m_folders.size()); - if (m_selectedLibraryIds.isEmpty()) { - m_statsLabel->setText(libCountText); - return; - } - - m_statsLabel->setText( - tr("%1 · %2") - .arg(libCountText, - tr("%n selected", "", m_selectedLibraryIds.size()))); -} - -void PageLibraries::updateSelectionState() { - m_btnRefresh->setEnabled(!m_isLoading); - m_btnCreate->setEnabled(!m_isLoading); - if (!m_isLoading && !hasActiveScan()) { - m_btnScanAll->setEnabled(true); - } else if (m_isLoading) { - m_btnScanAll->setEnabled(false); - } - m_btnSelectAll->setEnabled(!m_isLoading && !m_folders.isEmpty()); - m_btnDeleteSelected->setEnabled(!m_isLoading && - !m_selectedLibraryIds.isEmpty()); - m_btnSelectAll->setToolTip(areAllLibrariesSelected() - ? tr("Clear library selection") - : tr("Select all libraries")); - updateStats(); -} - -void PageLibraries::syncCardSelection() { - for (auto *card : m_grid->cards()) { - card->setSelected(m_selectedLibraryIds.contains(card->itemId())); - } -} - -void PageLibraries::onCardSelectionChanged(const QString &itemId, - bool selected) { - if (selected) { - m_selectedLibraryIds.insert(itemId); - } else { - m_selectedLibraryIds.remove(itemId); - } - - qDebug() << "[PageLibraries] Library selection changed" - << "| itemId=" << itemId - << "| selected=" << selected - << "| selectedCount=" << m_selectedLibraryIds.size(); - - updateSelectionState(); -} - -void PageLibraries::toggleSelectAll() { - if (areAllLibrariesSelected()) { - m_selectedLibraryIds.clear(); - } else { - m_selectedLibraryIds.clear(); - for (const VirtualFolder &folder : m_folders) { - m_selectedLibraryIds.insert(folder.itemId); - } - } - - syncCardSelection(); - updateSelectionState(); -} - -bool PageLibraries::areAllLibrariesSelected() const { - return !m_folders.isEmpty() && - m_selectedLibraryIds.size() == m_folders.size(); -} - -void PageLibraries::setLoadingState(bool loading) { - if (m_isLoading == loading) { - return; - } - - m_isLoading = loading; - qDebug() << "[PageLibraries] Loading state changed" - << "| loading=" << loading; - - updateContentState(); - - if (!m_loadingOverlay) { - return; - } - - updateLoadingOverlayGeometry(); - if (loading) { - m_loadingOverlay->start(); - } else { - m_loadingOverlay->stop(); - } -} - -void PageLibraries::updateLoadingOverlayGeometry() { - if (!m_loadingOverlay || !m_scrollArea) { - return; - } - - m_loadingOverlay->setGeometry(m_scrollArea->viewport()->rect()); -} - -void PageLibraries::finalizeScan(bool reloadLibraries) { - qDebug() << "[PageLibraries] Finalizing scan" - << "reloadLibraries=" << reloadLibraries - << "deferredReload=" << m_reloadDeferredUntilScanEnd; - - const bool showTotalUi = m_isScanAll || m_scanProgress->isVisible(); - clearAllCardScanProgress(); - if (showTotalUi) { - setTotalScanProgress(100); - } else { - resetTotalScanUi(); - m_btnScanAll->setEnabled(true); - } - - m_isScanAll = false; - m_scanningItemIds.clear(); - m_runningTaskId.clear(); - - if (showTotalUi) { - QTimer::singleShot(1500, this, [this]() { - if (hasActiveScan()) { - return; - } - resetTotalScanUi(); - m_btnScanAll->setEnabled(true); - }); - } - - const bool shouldReload = reloadLibraries || m_reloadDeferredUntilScanEnd; - m_reloadDeferredUntilScanEnd = false; - if (shouldReload) { - requestLibraryReload(false); - } -} - -QCoro::Task PageLibraries::loadData(bool isManual) { - QPointer safeThis(this); - setLoadingState(true); - if (isManual) { - m_btnRefresh->setEnabled(false); - RefreshSpin::start(m_btnRefresh); - } - - try { - const QList fetchedFolders = - co_await m_core->adminService()->getVirtualFolders(); - if (!safeThis) - co_return; - m_folders = fetchedFolders; - - - QStringList orderIds = co_await m_core->adminService()->getLibraryOrder(); - if (!safeThis) - co_return; - if (!orderIds.isEmpty()) { - QList sorted; - QList remaining = m_folders; - for (const auto &orderId : orderIds) { - for (int i = 0; i < remaining.size(); ++i) { - if (remaining[i].itemId == orderId) { - sorted.append(remaining.takeAt(i)); - break; - } - } - } - sorted.append(remaining); - m_folders = sorted; - } - - if (m_folders.isEmpty()) { - ++m_cardImageLoadGeneration; - m_grid->setCards({}); - m_selectedLibraryIds.clear(); - } else { - QSet retainedSelectionIds; - QList cards; - for (int i = 0; i < m_folders.size(); ++i) { - if (m_selectedLibraryIds.contains(m_folders[i].itemId)) { - retainedSelectionIds.insert(m_folders[i].itemId); - } - - auto *card = new LibraryCard(m_folders[i], i, m_grid); - card->setSelected(retainedSelectionIds.contains(m_folders[i].itemId)); - connect(card, &LibraryCard::editClicked, this, - [this](int idx) { m_pendingTask = onEditClicked(idx); }); - connect(card, &LibraryCard::editImagesRequested, this, - [this](int idx) { - m_pendingTask = onEditImagesRequested(idx); - }); - connect(card, &LibraryCard::scanClicked, this, - [this](int idx) { - m_pendingTask = onScanLibraryFilesRequested(idx); - }); - connect(card, &LibraryCard::deleteClicked, this, - [this](int idx) { m_pendingTask = onDeleteClicked(idx); }); - connect(card, &LibraryCard::refreshMetadataRequested, this, - [this](int idx) { - m_pendingTask = onRefreshMetadataRequested(idx); - }); - connect(card, &LibraryCard::scanLibraryFilesRequested, this, - [this](int idx) { - m_pendingTask = onScanLibraryFilesRequested(idx); - }); - connect(card, &LibraryCard::selectionChanged, this, - [this](const QString &itemId, bool selected) { - onCardSelectionChanged(itemId, selected); - }); - cards.append(card); - } - - m_selectedLibraryIds = retainedSelectionIds; - const quint64 imageLoadGeneration = ++m_cardImageLoadGeneration; - m_grid->setCards(cards); - syncCardSelection(); - - - for (int i = 0; i < cards.size(); ++i) { - loadCardImage(cards[i], m_folders[i], imageLoadGeneration); - } - } - - updateContentState(); - - } catch (const std::exception &e) { - if (!safeThis) - co_return; - ModernToast::showMessage( - tr("Failed to load libraries: %1").arg(QString::fromUtf8(e.what())), - 3000); - } - - if (!safeThis) - co_return; - setLoadingState(false); -} - -QCoro::Task PageLibraries::loadCardImage(LibraryCard *card, - VirtualFolder folder, - quint64 imageLoadGeneration) { - QPointer safeThis(this); - QPointer safeCard(card); - const QString folderItemId = folder.itemId; - const QString imageItemId = folder.primaryImageItemId.isEmpty() - ? folder.itemId - : folder.primaryImageItemId; - - try { - if (imageItemId.isEmpty()) - co_return; - - qDebug() << "[PageLibraries] Loading library card image" - << "folderItemId=" << folderItemId - << "imageItemId=" << imageItemId - << "generation=" << imageLoadGeneration; - - QPixmap pixmap = co_await m_core->mediaService()->fetchImage( - imageItemId, "Primary", QString(), LibraryGrid::CardWidth * 2); - - if (!safeThis) { - qDebug() << "[PageLibraries] Skip library card image apply:" - << "page already destroyed" - << "folderItemId=" << folderItemId; - co_return; - } - - if (imageLoadGeneration != safeThis->m_cardImageLoadGeneration) { - qDebug() << "[PageLibraries] Skip stale library card image:" - << "folderItemId=" << folderItemId - << "requestGeneration=" << imageLoadGeneration - << "currentGeneration=" << safeThis->m_cardImageLoadGeneration; - co_return; - } - - if (!safeCard) { - qDebug() << "[PageLibraries] Skip library card image apply:" - << "card already destroyed" - << "folderItemId=" << folderItemId; - co_return; - } - - if (safeCard->itemId() != folderItemId) { - qDebug() << "[PageLibraries] Skip mismatched library card image:" - << "folderItemId=" << folderItemId - << "cardItemId=" << safeCard->itemId(); - co_return; - } - - if (!pixmap.isNull()) { - safeCard->setImage(pixmap); - } - } catch (...) { - - qDebug() << "[PageLibraries] Failed to load library card image" - << "folderItemId=" << folderItemId - << "imageItemId=" << imageItemId; - } -} - -QCoro::Task PageLibraries::onCreateClicked() { - - auto providers = co_await m_core->adminService()->getAvailableProviders(); - auto localization = co_await m_core->adminService()->getLocalizationOptions(); - - LibraryCreateDialog dlg(m_core, providers.metadataFetchers, - providers.imageFetchers, - providers.subtitleDownloaders, - providers.lyricsFetchers, - localization.cultures, localization.countries, this); - if (dlg.exec() == QDialog::Accepted) { - try { - co_await m_core->adminService()->addVirtualFolder( - dlg.libraryName(), dlg.collectionType(), dlg.paths(), - dlg.libraryOptions()); - ModernToast::showMessage(tr("Library created successfully"), 2000); - co_await loadData(false); - } catch (const std::exception &e) { - ModernToast::showMessage( - tr("Failed to create library: %1").arg(QString::fromUtf8(e.what())), - 3000); - } - } -} - -QCoro::Task PageLibraries::onEditClicked(int row) { - if (row < 0 || row >= m_folders.size()) - co_return; - const VirtualFolder folder = m_folders[row]; - const QString virtualFolderId = folder.virtualFolderId(); - qDebug() << "[PageLibraries] onEditClicked" - << "| name:" << folder.name - << "| id:" << folder.id - << "| itemId:" << folder.itemId - << "| guid:" << folder.guid - << "| folderId:" << virtualFolderId; - auto providers = co_await m_core->adminService()->getAvailableProviders(); - auto localization = co_await m_core->adminService()->getLocalizationOptions(); - - LibraryEditDialog dlg(folder, m_core, providers.metadataFetchers, - providers.imageFetchers, - providers.subtitleDownloaders, - providers.lyricsFetchers, localization.cultures, - localization.countries, this); - if (dlg.exec() == QDialog::Accepted) { - try { - const QString updatedName = - dlg.newName().isEmpty() ? folder.name : dlg.newName(); - const QStringList addedPaths = dlg.addedPaths(); - const QStringList removedPaths = dlg.removedPaths(); - const QStringList currentPaths = dlg.paths(); - const QJsonObject updatedOptions = dlg.libraryOptions(); - const QVariant realtimeMonitorValue = - updatedOptions.value("EnableRealtimeMonitor").toVariant(); - const QVariant metadataLanguageValue = - updatedOptions.value("PreferredMetadataLanguage").toVariant(); - const QVariant countryValue = - updatedOptions.value("MetadataCountryCode").toVariant(); - const QVariant imageLanguageValue = - updatedOptions.value("PreferredImageLanguage").toVariant(); - - qDebug() << "[PageLibraries] Saving library edit" - << "| originalName:" << folder.name - << "| updatedName:" << updatedName - << "| folderId:" << virtualFolderId - << "| addedPaths:" << addedPaths - << "| removedPaths:" << removedPaths - << "| currentPathCount:" << currentPaths.size() - << "| realtimeMonitor:" << realtimeMonitorValue - << "| metadataLanguage:" << metadataLanguageValue - << "| country:" << countryValue - << "| imageLanguage:" << imageLanguageValue; - - if (updatedName != folder.name) { - co_await m_core->adminService()->renameVirtualFolder(folder.name, - updatedName); - } - for (const auto &path : addedPaths) { - co_await m_core->adminService()->addMediaPath( - virtualFolderId, updatedName, path); - } - for (const auto &path : removedPaths) { - co_await m_core->adminService()->removeMediaPath( - virtualFolderId, updatedName, path); - } - co_await m_core->adminService()->updateVirtualFolderOptions( - virtualFolderId, updatedOptions, currentPaths); - ModernToast::showMessage(tr("Library updated successfully"), 2000); - - - try { - m_scanningItemIds.insert(folder.itemId); - co_await m_core->adminService()->refreshItemMetadata(folder.itemId); - } catch (...) { - m_scanningItemIds.remove(folder.itemId); - } - - co_await loadData(false); - } catch (const std::exception &e) { - ModernToast::showMessage( - tr("Failed to update library: %1").arg(QString::fromUtf8(e.what())), - 3000); - } - } -} - -QCoro::Task PageLibraries::onDeleteClicked(int row) { - if (row < 0 || row >= m_folders.size()) - co_return; - const VirtualFolder folder = m_folders[row]; - - bool confirmed = ModernMessageBox::question( - this, tr("Delete Library"), - tr("Are you sure you want to delete the library \"%1\"?\n\nThis will " - "remove the library from the server, but will not delete the actual " - "media files.") - .arg(folder.name), - tr("Delete"), tr("Cancel"), ModernMessageBox::Danger, - ModernMessageBox::Warning); - - if (!confirmed) - co_return; - - try { - co_await m_core->adminService()->removeVirtualFolder(folder); - ModernToast::showMessage(tr("Library \"%1\" deleted").arg(folder.name), - 2000); - co_await loadData(false); - } catch (const std::exception &e) { - ModernToast::showMessage( - tr("Failed to delete library: %1").arg(QString::fromUtf8(e.what())), - 3000); - } -} - -QCoro::Task PageLibraries::onEditImagesRequested(int row) { - if (row < 0 || row >= m_folders.size()) - co_return; - - const VirtualFolder folder = m_folders[row]; - MediaImageEditTarget target; - target.itemId = folder.itemId; - target.imageItemId = folder.primaryImageItemId.isEmpty() - ? folder.itemId - : folder.primaryImageItemId; - target.displayName = folder.name; - target.itemType = QStringLiteral("CollectionFolder"); - target.collectionType = folder.collectionType; - target.isLibrary = true; - - qDebug() << "[PageLibraries] Opening library image editor" - << "| itemId:" << folder.itemId - << "| imageItemId:" << target.imageItemId - << "| name:" << folder.name - << "| collectionType:" << folder.collectionType; - - MediaImageEditDialog dialog(m_core, target, this); - dialog.exec(); - if (!dialog.hasChanges()) { - co_return; - } - - co_await loadData(false); -} - -QCoro::Task PageLibraries::onDeleteSelectedClicked() { - QPointer safeThis(this); - const int selectedCount = m_selectedLibraryIds.size(); - if (selectedCount <= 0) co_return; - - const bool confirmed = ModernMessageBox::question( - this, localizedDeleteSelectedTitle(), - localizedDeleteSelectedPrompt(selectedCount), - tr("Delete"), tr("Cancel"), ModernMessageBox::Danger, - ModernMessageBox::Warning); - if (!confirmed) co_return; - - int deletedCount = 0; - QString failureMessage; - - for (const VirtualFolder &folder : m_folders) { - if (!m_selectedLibraryIds.contains(folder.itemId)) { - continue; - } - - qDebug() << "[PageLibraries] Batch deleting library" - << "| itemId=" << folder.itemId - << "| name=" << folder.name; - - try { - co_await m_core->adminService()->removeVirtualFolder(folder); - if (!safeThis) co_return; - ++deletedCount; - } catch (const std::exception &e) { - failureMessage = QString::fromUtf8(e.what()); - qWarning() << "[PageLibraries] Failed to delete selected library" - << "| itemId=" << folder.itemId - << "| name=" << folder.name - << "| error=" << failureMessage; - break; - } - } - - if (!safeThis) co_return; - - m_selectedLibraryIds.clear(); - if (!failureMessage.isEmpty()) { - if (deletedCount > 0) { - ModernToast::showMessage( - tr("Deleted %1 library(s), then failed: %2") - .arg(deletedCount) - .arg(failureMessage), - 3500); - } else { - ModernToast::showMessage( - tr("Failed to delete selected libraries: %1").arg(failureMessage), - 3000); - } - } else { - ModernToast::showMessage(tr("Deleted %n library(s)", "", deletedCount), - 2000); - } - - co_await loadData(false); -} - -QCoro::Task PageLibraries::startLibraryRefresh(VirtualFolder folder, - bool replaceAllMetadata, - bool replaceAllImages, - bool isMetadataRefresh) { - const QString itemId = folder.itemId.trimmed(); - if (itemId.isEmpty()) { - co_return; - } - - const QString displayName = - folder.name.trimmed().isEmpty() ? itemId : folder.name.trimmed(); - const char *actionName = - isMetadataRefresh ? "RefreshMetadata" : "ScanLibraryFiles"; - - try { - qDebug() << "[PageLibraries] Starting library refresh" - << "| action=" << actionName - << "| itemId=" << itemId - << "| name=" << folder.name - << "| replaceAllMetadata=" << replaceAllMetadata - << "| replaceAllImages=" << replaceAllImages; - m_scanningItemIds.insert(itemId); - co_await m_core->adminService()->refreshItemMetadata( - itemId, replaceAllMetadata, replaceAllImages); - ModernToast::showMessage( - isMetadataRefresh - ? tr("Refreshing metadata for \"%1\"...").arg(displayName) - : tr("Scanning \"%1\"...").arg(displayName), - 2000); - QTimer::singleShot(2000, this, [this]() { requestLibraryReload(true); }); - } catch (const std::exception &e) { - qWarning() << "[PageLibraries] Library refresh failed" - << "| action=" << actionName - << "| itemId=" << itemId - << "| name=" << folder.name - << "| error=" << e.what(); - m_scanningItemIds.remove(itemId); - clearCardScanProgress(itemId); - if (!hasActiveScan()) { - resetTotalScanUi(); - m_btnScanAll->setEnabled(true); - } - ModernToast::showMessage( - isMetadataRefresh - ? tr("Metadata refresh failed: %1").arg(QString::fromUtf8(e.what())) - : tr("Scan failed: %1").arg(QString::fromUtf8(e.what())), - 3000); - } -} - -QCoro::Task PageLibraries::onRefreshMetadataRequested(int row) { - if (row < 0 || row >= m_folders.size()) - co_return; - - co_await startLibraryRefresh(m_folders[row], true, true, true); -} - -QCoro::Task PageLibraries::onScanLibraryFilesRequested(int row) { - if (row < 0 || row >= m_folders.size()) - co_return; - - co_await startLibraryRefresh(m_folders[row], false, false, false); -} - -QCoro::Task PageLibraries::onScanAllClicked() { - try { - m_isScanAll = true; - setTotalScanProgress(0); - co_await m_core->adminService()->refreshLibrary(); - ModernToast::showMessage(tr("Full library scan started"), 2000); - } catch (const std::exception &e) { - m_isScanAll = false; - resetTotalScanUi(); - m_btnScanAll->setEnabled(true); - ModernToast::showMessage( - tr("Scan failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageLibraries::pollScanProgress() { - try { - auto tasks = co_await m_core->adminService()->getScheduledTasks(); - bool foundRunning = false; - - qDebug() << "[HTTP Poll] ===== Polling ScheduledTasks ====="; - for (const auto &task : tasks) { - if (task.category == "Library") { - qDebug() << "[HTTP Poll] Task:" << task.name - << "| state:" << task.state - << "| progressPct:" << task.currentProgressPercentage - << "| id:" << task.id; - } - - if (task.category == "Library" && task.isRunning()) { - foundRunning = true; - m_runningTaskId = task.id; - - - if (!m_isScanAll && m_scanningItemIds.isEmpty()) { - qDebug() << "[HTTP Poll] >> Auto-detected external scan, setting isScanAll=true"; - m_isScanAll = true; - } - - int progress = - qBound(0, static_cast(task.currentProgressPercentage), 100); - int currentUiProgress = m_scanProgress->value(); - bool barVisible = m_scanProgress->isVisible(); - - qDebug() << "[HTTP Poll] >> Running! rawPct:" - << task.currentProgressPercentage - << "-> progress:" << progress - << "| UI current:" << currentUiProgress - << "| visible:" << barVisible - << "| isScanAll:" << m_isScanAll; - - - if (progress <= 0 && barVisible && currentUiProgress > 0) { - qDebug() << "[HTTP Poll] >> SKIP: progress=0, keeping UI at" << currentUiProgress; - break; - } - - - if (barVisible && progress < currentUiProgress) { - qDebug() << "[HTTP Poll] >> SKIP: would go backwards" << progress << "<" << currentUiProgress; - break; - } - - qDebug() << "[HTTP Poll] >> APPLY: setting progress to" << progress; - if (m_isScanAll) { - setTotalScanProgress(progress); - } - updateTrackedCardProgressFromTask(progress); - break; - } - } - - - if (!foundRunning && hasActiveScan()) { - qDebug() << "[HTTP Poll] >> Scan COMPLETED — clearing progress"; - finalizeScan(true); - } - } catch (...) { - qDebug() << "[HTTP Poll] >> ERROR: polling failed"; - } -} - -QCoro::Task PageLibraries::onCancelScanClicked() { - if (m_runningTaskId.isEmpty()) - co_return; - - try { - co_await m_core->adminService()->stopScheduledTask(m_runningTaskId); - ModernToast::showMessage(tr("Scan cancelled"), 2000); - - - clearAllCardScanProgress(); - - - resetTotalScanUi(); - m_btnScanAll->setEnabled(true); - - - m_isScanAll = false; - m_scanningItemIds.clear(); - m_runningTaskId.clear(); - if (m_reloadDeferredUntilScanEnd) { - m_reloadDeferredUntilScanEnd = false; - requestLibraryReload(false); - } - } catch (const std::exception &e) { - ModernToast::showMessage( - tr("Cancel failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageLibraries::onOrderChanged(QStringList newOrderIds) { - try { - co_await m_core->adminService()->updateLibraryOrder(newOrderIds); - ModernToast::showMessage(tr("Library order updated"), 1500); - } catch (const std::exception &e) { - ModernToast::showMessage( - tr("Failed to save order: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - - - - - -void PageLibraries::connectWebSocket() -{ - - disconnectWebSocket(); - - auto* ws = m_core->serverManager()->activeWebSocket(); - if (!ws) return; - - m_wsConnections.append( - connect(ws, &EmbyWebSocket::refreshProgress, - this, &PageLibraries::onWsRefreshProgress)); - - m_wsConnections.append( - connect(ws, &EmbyWebSocket::libraryChanged, - this, [this]() { requestLibraryReload(true); })); - - - m_wsConnections.append( - connect(ws, &EmbyWebSocket::connected, this, [this]() { - qDebug() << "[PageLibraries] WebSocket connected — slowing HTTP polling to 5s"; - m_scanTimer->setInterval(5000); - })); - - - m_wsConnections.append( - connect(ws, &EmbyWebSocket::disconnected, this, [this]() { - qDebug() << "[PageLibraries] WebSocket disconnected — restoring 2s polling"; - m_scanTimer->setInterval(2000); - if (isVisible() && !m_scanTimer->isActive()) { - m_scanTimer->start(); - m_pendingTask = pollScanProgress(); - } - })); - - - if (ws->isConnected()) { - m_scanTimer->setInterval(5000); - } -} - -void PageLibraries::disconnectWebSocket() -{ - for (auto& conn : m_wsConnections) { - QObject::disconnect(conn); - } - m_wsConnections.clear(); -} - -void PageLibraries::onWsRefreshProgress(const QString& itemId, int progress) -{ - qDebug() << "[PageLibraries] WS refreshProgress itemId=" << itemId << "progress=" << progress; - - - if (itemId.isEmpty()) { - if (!m_isScanAll && m_scanningItemIds.isEmpty()) { - m_isScanAll = true; - } - - int currentProgress = m_scanProgress->value(); - if (m_scanProgress->isVisible() && progress < currentProgress && progress > 0) { - return; - } - - if (m_isScanAll) { - setTotalScanProgress(progress); - } - updateTrackedCardProgressFromTask(progress); - - if (progress >= 100) { - finalizeScan(true); - } - return; - } - - - for (auto* card : m_grid->cards()) { - if (card->itemId() == itemId) { - if (progress <= 0) { - card->clearScanProgress(); - } else if (progress >= 100) { - card->clearScanProgress(); - m_scanningItemIds.remove(itemId); - } else { - card->setScanProgress(progress); - m_scanningItemIds.insert(itemId); - } - return; - } - } - - - + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this] { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this] { m_viewModel->setActive(false); }); } diff --git a/src/qEmbyApp/views/admin/pagelibraries.h b/src/qEmbyApp/views/admin/pagelibraries.h index 315329c..04f0692 100644 --- a/src/qEmbyApp/views/admin/pagelibraries.h +++ b/src/qEmbyApp/views/admin/pagelibraries.h @@ -2,114 +2,24 @@ #define PAGELIBRARIES_H #include "managepagebase.h" -#include "librarycard.h" -#include "librarygrid.h" -#include -#include -#include -#include -class EmbyWebSocket; -class LoadingOverlay; - -class QLabel; -class QPushButton; -class QProgressBar; -class QResizeEvent; +class AdminLibrariesViewModel; class QScrollArea; -class QTimer; -class QWidget; +class QuickPageHost; +// Compatibility shell kept because ManageView owns and indexes PageLibraries. +// The complete page body and interaction state now live in QML/ViewModel. +class PageLibraries final : public ManagePageBase { + Q_OBJECT - - -class PageLibraries : public ManagePageBase { - Q_OBJECT public: - explicit PageLibraries(QEmbyCore* core, QWidget* parent = nullptr); - QScrollArea* scrollArea() const { return m_scrollArea; } - -protected: - void showEvent(QShowEvent* event) override; - void hideEvent(QHideEvent* event) override; - void resizeEvent(QResizeEvent* event) override; + explicit PageLibraries(QEmbyCore *core, QWidget *parent = nullptr); + QScrollArea *scrollArea() const { return m_scrollArea; } private: - QCoro::Task loadData(bool isManual = false); - QCoro::Task loadCardImage(LibraryCard* card, VirtualFolder folder, - quint64 imageLoadGeneration); - QCoro::Task onCreateClicked(); - QCoro::Task onEditClicked(int row); - QCoro::Task onEditImagesRequested(int row); - QCoro::Task onDeleteClicked(int row); - QCoro::Task onDeleteSelectedClicked(); - QCoro::Task onRefreshMetadataRequested(int row); - QCoro::Task onScanLibraryFilesRequested(int row); - QCoro::Task onScanAllClicked(); - QCoro::Task onOrderChanged(QStringList newOrderIds); - QCoro::Task onCancelScanClicked(); - QCoro::Task pollScanProgress(); - QCoro::Task startLibraryRefresh(VirtualFolder folder, - bool replaceAllMetadata, - bool replaceAllImages, - bool isMetadataRefresh); - - - void onWsRefreshProgress(const QString& itemId, int progress); - void connectWebSocket(); - void disconnectWebSocket(); - bool hasActiveScan() const; - void setTotalScanProgress(int progress); - void resetTotalScanUi(); - void setCardScanProgress(const QString& itemId, int progress); - void clearCardScanProgress(const QString& itemId); - void clearAllCardScanProgress(); - void updateTrackedCardProgressFromTask(int progress); - void finalizeScan(bool reloadLibraries); - void requestLibraryReload(bool deferIfScanActive = true); - void updateContentState(); - void updateStats(); - void updateSelectionState(); - void syncCardSelection(); - void onCardSelectionChanged(const QString& itemId, bool selected); - void toggleSelectAll(); - bool areAllLibrariesSelected() const; - void setLoadingState(bool loading); - void updateLoadingOverlayGeometry(); - - LibraryGrid* m_grid; - QPushButton* m_btnRefresh; - QPushButton* m_btnScanAll; - QPushButton* m_btnCreate; - QPushButton* m_btnSelectAll; - QPushButton* m_btnDeleteSelected; - QPushButton* m_btnCancelScan; - QLabel* m_statsLabel; - QWidget* m_emptyStateContainer; - QLabel* m_emptyLabel; - LoadingOverlay* m_loadingOverlay = nullptr; - QProgressBar* m_scanProgress; - QLabel* m_scanPercentLabel; - QTimer* m_scanTimer; - QScrollArea* m_scrollArea; - - QList m_folders; - QSet m_selectedLibraryIds; - bool m_loaded = false; - bool m_isLoading = false; - - - QSet m_scanningItemIds; - bool m_isScanAll = false; - QString m_runningTaskId; - - - std::optional> m_pendingTask; - - - QList m_wsConnections; - quint64 m_cardImageLoadGeneration = 0; - bool m_reloadDeferredUntilScanEnd = false; + QScrollArea *m_scrollArea = nullptr; + QuickPageHost *m_quickHost = nullptr; + AdminLibrariesViewModel *m_viewModel = nullptr; }; -#endif +#endif diff --git a/src/qEmbyApp/views/admin/pagelogs.cpp b/src/qEmbyApp/views/admin/pagelogs.cpp index 5a996b0..f7602d5 100644 --- a/src/qEmbyApp/views/admin/pagelogs.cpp +++ b/src/qEmbyApp/views/admin/pagelogs.cpp @@ -1,690 +1,36 @@ #include "pagelogs.h" -#include "../../components/elidedlabel.h" -#include "../../components/moderntoast.h" -#include "../../managers/thememanager.h" -#include -#include +#include "../../quick/logsviewmodel.h" +#include "../../quick/quickpagehost.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" +#include -#include -#include -#include +PageLogs::PageLogs(QEmbyCore *core, QWidget *parent) + : ManagePageBase(core, QString(), parent) { + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); -namespace { + m_quickHost = new QuickPageHost(this); + m_viewModel = new LogsViewModel(core, this, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("logsPage"), m_viewModel); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminLogsPage.qml"))); + m_mainLayout->addWidget(m_quickHost); -QDateTime parseIsoDateTime(QString rawDateTime) { - if (rawDateTime.isEmpty()) { - return {}; - } - - QDateTime parsed = QDateTime::fromString(rawDateTime, Qt::ISODate); - if (!parsed.isValid()) { - parsed = QDateTime::fromString(rawDateTime, Qt::ISODateWithMs); - } - return parsed; + // ManageView tracks legacy scroll areas. QML owns the visible scrolling. + m_scrollArea = new QScrollArea(this); + m_scrollArea->hide(); } -QString formatIsoDateTime(const QString& rawDateTime) { - const QDateTime parsed = parseIsoDateTime(rawDateTime); - if (!parsed.isValid()) { - return rawDateTime; - } - return parsed.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); +void PageLogs::showEvent(QShowEvent *event) { + ManagePageBase::showEvent(event); + m_viewModel->setActive(true); } -QString formatCompactDateTime(const QString& rawDateTime) { - const QDateTime parsed = parseIsoDateTime(rawDateTime); - if (!parsed.isValid()) { - return QStringLiteral("--"); - } - return parsed.toLocalTime().toString(QStringLiteral("MM-dd HH:mm")); -} - -enum LogItemRoles { - LogNameRole = Qt::UserRole, - LogModifiedDisplayRole, -}; - -class LogFileItemDelegate : public QStyledItemDelegate { -public: - using DownloadHandler = std::function; - - explicit LogFileItemDelegate(QListWidget* listWidget, DownloadHandler handler) - : QStyledItemDelegate(listWidget), m_listWidget(listWidget), m_onDownload(std::move(handler)) { - if (m_listWidget && m_listWidget->viewport()) { - m_listWidget->viewport()->installEventFilter(this); - } - } - - QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override { - Q_UNUSED(option) - Q_UNUSED(index) - return QSize(0, 34); - } - - void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - - const bool isDarkMode = ThemeManager::instance()->isDarkMode(); - const bool isSelected = option.state.testFlag(QStyle::State_Selected); - const bool isHovered = option.state.testFlag(QStyle::State_MouseOver); - const QRect contentRect = option.rect.adjusted(0, 0, -10, 0); - const QRectF rowRect = contentRect.adjusted(0, 0, -1, -1); - - QColor rowBackground = Qt::transparent; - QColor rowBorder = Qt::transparent; - if (isSelected) { - rowBackground = isDarkMode ? QColor(59, 130, 246, 28) : QColor(59, 130, 246, 18); - rowBorder = isDarkMode ? QColor(59, 130, 246, 42) : QColor(59, 130, 246, 30); - } else if (isHovered) { - rowBackground = isDarkMode ? QColor(255, 255, 255, 12) : QColor(15, 23, 42, 8); - rowBorder = isDarkMode ? QColor(148, 163, 184, 20) : QColor(148, 163, 184, 20); - } - - if (rowBackground.alpha() > 0 || rowBorder.alpha() > 0) { - painter->setBrush(rowBackground); - painter->setPen(QPen(rowBorder, 1)); - painter->drawRoundedRect(rowRect.adjusted(1, 1, -1, -1), 8, 8); - } - - const QRect buttonRect = downloadButtonRect(contentRect); - const QString modifiedText = index.data(LogModifiedDisplayRole).toString(); - const QString fileName = index.data(LogNameRole).toString(); - const bool buttonHovered = m_hoveredButtonRow == index.row(); - const bool buttonPressed = m_pressedButtonRow == index.row(); - - QFont metaFont = option.font; - metaFont.setPointSize(std::max(10, metaFont.pointSize() - 1)); - QFontMetrics metaMetrics(metaFont); - const int timeWidth = metaMetrics.horizontalAdvance(modifiedText) + 2; - const QRect timeRect(buttonRect.left() - timeWidth - 10, contentRect.top(), timeWidth, contentRect.height()); - const QRect nameRect(contentRect.left() + 12, contentRect.top(), - std::max(40, timeRect.left() - contentRect.left() - 24), contentRect.height()); - - QFont nameFont = option.font; - nameFont.setWeight(QFont::Medium); - painter->setFont(nameFont); - painter->setPen(isDarkMode ? QColor("#E2E8F0") : QColor("#1E293B")); - painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, - QFontMetrics(nameFont).elidedText(fileName, Qt::ElideRight, nameRect.width())); - - painter->setFont(metaFont); - painter->setPen(isDarkMode ? QColor("#94A3B8") : QColor("#64748B")); - painter->drawText(timeRect, Qt::AlignVCenter | Qt::AlignRight, modifiedText); - - QColor buttonBackground = Qt::transparent; - QColor buttonBorder = isDarkMode ? QColor(148, 163, 184, 32) : QColor(148, 163, 184, 40); - QColor iconColor = isSelected ? (isDarkMode ? QColor("#BFDBFE") : QColor("#2563EB")) - : (isDarkMode ? QColor("#CBD5E1") : QColor("#475569")); - - if (buttonHovered) { - buttonBackground = isDarkMode ? QColor(59, 130, 246, 26) : QColor(59, 130, 246, 16); - buttonBorder = isDarkMode ? QColor(59, 130, 246, 52) : QColor(59, 130, 246, 42); - iconColor = isDarkMode ? QColor("#BFDBFE") : QColor("#2563EB"); - } - if (buttonPressed) { - buttonBackground = isDarkMode ? QColor(59, 130, 246, 42) : QColor(59, 130, 246, 26); - buttonBorder = isDarkMode ? QColor(59, 130, 246, 72) : QColor(59, 130, 246, 56); - } - - painter->setBrush(buttonBackground); - painter->setPen(QPen(buttonBorder, 1)); - painter->drawRoundedRect(buttonRect.adjusted(0, 0, -1, -1), 7, 7); - - const QIcon icon = ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/dark/download.svg"), iconColor); - icon.paint(painter, buttonRect.adjusted(5, 5, -5, -5)); - - painter->restore(); - } - - bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, - const QModelIndex& index) override { - Q_UNUSED(model) - Q_UNUSED(event) - Q_UNUSED(option) - Q_UNUSED(index) - return false; - } - -private: - bool eventFilter(QObject* watched, QEvent* event) override { - if (!m_listWidget || watched != m_listWidget->viewport()) { - return QStyledItemDelegate::eventFilter(watched, event); - } - - switch (event->type()) { - case QEvent::MouseMove: { - auto* mouseEvent = static_cast(event); - updateHoveredButtonRow(hitDownloadButtonRow(mouseEvent->position().toPoint())); - break; - } - case QEvent::MouseButtonPress: { - auto* mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::LeftButton) { - const int hitRow = hitDownloadButtonRow(mouseEvent->position().toPoint()); - if (hitRow >= 0) { - m_pressedButtonRow = hitRow; - updateHoveredButtonRow(hitRow); - m_listWidget->viewport()->update(); - return true; - } - } - break; - } - case QEvent::MouseButtonRelease: { - auto* mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::LeftButton) { - const int hitRow = hitDownloadButtonRow(mouseEvent->position().toPoint()); - const int pressedRow = m_pressedButtonRow; - m_pressedButtonRow = -1; - updateHoveredButtonRow(hitRow); - m_listWidget->viewport()->update(); - - if (pressedRow >= 0 && pressedRow == hitRow && m_onDownload) { - const QModelIndex index = m_listWidget->model()->index(pressedRow, 0); - m_onDownload(pressedRow, index.data(LogNameRole).toString()); - return true; - } - } - break; - } - case QEvent::Leave: - if (m_hoveredButtonRow >= 0 || m_pressedButtonRow >= 0) { - m_hoveredButtonRow = -1; - m_pressedButtonRow = -1; - m_listWidget->viewport()->update(); - } - break; - default: - break; - } - - return QStyledItemDelegate::eventFilter(watched, event); - } - - static QRect downloadButtonRect(const QRect& rowRect) { - const int buttonSize = 24; - return QRect(rowRect.right() - 12 - buttonSize, rowRect.center().y() - buttonSize / 2, buttonSize, - buttonSize); - } - - QRect buttonRectForIndex(const QModelIndex& index) const { - return downloadButtonRect(m_listWidget->visualRect(index).adjusted(0, 0, -10, 0)); - } - - int hitDownloadButtonRow(const QPoint& viewportPos) const { - const QModelIndex index = m_listWidget->indexAt(viewportPos); - if (!index.isValid()) { - return -1; - } - return buttonRectForIndex(index).contains(viewportPos) ? index.row() : -1; - } - - void updateHoveredButtonRow(int row) { - if (m_hoveredButtonRow == row) { - return; - } - m_hoveredButtonRow = row; - if (m_listWidget && m_listWidget->viewport()) { - m_listWidget->viewport()->update(); - } - } - - QListWidget* m_listWidget = nullptr; - DownloadHandler m_onDownload; - int m_hoveredButtonRow = -1; - int m_pressedButtonRow = -1; -}; - -} - - - - - -PageLogs::PageLogs(QEmbyCore* core, QWidget* parent) - : ManagePageBase(core, tr("Logs"), parent) { - m_mainLayout->setContentsMargins(16, 30, 0, 24); - m_mainLayout->setSpacing(16); - - auto* titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, 12, 0); - titleRow->setSpacing(8); - - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - titleRow->addStretch(1); - - m_statsLabel = new QLabel(this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - m_statsLabel->setText(tr("Loading...")); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); - - auto* fileListCard = new QFrame(this); - fileListCard->setObjectName("ManageLogListCard"); - fileListCard->setAttribute(Qt::WA_StyledBackground, true); - fileListCard->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - fileListCard->setMaximumHeight(176); - - auto* fileListLayout = new QVBoxLayout(fileListCard); - fileListLayout->setContentsMargins(0, 0, 0, 0); - fileListLayout->setSpacing(0); - - m_fileList = new QListWidget(fileListCard); - m_fileList->setObjectName("ManageLogFileList"); - m_fileList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_fileList->setSelectionMode(QAbstractItemView::SingleSelection); - m_fileList->setTextElideMode(Qt::ElideRight); - m_fileList->setWordWrap(false); - m_fileList->setSpacing(0); - m_fileList->setMouseTracking(true); - m_fileList->setUniformItemSizes(true); - m_fileList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); - m_fileList->setMinimumHeight(140); - m_fileList->setItemDelegate(new LogFileItemDelegate( - m_fileList, [this](int row, QString logName) { - Q_UNUSED(row) - if (!logName.isEmpty()) { - m_pendingDownloadTask = downloadLogFile(logName); - } - })); - fileListLayout->addWidget(m_fileList, 1); - - m_emptyLabel = new QLabel(tr("No log files found"), fileListCard); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - m_emptyLabel->setContentsMargins(0, 0, 0, 0); - m_emptyLabel->hide(); - fileListLayout->addWidget(m_emptyLabel, 1); - - m_mainLayout->addWidget(fileListCard); - - auto* dividerContainer = new QWidget(this); - auto* dividerLayout = new QHBoxLayout(dividerContainer); - dividerLayout->setContentsMargins(0, 0, 12, 0); - dividerLayout->setSpacing(0); - - auto* divider = new QFrame(dividerContainer); - divider->setObjectName("ManageLogDivider"); - divider->setFixedHeight(1); - divider->setFrameShape(QFrame::NoFrame); - dividerLayout->addWidget(divider); - - m_mainLayout->addWidget(dividerContainer); - - auto* viewerCard = new QFrame(this); - viewerCard->setObjectName("ManageLogViewerCard"); - viewerCard->setAttribute(Qt::WA_StyledBackground, true); - viewerCard->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - auto* viewerLayout = new QVBoxLayout(viewerCard); - viewerLayout->setContentsMargins(0, 0, 0, 0); - viewerLayout->setSpacing(0); - - auto* viewerHeader = new QWidget(viewerCard); - auto* viewerHeaderLayout = new QVBoxLayout(viewerHeader); - viewerHeaderLayout->setContentsMargins(16, 0, 16, 10); - viewerHeaderLayout->setSpacing(6); - - m_currentFileNameLabel = new ElidedLabel(viewerHeader); - m_currentFileNameLabel->setObjectName("ManageLogSelectedName"); - viewerHeaderLayout->addWidget(m_currentFileNameLabel); - - m_currentFileMetaLabel = new QLabel(viewerHeader); - m_currentFileMetaLabel->setObjectName("ManageLogSelectedMeta"); - m_currentFileMetaLabel->setWordWrap(true); - viewerHeaderLayout->addWidget(m_currentFileMetaLabel); - viewerLayout->addWidget(viewerHeader); - - m_viewerPlaceholder = new QLabel(tr("Select a log file to inspect its contents"), viewerCard); - m_viewerPlaceholder->setObjectName("ManageEmptyLabel"); - m_viewerPlaceholder->setAlignment(Qt::AlignCenter); - m_viewerPlaceholder->setWordWrap(true); - m_viewerPlaceholder->setMargin(24); - viewerLayout->addWidget(m_viewerPlaceholder, 1); - - m_logViewer = new QPlainTextEdit(viewerCard); - m_logViewer->setObjectName("ManageLogViewer"); - m_logViewer->setReadOnly(true); - m_logViewer->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_logViewer->setLineWrapMode(QPlainTextEdit::WidgetWidth); - m_logViewer->setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); - m_logViewer->document()->setDocumentMargin(16.0); - - QFont monoFont(QStringLiteral("Consolas"), 10); - monoFont.setStyleHint(QFont::Monospace); - m_logViewer->setFont(monoFont); - m_logViewer->hide(); - viewerLayout->addWidget(m_logViewer, 1); - - m_mainLayout->addWidget(viewerCard, 1); - - updateSelectionDetails(QString()); - - connect(m_btnRefresh, &QPushButton::clicked, this, [this]() { m_pendingTask = loadFileList(true); }); - - connect(m_fileList, &QListWidget::currentRowChanged, this, [this](int row) { - if (row < 0 || row >= m_fileList->count()) { - m_currentLogName.clear(); - updateSelectionDetails(QString()); - m_logViewer->clear(); - setViewerPlaceholder(tr("Select a log file to inspect its contents")); - return; - } - - const QString logName = m_fileList->item(row)->data(LogNameRole).toString(); - if (logName.isEmpty()) { - return; - } - - updateSelectionDetails(logName); - m_pendingTask = loadLogContent(logName); - }); -} - -void PageLogs::showEvent(QShowEvent* event) { - ManagePageBase::showEvent(event); - if (!m_loaded) { - m_pendingTask = loadFileList(); - m_loaded = true; - } -} - -QString PageLogs::currentSelectedLogName() const { - if (!m_fileList || !m_fileList->currentItem()) { - return {}; - } - return m_fileList->currentItem()->data(LogNameRole).toString(); -} - -QString PageLogs::rebuildFileList(const QString& preferredLogName) { - QSignalBlocker blocker(m_fileList); - m_fileList->clear(); - - int selectedRow = -1; - for (int i = 0; i < m_logFiles.size(); ++i) { - const LogFileInfo& logFile = m_logFiles.at(i); - - auto* item = new QListWidgetItem(m_fileList); - item->setData(LogNameRole, logFile.name); - item->setData(LogModifiedDisplayRole, formatCompactDateTime(logFile.dateModified)); - item->setText(logFile.name); - item->setToolTip(logFile.name); - item->setSizeHint(QSize(0, 34)); - - if (logFile.name == preferredLogName) { - selectedRow = i; - } - } - - const bool hasFiles = !m_logFiles.isEmpty(); - m_fileList->setVisible(hasFiles); - m_emptyLabel->setVisible(!hasFiles); - - if (!hasFiles) { - m_fileList->setCurrentRow(-1); - m_currentLogName.clear(); - updateSelectionDetails(QString()); - return {}; - } - - if (selectedRow < 0) { - selectedRow = 0; - } - - m_fileList->setCurrentRow(selectedRow); - const QString selectedLogName = m_logFiles.at(selectedRow).name; - updateSelectionDetails(selectedLogName); - return selectedLogName; -} - -void PageLogs::updateSelectionDetails(const QString& logName) { - m_currentLogName = logName; - - const LogFileInfo* selectedInfo = nullptr; - for (const LogFileInfo& logFile : m_logFiles) { - if (logFile.name == logName) { - selectedInfo = &logFile; - break; - } - } - - if (!selectedInfo) { - m_currentFileNameLabel->setFullText(tr("No log file selected")); - m_currentFileMetaLabel->setText(tr("Choose a log file from the list to inspect the latest server output.")); - return; - } - - m_currentFileNameLabel->setFullText(selectedInfo->name); - - QStringList metaParts; - metaParts.append(selectedInfo->readableSize()); - const QString modifiedText = formatIsoDateTime(selectedInfo->dateModified); - if (!modifiedText.isEmpty()) { - metaParts.append(tr("Modified %1").arg(modifiedText)); - } - m_currentFileMetaLabel->setText(metaParts.join(QStringLiteral(" · "))); -} - -void PageLogs::setViewerPlaceholder(const QString& text) { - m_viewerPlaceholder->setText(text); - m_viewerPlaceholder->show(); - m_logViewer->hide(); -} - -QCoro::Task PageLogs::loadFileList(bool isManual) { - QPointer safeThis(this); - - if (m_isLoading && !isManual) { - co_return; - } - - m_isLoading = true; - m_btnRefresh->setEnabled(false); - m_fileList->setEnabled(false); - - if (m_logFiles.isEmpty()) { - m_statsLabel->setText(tr("Loading...")); - setViewerPlaceholder(tr("Loading...")); - } - - if (isManual) { - RefreshSpin::start(m_btnRefresh); - } - - try { - QList logFiles = co_await m_core->adminService()->getLogFiles(); - if (!safeThis) { - co_return; - } - - std::sort(logFiles.begin(), logFiles.end(), [](const LogFileInfo& lhs, const LogFileInfo& rhs) { - const QDateTime lhsModified = parseIsoDateTime(lhs.dateModified); - const QDateTime rhsModified = parseIsoDateTime(rhs.dateModified); - - if (lhsModified.isValid() != rhsModified.isValid()) { - return lhsModified.isValid(); - } - if (lhsModified.isValid() && lhsModified != rhsModified) { - return lhsModified > rhsModified; - } - return QString::localeAwareCompare(lhs.name, rhs.name) < 0; - }); - - qDebug() << "[PageLogs] Loaded log files" - << "| count=" << logFiles.size(); - - const QString currentSelectedLog = currentSelectedLogName(); - const QString preferredLogName = currentSelectedLog.isEmpty() ? m_currentLogName : currentSelectedLog; - m_logFiles = logFiles; - m_statsLabel->setText(m_logFiles.isEmpty() ? tr("No files") : tr("%n file(s)", "", m_logFiles.size())); - - const QString selectedLogName = rebuildFileList(preferredLogName); - if (selectedLogName.isEmpty()) { - m_logViewer->clear(); - setViewerPlaceholder(tr("No log files found")); - } else { - co_await loadLogContent(selectedLogName); - if (!safeThis) { - co_return; - } - } - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - - if (m_logFiles.isEmpty()) { - m_statsLabel->setText(tr("No files")); - setViewerPlaceholder(tr("Failed to load: %1").arg(QString::fromUtf8(e.what()))); - } - ModernToast::showMessage(tr("Failed to load log files: %1").arg(QString::fromUtf8(e.what())), 3000); - } - - if (!safeThis) { - co_return; - } - - m_isLoading = false; - m_btnRefresh->setEnabled(true); - m_fileList->setEnabled(true); -} - -QCoro::Task PageLogs::loadLogContent(QString logName) { - QPointer safeThis(this); - - m_currentLogName = logName; - updateSelectionDetails(logName); - - qDebug() << "[PageLogs] Loading log content" - << "| logName=" << logName; - - setViewerPlaceholder(tr("Loading...")); - - try { - const QString content = co_await m_core->adminService()->getLogFileContent(logName); - if (!safeThis) { - co_return; - } - - if (m_currentLogName != logName) { - qDebug() << "[PageLogs] Ignoring stale log content" - << "| requested=" << logName - << "| current=" << m_currentLogName; - co_return; - } - - qDebug() << "[PageLogs] Log content loaded" - << "| logName=" << logName - << "| contentLength=" << content.length(); - - m_logViewer->setPlainText(content); - m_viewerPlaceholder->hide(); - m_logViewer->show(); - - if (QScrollBar* verticalBar = m_logViewer->verticalScrollBar()) { - verticalBar->setValue(verticalBar->maximum()); - } - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - - if (m_currentLogName != logName) { - co_return; - } - - setViewerPlaceholder(tr("Failed to load: %1").arg(QString::fromUtf8(e.what()))); - } -} - -QCoro::Task PageLogs::downloadLogFile(QString logName) { - QPointer safeThis(this); - - QString defaultDirectory = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation); - if (defaultDirectory.isEmpty()) { - defaultDirectory = QDir::homePath(); - } - - const QString suggestedPath = QDir(defaultDirectory).filePath(logName); - const QString savePath = QFileDialog::getSaveFileName(this, tr("Save Log File"), suggestedPath, - tr("Text Files (*.txt);;All Files (*)")); - if (savePath.isEmpty()) { - co_return; - } - - qDebug() << "[PageLogs] Saving log file" - << "| logName=" << logName - << "| savePath=" << savePath; - - try { - const QString content = co_await m_core->adminService()->getLogFileContent(logName); - if (!safeThis) { - co_return; - } - - QSaveFile saveFile(savePath); - if (!saveFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - throw std::runtime_error(saveFile.errorString().toStdString()); - } - - const QByteArray data = content.toUtf8(); - if (saveFile.write(data) != data.size()) { - throw std::runtime_error(saveFile.errorString().toStdString()); - } - - if (!saveFile.commit()) { - throw std::runtime_error(saveFile.errorString().toStdString()); - } - - ModernToast::showMessage(tr("Log file \"%1\" saved").arg(QFileInfo(savePath).fileName()), 2200); - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - - ModernToast::showMessage(tr("Failed to save log file: %1").arg(QString::fromUtf8(e.what())), 3200); - } +void PageLogs::hideEvent(QHideEvent *event) { + m_viewModel->setActive(false); + ManagePageBase::hideEvent(event); } diff --git a/src/qEmbyApp/views/admin/pagelogs.h b/src/qEmbyApp/views/admin/pagelogs.h index d8ccf25..c44f9eb 100644 --- a/src/qEmbyApp/views/admin/pagelogs.h +++ b/src/qEmbyApp/views/admin/pagelogs.h @@ -2,52 +2,28 @@ #define PAGELOGS_H #include "managepagebase.h" -#include -#include -#include -class ElidedLabel; -class QLabel; -class QListWidget; -class QPlainTextEdit; -class QPushButton; +class LogsViewModel; +class QHideEvent; +class QScrollArea; class QShowEvent; +class QuickPageHost; +// Compatibility shell retained until ManageView itself moves to QML. class PageLogs : public ManagePageBase { - Q_OBJECT + Q_OBJECT public: - explicit PageLogs(QEmbyCore* core, QWidget* parent = nullptr); + explicit PageLogs(QEmbyCore *core, QWidget *parent = nullptr); + QScrollArea *scrollArea() const { return m_scrollArea; } protected: - void showEvent(QShowEvent* event) override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; private: - QCoro::Task loadFileList(bool isManual = false); - QCoro::Task loadLogContent(QString logName); - QCoro::Task downloadLogFile(QString logName); - QString rebuildFileList(const QString& preferredLogName); - void updateSelectionDetails(const QString& logName); - void setViewerPlaceholder(const QString& text); - QString currentSelectedLogName() const; - - QPushButton* m_btnRefresh = nullptr; - QLabel* m_statsLabel = nullptr; - - QListWidget* m_fileList = nullptr; - QPlainTextEdit* m_logViewer = nullptr; - QLabel* m_emptyLabel = nullptr; - QLabel* m_viewerPlaceholder = nullptr; - ElidedLabel* m_currentFileNameLabel = nullptr; - QLabel* m_currentFileMetaLabel = nullptr; - - QList m_logFiles; - QString m_currentLogName; - - bool m_loaded = false; - bool m_isLoading = false; - - std::optional> m_pendingTask; - std::optional> m_pendingDownloadTask; + QuickPageHost *m_quickHost = nullptr; + LogsViewModel *m_viewModel = nullptr; + QScrollArea *m_scrollArea = nullptr; }; -#endif +#endif // PAGELOGS_H diff --git a/src/qEmbyApp/views/admin/pagetasks.cpp b/src/qEmbyApp/views/admin/pagetasks.cpp index 7dcb27c..cd97126 100644 --- a/src/qEmbyApp/views/admin/pagetasks.cpp +++ b/src/qEmbyApp/views/admin/pagetasks.cpp @@ -1,579 +1,50 @@ #include "pagetasks.h" -#include "../../components/moderntoast.h" #include "../../components/taskeditdialog.h" -#include "../../utils/layoututils.h" -#include "../../utils/taskutils.h" -#include -#include +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "../../quick/tasksviewmodel.h" -#include -#include -#include -#include -#include -#include -#include -#include +#include #include #include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" +#include -#include +PageTasks::PageTasks(QEmbyCore *core, QWidget *parent) + : ManagePageBase(core, QString(), parent) { + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); -namespace -{ + m_quickHost = new QuickPageHost(this); + m_viewModel = new TasksViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("tasksPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminTasksPage.qml"))); + m_mainLayout->addWidget(m_quickHost); -constexpr int AutoRefreshIntervalMs = 3000; + connect(m_viewModel, &TasksViewModel::editRequested, this, + [this](const QString &taskId, const QString &taskName) { + auto *dialog = new TaskEditDialog(m_core, taskId, taskName, this); + connect(dialog, &TaskEditDialog::triggersUpdated, m_viewModel, + &TasksViewModel::refresh); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); + }); -} - - - - - -PageTasks::PageTasks(QEmbyCore *core, QWidget *parent) : ManagePageBase(core, tr("Tasks"), parent) -{ - m_mainLayout->setContentsMargins(16, 30, 0, 0); - - auto *titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, 25, 0); - titleRow->setSpacing(8); - - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addStretch(2); - - m_statsLabel = new QLabel(this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); - - m_scrollContentWidget = new QWidget(this); - m_scrollContentWidget->setAttribute(Qt::WA_StyledBackground, true); - m_scrollContentLayout = new QVBoxLayout(m_scrollContentWidget); - m_scrollContentLayout->setContentsMargins(0, 0, 12, 40); - m_scrollContentLayout->setSpacing(16); - - auto *summaryGrid = new QGridLayout(); - summaryGrid->setContentsMargins(0, 0, 0, 0); - summaryGrid->setHorizontalSpacing(10); - summaryGrid->setVerticalSpacing(10); - summaryGrid->addWidget(createSummaryCard(tr("All Tasks"), m_totalValueLabel, m_scrollContentWidget), 0, 0); - summaryGrid->addWidget(createSummaryCard(tr("Scheduled"), m_scheduledValueLabel, m_scrollContentWidget), 0, 1); - summaryGrid->addWidget(createSummaryCard(tr("Running"), m_runningValueLabel, m_scrollContentWidget), 0, 2); - summaryGrid->addWidget(createSummaryCard(tr("Manual"), m_manualValueLabel, m_scrollContentWidget), 0, 3); - summaryGrid->setColumnStretch(0, 1); - summaryGrid->setColumnStretch(1, 1); - summaryGrid->setColumnStretch(2, 1); - summaryGrid->setColumnStretch(3, 1); - m_scrollContentLayout->addLayout(summaryGrid); - - m_taskSectionsLayout = new QVBoxLayout(); - m_taskSectionsLayout->setContentsMargins(0, 0, 0, 0); - m_taskSectionsLayout->setSpacing(16); - m_scrollContentLayout->addLayout(m_taskSectionsLayout); - - m_emptyStateContainer = new QWidget(m_scrollContentWidget); - m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - auto *emptyLayout = new QVBoxLayout(m_emptyStateContainer); - emptyLayout->setContentsMargins(0, 20, 0, 0); - emptyLayout->setSpacing(0); - emptyLayout->addStretch(1); - m_emptyLabel = new QLabel(tr("No tasks found"), m_emptyStateContainer); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - m_emptyStateContainer->hide(); - emptyLayout->addWidget(m_emptyLabel); - emptyLayout->addStretch(1); - m_scrollContentLayout->addWidget(m_emptyStateContainer, 1); - - m_bottomSpacer = new QWidget(m_scrollContentWidget); - m_bottomSpacer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - m_scrollContentLayout->addWidget(m_bottomSpacer, 1); - - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName("SettingsScrollArea"); - m_scrollArea->setWidget(m_scrollContentWidget); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->viewport()->setAutoFillBackground(false); - - m_mainLayout->addWidget(m_scrollArea, 1); - - m_refreshTimer = new QTimer(this); - m_refreshTimer->setInterval(AutoRefreshIntervalMs); - connect(m_refreshTimer, &QTimer::timeout, this, [this]() { m_pendingTask = loadData(false); }); - - connect(m_btnRefresh, &QPushButton::clicked, this, [this]() { m_pendingTask = loadData(true); }); + // Temporary compatibility object for ManageView::scrollArea(). It is not + // part of the visual hierarchy; AdminTasksPage owns the real scrolling. + m_scrollArea = new QScrollArea(this); + m_scrollArea->hide(); } -void PageTasks::showEvent(QShowEvent *event) -{ - ManagePageBase::showEvent(event); - if (!m_loaded) - { - m_pendingTask = loadData(); - m_loaded = true; - } - m_refreshTimer->start(); +void PageTasks::showEvent(QShowEvent *event) { + ManagePageBase::showEvent(event); + m_viewModel->setActive(true); } -void PageTasks::hideEvent(QHideEvent *event) -{ - m_refreshTimer->stop(); - ManagePageBase::hideEvent(event); -} - -QCoro::Task PageTasks::loadData(bool isManual) -{ - QPointer safeThis(this); - - if (m_isLoading && !isManual) - { - co_return; - } - - m_isLoading = true; - m_btnRefresh->setEnabled(false); - if (isManual || !m_hasLoadedData) - { - m_statsLabel->setText(tr("Loading...")); - } - - if (isManual) - { - RefreshSpin::start(m_btnRefresh); - } - - try - { - auto tasks = co_await m_core->adminService()->getScheduledTasks(); - if (!safeThis) - { - co_return; - } - - qDebug() << "[PageTasks] Loaded tasks" - << "| count=" << tasks.size(); - - std::sort(tasks.begin(), tasks.end(), - [](const ScheduledTaskInfo &lhs, const ScheduledTaskInfo &rhs) - { - if (lhs.isRunning() != rhs.isRunning()) - { - return lhs.isRunning() && !rhs.isRunning(); - } - if (lhs.isHidden != rhs.isHidden) - { - return !lhs.isHidden && rhs.isHidden; - } - - const QString leftCategory = lhs.category.isEmpty() ? QStringLiteral("~") : lhs.category; - const QString rightCategory = rhs.category.isEmpty() ? QStringLiteral("~") : rhs.category; - const int categoryCompare = QString::localeAwareCompare(leftCategory, rightCategory); - if (categoryCompare != 0) - { - return categoryCompare < 0; - } - - return QString::localeAwareCompare(lhs.name, rhs.name) < 0; - }); - - updateSummaryCards(tasks); - rebuildTaskSections(tasks); - - if (tasks.isEmpty()) - { - m_emptyStateContainer->show(); - m_bottomSpacer->hide(); - m_statsLabel->setText(tr("No tasks")); - } - else - { - m_emptyStateContainer->hide(); - m_bottomSpacer->show(); - } - m_hasLoadedData = true; - } - catch (const std::exception &e) - { - if (!safeThis) - { - co_return; - } - if (isManual || !m_hasLoadedData) - { - m_statsLabel->setText(tr("Refresh failed")); - } - ModernToast::showMessage(tr("Failed to load tasks: %1").arg(QString::fromUtf8(e.what())), 3000); - } - - if (!safeThis) - { - co_return; - } - - m_isLoading = false; - m_btnRefresh->setEnabled(true); -} - -QCoro::Task PageTasks::onRunClicked(QString taskId, QString taskName) -{ - QPointer safeThis(this); - - qDebug() << "[PageTasks] Running task" - << "| taskId=" << taskId << "| taskName=" << taskName; - - try - { - co_await m_core->adminService()->runScheduledTask(taskId); - if (!safeThis) - { - co_return; - } - ModernToast::showMessage(tr("Task \"%1\" started").arg(taskName), 2000); - co_await loadData(false); - } - catch (const std::exception &e) - { - if (!safeThis) - { - co_return; - } - ModernToast::showMessage(tr("Failed to run task: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageTasks::onStopClicked(QString taskId, QString taskName) -{ - QPointer safeThis(this); - - qDebug() << "[PageTasks] Stopping task" - << "| taskId=" << taskId << "| taskName=" << taskName; - - try - { - co_await m_core->adminService()->stopScheduledTask(taskId); - if (!safeThis) - { - co_return; - } - ModernToast::showMessage(tr("Task \"%1\" stopped").arg(taskName), 2000); - co_await loadData(false); - } - catch (const std::exception &e) - { - if (!safeThis) - { - co_return; - } - ModernToast::showMessage(tr("Failed to stop task: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -void PageTasks::onCardClicked(const QString &taskId, const QString &taskName) -{ - auto *dialog = new TaskEditDialog(m_core, taskId, taskName, this); - connect(dialog, &TaskEditDialog::triggersUpdated, this, [this]() { m_pendingTask = loadData(false); }); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); -} - -QWidget *PageTasks::createSummaryCard(const QString &title, QLabel *&valueLabel, QWidget *parent) -{ - auto *card = new QFrame(parent); - card->setObjectName("ManageInfoCard"); - card->setAttribute(Qt::WA_StyledBackground, true); - card->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - - auto *layout = new QVBoxLayout(card); - layout->setContentsMargins(14, 10, 14, 10); - layout->setSpacing(4); - - valueLabel = new QLabel(QStringLiteral("0"), card); - valueLabel->setObjectName("TaskSummaryValue"); - layout->addWidget(valueLabel); - - auto *titleLabel = new QLabel(title, card); - titleLabel->setObjectName("TaskSummaryCaption"); - titleLabel->setWordWrap(false); - layout->addWidget(titleLabel); - layout->addStretch(1); - - return card; -} - -void PageTasks::clearTaskSections() -{ - LayoutUtils::clearLayout(m_taskSectionsLayout); -} - -void PageTasks::updateSummaryCards(const QList &tasks) -{ - int runningCount = 0; - int scheduledCount = 0; - int manualCount = 0; - int systemCount = 0; - - for (const ScheduledTaskInfo &task : tasks) - { - if (task.isRunning()) - { - ++runningCount; - } - - if (task.isHidden) - { - ++systemCount; - } - else if (task.hasTriggers()) - { - ++scheduledCount; - } - else - { - ++manualCount; - } - } - - m_totalValueLabel->setText(QString::number(tasks.size())); - m_runningValueLabel->setText(QString::number(runningCount)); - m_scheduledValueLabel->setText(QString::number(scheduledCount)); - m_manualValueLabel->setText(QString::number(manualCount)); - - if (tasks.isEmpty()) - { - m_statsLabel->setText(tr("No tasks")); - return; - } - - QStringList parts; - parts.append(tr("%n task(s)", "", tasks.size())); - if (runningCount > 0) - { - parts.append(tr("%n running", "", runningCount)); - } - if (systemCount > 0) - { - parts.append(tr("%n system", "", systemCount)); - } - m_statsLabel->setText(parts.join(QStringLiteral(" · "))); -} - -void PageTasks::rebuildTaskSections(const QList &tasks) -{ - clearTaskSections(); - - QList runningTasks; - QList scheduledTasks; - QList manualTasks; - QList systemTasks; - - for (const ScheduledTaskInfo &task : tasks) - { - if (task.isRunning()) - { - runningTasks.append(task); - } - else if (task.isHidden) - { - systemTasks.append(task); - } - else if (task.hasTriggers()) - { - scheduledTasks.append(task); - } - else - { - manualTasks.append(task); - } - } - - if (!runningTasks.isEmpty()) - { - addTaskSection(tr("Running Now"), tr("Tasks that are currently executing. Progress refreshes automatically."), - runningTasks); - } - if (!scheduledTasks.isEmpty()) - { - addTaskSection(tr("Scheduled Tasks"), tr("Tasks with one or more configured triggers."), scheduledTasks); - } - if (!manualTasks.isEmpty()) - { - addTaskSection(tr("Manual Tasks"), tr("Tasks that only run when started manually."), manualTasks); - } - if (!systemTasks.isEmpty()) - { - addTaskSection(tr("System Tasks"), tr("Hidden or built-in tasks reported by the server."), systemTasks); - } - - m_emptyStateContainer->setVisible(tasks.isEmpty()); -} - -void PageTasks::addTaskSection(const QString &title, const QString &subtitle, const QList &tasks) -{ - auto *sectionWidget = new QWidget(m_scrollContentWidget); - auto *sectionLayout = new QVBoxLayout(sectionWidget); - sectionLayout->setContentsMargins(0, 0, 0, 0); - sectionLayout->setSpacing(10); - - auto *titleLabel = new QLabel(title, sectionWidget); - titleLabel->setObjectName("ManageCardTitle"); - sectionLayout->addWidget(titleLabel); - - auto *subtitleLabel = new QLabel(subtitle, sectionWidget); - subtitleLabel->setObjectName("ManageInfoKey"); - subtitleLabel->setWordWrap(true); - sectionLayout->addWidget(subtitleLabel); - - auto *cardsLayout = new QVBoxLayout(); - cardsLayout->setContentsMargins(0, 0, 0, 0); - cardsLayout->setSpacing(8); - for (const ScheduledTaskInfo &task : tasks) - { - addTaskCard(task, cardsLayout); - } - sectionLayout->addLayout(cardsLayout); - - m_taskSectionsLayout->addWidget(sectionWidget); -} - -void PageTasks::addTaskCard(const ScheduledTaskInfo &task, QVBoxLayout *parentLayout) -{ - auto *card = new QFrame(m_scrollContentWidget); - card->setObjectName("ManageInfoCard"); - card->setAttribute(Qt::WA_StyledBackground, true); - - auto *cardLayout = new QVBoxLayout(card); - cardLayout->setContentsMargins(14, 12, 14, 12); - cardLayout->setSpacing(6); - - auto *topRow = new QHBoxLayout(); - topRow->setContentsMargins(0, 0, 0, 0); - topRow->setSpacing(10); - - auto *titleColumn = new QVBoxLayout(); - titleColumn->setContentsMargins(0, 0, 0, 0); - titleColumn->setSpacing(4); - - auto *nameLabel = new QLabel(task.name, card); - nameLabel->setObjectName("ManageCardTitle"); - nameLabel->setWordWrap(true); - titleColumn->addWidget(nameLabel); - - auto *badgeRow = new QHBoxLayout(); - badgeRow->setContentsMargins(0, 0, 0, 0); - badgeRow->setSpacing(5); - - auto *categoryBadge = new QLabel(task.category.isEmpty() ? tr("Other") : task.category, card); - categoryBadge->setObjectName("TaskMetaBadge"); - badgeRow->addWidget(categoryBadge); - - auto *modeBadge = - new QLabel(task.isHidden ? tr("System") : (task.hasTriggers() ? tr("Scheduled") : tr("Manual")), card); - modeBadge->setObjectName(task.isHidden ? "TaskMutedBadge" : "TaskMetaBadge"); - badgeRow->addWidget(modeBadge); - badgeRow->addStretch(1); - titleColumn->addLayout(badgeRow); - - topRow->addLayout(titleColumn, 1); - - auto *buttonRow = new QHBoxLayout(); - buttonRow->setContentsMargins(0, 0, 0, 0); - buttonRow->setSpacing(6); - - auto *detailsBtn = new QPushButton(card); - detailsBtn->setObjectName("ManageTaskDetailsBtn"); - detailsBtn->setCursor(Qt::PointingHandCursor); - detailsBtn->setFixedSize(32, 32); - detailsBtn->setToolTip(tr("Details")); - connect(detailsBtn, &QPushButton::clicked, this, [this, task]() { onCardClicked(task.id, task.name); }); - buttonRow->addWidget(detailsBtn); - - auto *actionBtn = new QPushButton(card); - actionBtn->setFixedSize(32, 32); - actionBtn->setCursor(Qt::PointingHandCursor); - if (task.isRunning()) - { - actionBtn->setObjectName("ManageTaskStopBtn"); - actionBtn->setToolTip(tr("Stop task")); - connect(actionBtn, &QPushButton::clicked, this, - [this, task]() { m_pendingTask = onStopClicked(task.id, task.name); }); - } - else - { - actionBtn->setObjectName("ManageTaskRunBtn"); - actionBtn->setToolTip(tr("Run task")); - connect(actionBtn, &QPushButton::clicked, this, - [this, task]() { m_pendingTask = onRunClicked(task.id, task.name); }); - } - buttonRow->addWidget(actionBtn); - - topRow->addLayout(buttonRow); - cardLayout->addLayout(topRow); - - if (!task.description.isEmpty()) - { - auto *descLabel = new QLabel(task.description, card); - descLabel->setObjectName("ManageInfoKey"); - descLabel->setWordWrap(true); - cardLayout->addWidget(descLabel); - } - - auto *scheduleLabel = new QLabel(tr("Schedule: %1").arg(TaskUtils::formatTriggerSummary(task.triggers, 2)), card); - scheduleLabel->setObjectName("ManageInfoValue"); - scheduleLabel->setWordWrap(true); - cardLayout->addWidget(scheduleLabel); - - QStringList statusParts; - statusParts.append(tr("State: %1").arg(TaskUtils::localizedTaskState(task.state))); - if (!task.lastExecutionResult.isEmpty()) - { - QString lastResult = tr("Last result: %1").arg(TaskUtils::localizedTaskResult(task.lastExecutionResult)); - if (!task.lastEndTime.isEmpty()) - { - lastResult += QStringLiteral(" · %1").arg(TaskUtils::formatIsoDateTime(task.lastEndTime)); - } - statusParts.append(lastResult); - } - - auto *statusLabel = new QLabel(statusParts.join(QStringLiteral(" · ")), card); - statusLabel->setObjectName("ManageInfoValue"); - statusLabel->setWordWrap(true); - cardLayout->addWidget(statusLabel); - - if (task.isRunning()) - { - auto *progressBar = new QProgressBar(card); - progressBar->setObjectName("ManageTaskProgress"); - progressBar->setRange(0, 100); - progressBar->setValue(static_cast(task.currentProgressPercentage)); - progressBar->setTextVisible(false); - progressBar->setFixedHeight(4); - cardLayout->addWidget(progressBar); - - auto *progressLabel = new QLabel(tr("%1 · %2%") - .arg(TaskUtils::localizedTaskState(task.state)) - .arg(task.currentProgressPercentage, 0, 'f', 1), - card); - progressLabel->setObjectName("ManageInfoKey"); - cardLayout->addWidget(progressLabel); - } - - parentLayout->addWidget(card); +void PageTasks::hideEvent(QHideEvent *event) { + m_viewModel->setActive(false); + ManagePageBase::hideEvent(event); } diff --git a/src/qEmbyApp/views/admin/pagetasks.h b/src/qEmbyApp/views/admin/pagetasks.h index 5049e8f..0e281df 100644 --- a/src/qEmbyApp/views/admin/pagetasks.h +++ b/src/qEmbyApp/views/admin/pagetasks.h @@ -2,66 +2,32 @@ #define PAGETASKS_H #include "managepagebase.h" -#include -#include -#include -class QLabel; -class QPushButton; -class QScrollArea; class QHideEvent; +class QScrollArea; class QShowEvent; -class QTimer; -class QVBoxLayout; -class QWidget; +class QuickPageHost; +class TasksViewModel; +// Compatibility shell kept for ManageView while the page body lives in QML. class PageTasks : public ManagePageBase { - Q_OBJECT + Q_OBJECT public: - explicit PageTasks(QEmbyCore* core, QWidget* parent = nullptr); - QScrollArea* scrollArea() const { return m_scrollArea; } + explicit PageTasks(QEmbyCore *core, QWidget *parent = nullptr); + + // ManageView still registers every legacy page's scroll area for wheel + // animation. The QML page scrolls itself; this hidden object only preserves + // that construction-time contract until ManageView is migrated. + QScrollArea *scrollArea() const { return m_scrollArea; } protected: - void showEvent(QShowEvent* event) override; - void hideEvent(QHideEvent* event) override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; private: - QCoro::Task loadData(bool isManual = false); - QCoro::Task onRunClicked(QString taskId, QString taskName); - QCoro::Task onStopClicked(QString taskId, QString taskName); - void onCardClicked(const QString& taskId, const QString& taskName); - QWidget* createSummaryCard(const QString& title, - QLabel*& valueLabel, - QWidget* parent); - void clearTaskSections(); - void updateSummaryCards(const QList& tasks); - void rebuildTaskSections(const QList& tasks); - void addTaskSection(const QString& title, - const QString& subtitle, - const QList& tasks); - void addTaskCard(const ScheduledTaskInfo& task, QVBoxLayout* parentLayout); - - QPushButton* m_btnRefresh = nullptr; - QLabel* m_statsLabel = nullptr; - QScrollArea* m_scrollArea = nullptr; - QWidget* m_scrollContentWidget = nullptr; - QVBoxLayout* m_scrollContentLayout = nullptr; - QVBoxLayout* m_taskSectionsLayout = nullptr; - - QWidget* m_emptyStateContainer = nullptr; - QLabel* m_emptyLabel = nullptr; - QWidget* m_bottomSpacer = nullptr; - QLabel* m_totalValueLabel = nullptr; - QLabel* m_runningValueLabel = nullptr; - QLabel* m_scheduledValueLabel = nullptr; - QLabel* m_manualValueLabel = nullptr; - - QTimer* m_refreshTimer = nullptr; - bool m_loaded = false; - bool m_hasLoadedData = false; - bool m_isLoading = false; - - std::optional> m_pendingTask; + QuickPageHost *m_quickHost = nullptr; + TasksViewModel *m_viewModel = nullptr; + QScrollArea *m_scrollArea = nullptr; }; -#endif +#endif // PAGETASKS_H diff --git a/src/qEmbyApp/views/admin/pagetranscoding.cpp b/src/qEmbyApp/views/admin/pagetranscoding.cpp index 788f8e0..96d3d16 100644 --- a/src/qEmbyApp/views/admin/pagetranscoding.cpp +++ b/src/qEmbyApp/views/admin/pagetranscoding.cpp @@ -2,2488 +2,75 @@ #include "../../components/embycodecinfodialog.h" #include "../../components/embycodecparametersdialog.h" -#include "../../components/elidedlabel.h" -#include "../../components/flowlayout.h" -#include "../../components/moderncombobox.h" -#include "../../components/moderntaginput.h" -#include "../../components/moderntoast.h" -#include "../../components/modernswitch.h" -#include "../../components/transcodingcodeclistwidget.h" -#include "../../components/transcodingcodecrowwidget.h" -#include "../../utils/layoututils.h" -#include -#include -#include +#include "../../quick/quickpagehost.h" +#include "transcodingviewmodel.h" -#include -#include -#include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" -#include -#include -#include +#include -PageTranscoding::PageTranscoding(QEmbyCore* core, QWidget* parent) - : ManagePageBase(core, tr("Transcoding"), parent) { - if (m_core && m_core->serverManager()) { - m_isJellyfin = - m_core->serverManager()->activeProfile().type == ServerProfile::Jellyfin; - } +PageTranscoding::PageTranscoding(QEmbyCore *core, QWidget *parent) + : ManagePageBase(core, QString(), parent) { + setObjectName(QStringLiteral("admin-transcoding-page")); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); - m_mainLayout->setContentsMargins(16, 30, 0, 0); + // ManageView uses this viewport for wheel-event coordination. QML owns the + // actual scrolling, so the compatibility scroll bars remain disabled. + m_scrollArea = new QScrollArea(this); + m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); + m_scrollArea->setWidgetResizable(true); + m_scrollArea->setFrameShape(QFrame::NoFrame); + m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setupToolbar(); - setupContentArea(); - buildPageLayout(); - connectInputs(); + m_quickHost = new QuickPageHost(m_scrollArea); + m_viewModel = new TranscodingViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("transcodingPage"), + m_viewModel); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminTranscodingPage.qml"))); + m_scrollArea->setWidget(m_quickHost); + m_mainLayout->addWidget(m_scrollArea, 1); - m_contentContainer->hide(); + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this] { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this] { m_viewModel->setActive(false); }); + connect(m_viewModel, &TranscodingViewModel::pathPickerRequested, this, + &PageTranscoding::choosePath); + connect(m_viewModel, &TranscodingViewModel::codecInfoRequested, this, + &PageTranscoding::showCodecInfo); + connect(m_viewModel, &TranscodingViewModel::codecParametersRequested, this, + &PageTranscoding::showCodecParameters); } -void PageTranscoding::setupToolbar() { - auto* titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, 20, 0); - titleRow->setSpacing(6); - - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addSpacing(6); - - m_btnSave = new QPushButton(this); - m_btnSave->setObjectName("ManageSaveBtn"); - m_btnSave->setCursor(Qt::PointingHandCursor); - m_btnSave->setFixedSize(32, 32); - m_btnSave->setToolTip(tr("Save")); - m_btnSave->setEnabled(false); - titleRow->addWidget(m_btnSave); - titleRow->addStretch(1); - - m_statsLabel = new QLabel(tr("Loading..."), this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); +void PageTranscoding::choosePath(const QString &key, bool directory) { + const QString initial = m_viewModel->value(key).toString(); + const QString selected = + directory + ? QFileDialog::getExistingDirectory(this, tr("Select Folder"), + initial) + : QFileDialog::getOpenFileName(this, tr("Select File"), initial); + if (!selected.isEmpty()) + m_viewModel->setValue(key, selected); } -void PageTranscoding::setupContentArea() { - m_scrollContentWidget = new QWidget(this); - m_scrollContentWidget->setAttribute(Qt::WA_StyledBackground, true); - - auto* scrollLayout = new QVBoxLayout(m_scrollContentWidget); - scrollLayout->setContentsMargins(0, 0, 16, 40); - scrollLayout->setSpacing(16); - - m_contentContainer = new QWidget(m_scrollContentWidget); - m_contentLayout = new QVBoxLayout(m_contentContainer); - m_contentLayout->setContentsMargins(0, 0, 0, 0); - m_contentLayout->setSpacing(16); - scrollLayout->addWidget(m_contentContainer, 0, Qt::AlignTop); - - m_emptyStateContainer = new QWidget(m_scrollContentWidget); - m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - auto* emptyLayout = new QVBoxLayout(m_emptyStateContainer); - emptyLayout->setContentsMargins(0, 24, 0, 0); - emptyLayout->addStretch(1); - - m_emptyLabel = - new QLabel(tr("No transcoding configuration available"), m_emptyStateContainer); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - emptyLayout->addWidget(m_emptyLabel); - emptyLayout->addStretch(1); - m_emptyStateContainer->hide(); - scrollLayout->addWidget(m_emptyStateContainer, 1); - - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName("SettingsScrollArea"); - m_scrollArea->setWidget(m_scrollContentWidget); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->viewport()->setAutoFillBackground(false); - m_mainLayout->addWidget(m_scrollArea, 1); +void PageTranscoding::showCodecInfo(const QString &codecId) { + const QJsonObject info = m_viewModel->codecInfo(codecId); + if (info.isEmpty()) + return; + auto *dialog = new EmbyCodecInfoDialog(info, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); } -void PageTranscoding::buildPageLayout() { - if (!m_contentLayout) { - return; - } - - if (m_isJellyfin) { - buildJellyfinLayout(m_contentLayout); - } else { - buildEmbyLayout(m_contentLayout); - } - - m_contentLayout->addStretch(1); -} - -QWidget* PageTranscoding::createSectionCard(const QString& title, - const QString& subtitle, - QVBoxLayout*& bodyLayout, - QWidget* parent) { - auto* card = new QFrame(parent); - card->setObjectName("TranscodingSectionCard"); - card->setAttribute(Qt::WA_StyledBackground, true); - - auto* layout = new QVBoxLayout(card); - layout->setContentsMargins(18, 16, 18, 18); - layout->setSpacing(14); - - auto* titleLabel = new QLabel(title, card); - titleLabel->setObjectName("TranscodingSectionTitle"); - layout->addWidget(titleLabel); - - if (!subtitle.isEmpty()) { - auto* subtitleLabel = new QLabel(subtitle, card); - subtitleLabel->setObjectName("TranscodingSectionSubtitle"); - subtitleLabel->setWordWrap(true); - layout->addWidget(subtitleLabel); - } - - bodyLayout = new QVBoxLayout(); - bodyLayout->setContentsMargins(0, 0, 0, 0); - bodyLayout->setSpacing(12); - layout->addLayout(bodyLayout); - - m_sectionCards.append(card); - return card; -} - -QWidget* PageTranscoding::createFieldBlock(const QString& title, - const QString& description, - QWidget* control, - QWidget* parent, - bool compactControl) { - auto* block = new QFrame(parent); - block->setObjectName("TranscodingFieldBlock"); - block->setAttribute(Qt::WA_StyledBackground, true); - - auto* layout = new QVBoxLayout(block); - layout->setContentsMargins(14, 12, 14, 12); - layout->setSpacing(8); - - if (description.trimmed().isEmpty()) { - auto* topRow = new QHBoxLayout(); - topRow->setContentsMargins(0, 0, 0, 0); - topRow->setSpacing(12); - - auto* titleLabel = new ElidedLabel(block); - titleLabel->setObjectName("TranscodingFieldTitle"); - titleLabel->setFullText(title); - titleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - topRow->addWidget(titleLabel, 1, Qt::AlignVCenter); - - if (control) { - topRow->addWidget(control, 0, compactControl ? Qt::AlignLeft : Qt::AlignVCenter); - } - - layout->addLayout(topRow); - return block; - } - - auto* titleLabel = new QLabel(title, block); - titleLabel->setObjectName("TranscodingFieldTitle"); - titleLabel->setWordWrap(true); - layout->addWidget(titleLabel); - - if (!description.isEmpty()) { - auto* descriptionLabel = new QLabel(description, block); - descriptionLabel->setObjectName("TranscodingFieldDescription"); - descriptionLabel->setWordWrap(true); - layout->addWidget(descriptionLabel); - } - - if (control) { - layout->addWidget(control, 0, compactControl ? Qt::AlignLeft : Qt::AlignTop); - } - - return block; -} - -QWidget* PageTranscoding::createSwitchBlock(const QString& title, - const QString& description, - ModernSwitch* control, - QWidget* parent) { - auto* block = new QFrame(parent); - block->setObjectName("TranscodingFieldBlock"); - block->setAttribute(Qt::WA_StyledBackground, true); - - auto* layout = new QHBoxLayout(block); - layout->setContentsMargins(14, 12, 14, 12); - layout->setSpacing(12); - - auto* textLayout = new QVBoxLayout(); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(4); - - if (description.trimmed().isEmpty()) { - auto* titleLabel = new ElidedLabel(block); - titleLabel->setObjectName("TranscodingFieldTitle"); - titleLabel->setFullText(title); - titleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - textLayout->addWidget(titleLabel); - } else { - auto* titleLabel = new QLabel(title, block); - titleLabel->setObjectName("TranscodingFieldTitle"); - titleLabel->setWordWrap(true); - textLayout->addWidget(titleLabel); - } - - if (!description.isEmpty()) { - auto* descriptionLabel = new QLabel(description, block); - descriptionLabel->setObjectName("TranscodingFieldDescription"); - descriptionLabel->setWordWrap(true); - textLayout->addWidget(descriptionLabel); - } - - layout->addLayout(textLayout, 1); - if (control) { - layout->addWidget(control, 0, - description.trimmed().isEmpty() ? Qt::AlignVCenter - : Qt::AlignTop); - } - - return block; -} - -QWidget* PageTranscoding::createGroupBlock(const QString& title, - const QString& description, - QVBoxLayout*& bodyLayout, - QWidget* parent) { - auto* block = new QFrame(parent); - block->setObjectName("TranscodingGroupBlock"); - block->setAttribute(Qt::WA_StyledBackground, true); - - auto* layout = new QVBoxLayout(block); - layout->setContentsMargins(14, 14, 14, 14); - layout->setSpacing(10); - - auto* titleLabel = new QLabel(title, block); - titleLabel->setObjectName("TranscodingGroupTitle"); - titleLabel->setWordWrap(true); - layout->addWidget(titleLabel); - - if (!description.isEmpty()) { - auto* descriptionLabel = new QLabel(description, block); - descriptionLabel->setObjectName("TranscodingGroupDescription"); - descriptionLabel->setWordWrap(true); - layout->addWidget(descriptionLabel); - } - - bodyLayout = new QVBoxLayout(); - bodyLayout->setContentsMargins(0, 0, 0, 0); - bodyLayout->setSpacing(10); - layout->addLayout(bodyLayout); - - return block; -} - -QLineEdit* PageTranscoding::createTextInput(int width, bool readOnly) { - auto* edit = new QLineEdit(this); - edit->setObjectName("ManageLibInput"); - edit->setMinimumWidth(width); - edit->setClearButtonEnabled(!readOnly); - edit->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - edit->setReadOnly(readOnly); - if (readOnly) { - edit->setFocusPolicy(Qt::NoFocus); - } - return edit; -} - -QSpinBox* PageTranscoding::createSpinBox(int minimum, int maximum, int singleStep, int width) { - auto* spin = new QSpinBox(this); - spin->setObjectName("ManageLibSpinBox"); - spin->setRange(minimum, maximum); - spin->setSingleStep(singleStep); - spin->setFixedWidth(width); - spin->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - return spin; -} - -QDoubleSpinBox* PageTranscoding::createDoubleSpinBox(double minimum, - double maximum, - double singleStep, - int decimals, - int width) { - auto* spin = new QDoubleSpinBox(this); - spin->setObjectName("ManageLibSpinBox"); - spin->setRange(minimum, maximum); - spin->setSingleStep(singleStep); - spin->setDecimals(decimals); - spin->setFixedWidth(width); - spin->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - return spin; -} - -ModernComboBox* PageTranscoding::createComboBox(int width) { - auto* combo = LayoutUtils::createStyledCombo(this); - combo->setFixedWidth(width); - combo->setMaxTextWidth(width - 42); - combo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - return combo; -} - -ModernTagInput* PageTranscoding::createTagInput(const QStringList& presetValues) { - auto* input = new ModernTagInput(this); - input->setFixedWidth(252); - input->setPopupMode(ModernTagInput::ForcePopup); - input->setMaxPopupHeight(240); - for (const QString& presetValue : presetValues) { - input->addPreset(presetValue, presetValue); - } - return input; -} - -void PageTranscoding::addComboOption(ModernComboBox* combo, - const QString& text, - const QVariant& value) { - if (combo) { - combo->addItem(text, value); - } -} - -void PageTranscoding::setComboCurrentValue(ModernComboBox* combo, const QVariant& value) { - if (!combo) { - return; - } - - int index = combo->findData(value); - if (index < 0) { - const QString text = value.toString().trimmed(); - if (!text.isEmpty()) { - combo->addItem(text, value); - index = combo->count() - 1; - } - } - - if (index >= 0) { - combo->setCurrentIndex(index); - } else if (combo->count() > 0) { - combo->setCurrentIndex(0); - } -} - -QVariant PageTranscoding::comboCurrentValue(ModernComboBox* combo) const { - if (!combo || combo->currentIndex() < 0) { - return {}; - } - return combo->currentData(); -} - -QStringList PageTranscoding::stringListFromJsonValue(const QJsonValue& value) const { - QStringList values; - if (value.isArray()) { - for (const QJsonValue& entry : value.toArray()) { - const QString text = entry.toString().trimmed(); - if (!text.isEmpty() && !values.contains(text)) { - values.append(text); - } - } - } else { - const QString text = value.toString().trimmed(); - if (!text.isEmpty()) { - values.append(text); - } - } - return values; -} - -QJsonArray PageTranscoding::jsonArrayFromStringList(const QStringList& values) const { - QJsonArray result; - QStringList deduplicated; - for (const QString& rawValue : values) { - const QString value = rawValue.trimmed(); - if (!value.isEmpty() && !deduplicated.contains(value)) { - deduplicated.append(value); - result.append(value); - } - } - return result; -} - -void PageTranscoding::addBlock(QVBoxLayout* body, - QWidget* sectionCard, - const QString& key, - QWidget* block, - bool actualKey) { - if (!body || !sectionCard || !block || key.isEmpty()) { - return; - } - - body->addWidget(block); - m_blocks.insert(key, block); - m_sectionBlocks[sectionCard].append(key); - if (actualKey) { - m_actualBlockKeys.insert(key); - } -} - -void PageTranscoding::setBlockVisible(const QString& key, bool visible) { - if (auto* block = m_blocks.value(key)) { - block->setVisible(visible); - } -} - -void PageTranscoding::buildJellyfinLayout(QVBoxLayout* contentLayout) { - auto createCompactDecoderSwitchBlock = - [](const QString& title, ModernSwitch* control, QWidget* parent) -> QWidget* { - auto* block = new QFrame(parent); - block->setObjectName("TranscodingFieldBlock"); - block->setAttribute(Qt::WA_StyledBackground, true); - block->setFixedWidth(128); - block->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - auto* layout = new QHBoxLayout(block); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(10); - - auto* titleLabel = new ElidedLabel(block); - titleLabel->setObjectName("TranscodingFieldTitle"); - titleLabel->setFullText(title); - titleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - layout->addWidget(titleLabel, 1, Qt::AlignVCenter); - - if (control) { - control->setParent(block); - layout->addWidget(control, 0, Qt::AlignVCenter); - } - - return block; - }; - - QVBoxLayout* hardwareBody = nullptr; - QWidget* hardwareCard = - createSectionCard(tr("Hardware Acceleration"), QString(), hardwareBody, m_contentContainer); - contentLayout->addWidget(hardwareCard); - - auto* hardwareTypeCombo = createComboBox(); - addComboOption(hardwareTypeCombo, tr("None"), QStringLiteral("none")); - addComboOption(hardwareTypeCombo, tr("AMF"), QStringLiteral("amf")); - addComboOption(hardwareTypeCombo, tr("NVENC"), QStringLiteral("nvenc")); - addComboOption(hardwareTypeCombo, tr("QSV"), QStringLiteral("qsv")); - addComboOption(hardwareTypeCombo, tr("VAAPI"), QStringLiteral("vaapi")); - addComboOption(hardwareTypeCombo, tr("RKMPP"), QStringLiteral("rkmpp")); - addComboOption(hardwareTypeCombo, tr("VideoToolbox"), QStringLiteral("videotoolbox")); - addComboOption(hardwareTypeCombo, tr("V4L2M2M"), QStringLiteral("v4l2m2m")); - m_comboBoxes.insert(QStringLiteral("HardwareAccelerationType"), hardwareTypeCombo); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("HardwareAccelerationType"), - createFieldBlock(tr("Hardware Acceleration Type"), - QString(), - hardwareTypeCombo, - hardwareCard)); - - m_lineEdits.insert(QStringLiteral("VaapiDevice"), createTextInput()); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("VaapiDevice"), - createFieldBlock(tr("VAAPI Device"), - QString(), - m_lineEdits.value(QStringLiteral("VaapiDevice")), - hardwareCard)); - - m_lineEdits.insert(QStringLiteral("QsvDevice"), createTextInput()); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("QsvDevice"), - createFieldBlock(tr("QSV Device"), - QString(), - m_lineEdits.value(QStringLiteral("QsvDevice")), - hardwareCard)); - - QVBoxLayout* decodingGroupBody = nullptr; - QWidget* decodingGroup = - createGroupBlock(tr("Hardware Decoding"), QString(), decodingGroupBody, hardwareCard); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("@jellyfin_decoding_codecs"), - decodingGroup, - false); - - auto* decodingFlow = new FlowLayout(0, 10, 10); - const QStringList decoderCodecs = knownJellyfinDecoderCodecs(); - for (const QString& codec : decoderCodecs) { - auto* codecSwitch = new ModernSwitch(decodingGroup); - codecSwitch->setCursor(Qt::PointingHandCursor); - m_jellyfinCodecSwitches.insert(codec, codecSwitch); - - QWidget* codecBlock = - createCompactDecoderSwitchBlock(jellyfinCodecLabel(codec), codecSwitch, decodingGroup); - m_jellyfinCodecBlocks.insert(codec, codecBlock); - decodingFlow->addWidget(codecBlock); - } - decodingGroupBody->addLayout(decodingFlow); - - QVBoxLayout* depthGroupBody = nullptr; - QWidget* depthGroup = - createGroupBlock(tr("10-bit Decoding"), QString(), depthGroupBody, hardwareCard); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("@jellyfin_depth_group"), - depthGroup, - false); - - m_switches.insert(QStringLiteral("EnableDecodingColorDepth10Hevc"), - new ModernSwitch(depthGroup)); - addBlock(depthGroupBody, - hardwareCard, - QStringLiteral("EnableDecodingColorDepth10Hevc"), - createSwitchBlock(tr("HEVC 10-bit"), QString(), - m_switches.value(QStringLiteral("EnableDecodingColorDepth10Hevc")), - depthGroup)); - - m_switches.insert(QStringLiteral("EnableDecodingColorDepth10Vp9"), - new ModernSwitch(depthGroup)); - addBlock(depthGroupBody, - hardwareCard, - QStringLiteral("EnableDecodingColorDepth10Vp9"), - createSwitchBlock(tr("VP9 10-bit"), QString(), - m_switches.value(QStringLiteral("EnableDecodingColorDepth10Vp9")), - depthGroup)); - - QVBoxLayout* rextGroupBody = nullptr; - QWidget* rextGroup = - createGroupBlock(tr("HEVC RExt Decoding"), QString(), rextGroupBody, hardwareCard); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("@jellyfin_rext_group"), - rextGroup, - false); - - m_switches.insert(QStringLiteral("EnableDecodingColorDepth10HevcRext"), - new ModernSwitch(rextGroup)); - addBlock(rextGroupBody, - hardwareCard, - QStringLiteral("EnableDecodingColorDepth10HevcRext"), - createSwitchBlock( - tr("HEVC RExt 8/10-bit"), - QString(), - m_switches.value(QStringLiteral("EnableDecodingColorDepth10HevcRext")), - rextGroup)); - - m_switches.insert(QStringLiteral("EnableDecodingColorDepth12HevcRext"), - new ModernSwitch(rextGroup)); - addBlock(rextGroupBody, - hardwareCard, - QStringLiteral("EnableDecodingColorDepth12HevcRext"), - createSwitchBlock( - tr("HEVC RExt 12-bit"), - QString(), - m_switches.value(QStringLiteral("EnableDecodingColorDepth12HevcRext")), - rextGroup)); - - m_switches.insert(QStringLiteral("PreferSystemNativeHwDecoder"), - new ModernSwitch(hardwareCard)); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("PreferSystemNativeHwDecoder"), - createSwitchBlock(tr("Prefer System Native Decoder"), - QString(), - m_switches.value(QStringLiteral("PreferSystemNativeHwDecoder")), - hardwareCard)); - - m_switches.insert(QStringLiteral("EnableEnhancedNvdecDecoder"), - new ModernSwitch(hardwareCard)); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("EnableEnhancedNvdecDecoder"), - createSwitchBlock(tr("Enhanced NVDEC"), - QString(), - m_switches.value(QStringLiteral("EnableEnhancedNvdecDecoder")), - hardwareCard)); - - QVBoxLayout* encodingGroupBody = nullptr; - QWidget* encodingGroup = - createGroupBlock(tr("Hardware Encoding"), QString(), encodingGroupBody, hardwareCard); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("@jellyfin_hardware_encoding_group"), - encodingGroup, - false); - - m_switches.insert(QStringLiteral("EnableHardwareEncoding"), - new ModernSwitch(encodingGroup)); - addBlock(encodingGroupBody, - hardwareCard, - QStringLiteral("EnableHardwareEncoding"), - createSwitchBlock(tr("Enable Hardware Encoding"), - QString(), - m_switches.value(QStringLiteral("EnableHardwareEncoding")), - encodingGroup)); - - m_switches.insert(QStringLiteral("EnableIntelLowPowerH264HwEncoder"), - new ModernSwitch(encodingGroup)); - addBlock( - encodingGroupBody, - hardwareCard, - QStringLiteral("EnableIntelLowPowerH264HwEncoder"), - createSwitchBlock(tr("Intel Low Power H.264"), - QString(), - m_switches.value(QStringLiteral("EnableIntelLowPowerH264HwEncoder")), - encodingGroup)); - - m_switches.insert(QStringLiteral("EnableIntelLowPowerHevcHwEncoder"), - new ModernSwitch(encodingGroup)); - addBlock( - encodingGroupBody, - hardwareCard, - QStringLiteral("EnableIntelLowPowerHevcHwEncoder"), - createSwitchBlock(tr("Intel Low Power HEVC"), - QString(), - m_switches.value(QStringLiteral("EnableIntelLowPowerHevcHwEncoder")), - encodingGroup)); - - QVBoxLayout* formatsGroupBody = nullptr; - QWidget* formatsGroup = - createGroupBlock(tr("Encoding Formats"), QString(), formatsGroupBody, hardwareCard); - addBlock(hardwareBody, - hardwareCard, - QStringLiteral("@jellyfin_encoding_formats_group"), - formatsGroup, - false); - - m_switches.insert(QStringLiteral("AllowHevcEncoding"), new ModernSwitch(formatsGroup)); - addBlock(formatsGroupBody, - hardwareCard, - QStringLiteral("AllowHevcEncoding"), - createSwitchBlock(tr("Allow HEVC Encoding"), - QString(), - m_switches.value(QStringLiteral("AllowHevcEncoding")), - formatsGroup)); - - m_switches.insert(QStringLiteral("AllowAv1Encoding"), new ModernSwitch(formatsGroup)); - addBlock(formatsGroupBody, - hardwareCard, - QStringLiteral("AllowAv1Encoding"), - createSwitchBlock(tr("Allow AV1 Encoding"), - QString(), - m_switches.value(QStringLiteral("AllowAv1Encoding")), - formatsGroup)); - - QVBoxLayout* toneBody = nullptr; - QWidget* toneCard = - createSectionCard(tr("Tone Mapping"), QString(), toneBody, m_contentContainer); - contentLayout->addWidget(toneCard); - - m_switches.insert(QStringLiteral("EnableTonemapping"), new ModernSwitch(toneCard)); - addBlock(toneBody, - toneCard, - QStringLiteral("EnableTonemapping"), - createSwitchBlock(tr("Enable Tonemapping"), - QString(), - m_switches.value(QStringLiteral("EnableTonemapping")), - toneCard)); - - m_switches.insert(QStringLiteral("EnableVppTonemapping"), new ModernSwitch(toneCard)); - addBlock(toneBody, - toneCard, - QStringLiteral("EnableVppTonemapping"), - createSwitchBlock(tr("Enable VPP Tonemapping"), - QString(), - m_switches.value(QStringLiteral("EnableVppTonemapping")), - toneCard)); - - m_switches.insert(QStringLiteral("EnableVideoToolboxTonemapping"), - new ModernSwitch(toneCard)); - addBlock(toneBody, - toneCard, - QStringLiteral("EnableVideoToolboxTonemapping"), - createSwitchBlock(tr("Enable VideoToolbox Tonemapping"), - QString(), - m_switches.value(QStringLiteral("EnableVideoToolboxTonemapping")), - toneCard)); - - auto* algorithmCombo = createComboBox(); - addComboOption(algorithmCombo, tr("None"), QStringLiteral("none")); - addComboOption(algorithmCombo, tr("BT.2390"), QStringLiteral("bt2390")); - addComboOption(algorithmCombo, tr("Hable"), QStringLiteral("hable")); - addComboOption(algorithmCombo, tr("Reinhard"), QStringLiteral("reinhard")); - addComboOption(algorithmCombo, tr("Mobius"), QStringLiteral("mobius")); - addComboOption(algorithmCombo, tr("Clip"), QStringLiteral("clip")); - addComboOption(algorithmCombo, tr("Linear"), QStringLiteral("linear")); - addComboOption(algorithmCombo, tr("Gamma"), QStringLiteral("gamma")); - m_comboBoxes.insert(QStringLiteral("TonemappingAlgorithm"), algorithmCombo); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingAlgorithm"), - createFieldBlock(tr("Algorithm"), QString(), algorithmCombo, toneCard, true)); - - auto* toneModeCombo = createComboBox(180); - addComboOption(toneModeCombo, tr("Auto"), QStringLiteral("auto")); - addComboOption(toneModeCombo, tr("MAX"), QStringLiteral("max")); - addComboOption(toneModeCombo, tr("RGB"), QStringLiteral("rgb")); - addComboOption(toneModeCombo, tr("LUM"), QStringLiteral("lum")); - addComboOption(toneModeCombo, tr("ITP"), QStringLiteral("itp")); - m_comboBoxes.insert(QStringLiteral("TonemappingMode"), toneModeCombo); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingMode"), - createFieldBlock(tr("Mode"), - QString(), - toneModeCombo, - toneCard, - true)); - - auto* toneRangeCombo = createComboBox(180); - addComboOption(toneRangeCombo, tr("Auto"), QStringLiteral("auto")); - addComboOption(toneRangeCombo, tr("TV"), QStringLiteral("tv")); - addComboOption(toneRangeCombo, tr("PC"), QStringLiteral("pc")); - m_comboBoxes.insert(QStringLiteral("TonemappingRange"), toneRangeCombo); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingRange"), - createFieldBlock(tr("Range"), - QString(), - toneRangeCombo, - toneCard, - true)); - - m_doubleSpinBoxes.insert(QStringLiteral("TonemappingDesat"), - createDoubleSpinBox(0.0, 1000000.0, 0.00001, 5)); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingDesat"), - createFieldBlock(tr("Desaturation"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("TonemappingDesat")), - toneCard, - true)); - - m_doubleSpinBoxes.insert(QStringLiteral("TonemappingPeak"), - createDoubleSpinBox(0.0, 1000000.0, 0.00001, 5)); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingPeak"), - createFieldBlock(tr("Peak"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("TonemappingPeak")), - toneCard, - true)); - - m_doubleSpinBoxes.insert(QStringLiteral("TonemappingParam"), - createDoubleSpinBox(-1000000.0, 1000000.0, 0.00001, 5)); - addBlock(toneBody, - toneCard, - QStringLiteral("TonemappingParam"), - createFieldBlock(tr("Parameter"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("TonemappingParam")), - toneCard, - true)); - - QVBoxLayout* vppBody = nullptr; - QWidget* vppGroup = - createGroupBlock(tr("VPP Tone Mapping"), QString(), vppBody, toneCard); - addBlock(toneBody, toneCard, QStringLiteral("@jellyfin_vpp_group"), vppGroup, false); - - m_doubleSpinBoxes.insert(QStringLiteral("VppTonemappingBrightness"), - createDoubleSpinBox(0.0, 100.0, 0.00001, 5)); - addBlock(vppBody, - toneCard, - QStringLiteral("VppTonemappingBrightness"), - createFieldBlock(tr("VPP Brightness"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("VppTonemappingBrightness")), - vppGroup, - true)); - - m_doubleSpinBoxes.insert(QStringLiteral("VppTonemappingContrast"), - createDoubleSpinBox(1.0, 2.0, 0.00001, 5)); - addBlock(vppBody, - toneCard, - QStringLiteral("VppTonemappingContrast"), - createFieldBlock(tr("VPP Contrast"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("VppTonemappingContrast")), - vppGroup, - true)); - - QVBoxLayout* pathsBody = nullptr; - QWidget* pathsCard = - createSectionCard(tr("Paths and Threads"), QString(), pathsBody, m_contentContainer); - contentLayout->addWidget(pathsCard); - - auto* encodingThreads = createSpinBox(-1, 256, 1, 168); - encodingThreads->setSpecialValueText(tr("Auto")); - m_spinBoxes.insert(QStringLiteral("EncodingThreadCount"), encodingThreads); - addBlock(pathsBody, - pathsCard, - QStringLiteral("EncodingThreadCount"), - createFieldBlock(tr("Encoding Threads"), - QString(), - encodingThreads, - pathsCard, - true)); - - m_lineEdits.insert(QStringLiteral("TranscodingTempPath"), createTextInput()); - addBlock(pathsBody, - pathsCard, - QStringLiteral("TranscodingTempPath"), - createFieldBlock(tr("Transcoding Temp Path"), - QString(), - m_lineEdits.value(QStringLiteral("TranscodingTempPath")), - pathsCard)); - - m_encoderAppPathDisplay = createTextInput(240, true); - addBlock(pathsBody, - pathsCard, - QStringLiteral("EncoderAppPathDisplay"), - createFieldBlock(tr("FFmpeg Path"), QString(), m_encoderAppPathDisplay, pathsCard)); - - m_lineEdits.insert(QStringLiteral("FallbackFontPath"), createTextInput()); - addBlock(pathsBody, - pathsCard, - QStringLiteral("FallbackFontPath"), - createFieldBlock(tr("Fallback Font Path"), - QString(), - m_lineEdits.value(QStringLiteral("FallbackFontPath")), - pathsCard)); - - m_switches.insert(QStringLiteral("EnableFallbackFont"), new ModernSwitch(pathsCard)); - addBlock(pathsBody, - pathsCard, - QStringLiteral("EnableFallbackFont"), - createSwitchBlock(tr("Enable Fallback Font"), - QString(), - m_switches.value(QStringLiteral("EnableFallbackFont")), - pathsCard)); - - m_spinBoxes.insert(QStringLiteral("MaxMuxingQueueSize"), createSpinBox(0, 1000000)); - addBlock(pathsBody, - pathsCard, - QStringLiteral("MaxMuxingQueueSize"), - createFieldBlock(tr("Max Muxing Queue"), - QString(), - m_spinBoxes.value(QStringLiteral("MaxMuxingQueueSize")), - pathsCard, - true)); - - QVBoxLayout* qualityBody = nullptr; - QWidget* qualityCard = - createSectionCard(tr("Quality and Processing"), QString(), qualityBody, m_contentContainer); - contentLayout->addWidget(qualityCard); - - m_switches.insert(QStringLiteral("EnableAudioVbr"), new ModernSwitch(qualityCard)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("EnableAudioVbr"), - createSwitchBlock(tr("Audio VBR"), - QString(), - m_switches.value(QStringLiteral("EnableAudioVbr")), - qualityCard)); - - auto* downMixStereoCombo = createComboBox(); - addComboOption(downMixStereoCombo, tr("None"), QStringLiteral("None")); - addComboOption(downMixStereoCombo, tr("Dave750"), QStringLiteral("Dave750")); - addComboOption(downMixStereoCombo, - tr("NightmodeDialogue"), - QStringLiteral("NightmodeDialogue")); - addComboOption(downMixStereoCombo, tr("RFC7845"), QStringLiteral("Rfc7845")); - addComboOption(downMixStereoCombo, tr("AC-4"), QStringLiteral("Ac4")); - m_comboBoxes.insert(QStringLiteral("DownMixStereoAlgorithm"), downMixStereoCombo); - addBlock(qualityBody, - qualityCard, - QStringLiteral("DownMixStereoAlgorithm"), - createFieldBlock(tr("Stereo Downmix"), - QString(), - downMixStereoCombo, - qualityCard)); - - m_doubleSpinBoxes.insert(QStringLiteral("DownMixAudioBoost"), - createDoubleSpinBox(0.5, 3.0, 0.1, 1)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("DownMixAudioBoost"), - createFieldBlock(tr("Downmix Audio Boost"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("DownMixAudioBoost")), - qualityCard, - true)); - - auto* presetCombo = createComboBox(); - addComboOption(presetCombo, tr("Auto"), QStringLiteral("auto")); - addComboOption(presetCombo, tr("Ultra Fast"), QStringLiteral("ultrafast")); - addComboOption(presetCombo, tr("Super Fast"), QStringLiteral("superfast")); - addComboOption(presetCombo, tr("Very Fast"), QStringLiteral("veryfast")); - addComboOption(presetCombo, tr("Faster"), QStringLiteral("faster")); - addComboOption(presetCombo, tr("Fast"), QStringLiteral("fast")); - addComboOption(presetCombo, tr("Medium"), QStringLiteral("medium")); - addComboOption(presetCombo, tr("Slow"), QStringLiteral("slow")); - addComboOption(presetCombo, tr("Slower"), QStringLiteral("slower")); - addComboOption(presetCombo, tr("Very Slow"), QStringLiteral("veryslow")); - m_comboBoxes.insert(QStringLiteral("EncoderPreset"), presetCombo); - addBlock(qualityBody, - qualityCard, - QStringLiteral("EncoderPreset"), - createFieldBlock(tr("Encoder Preset"), - QString(), - presetCombo, - qualityCard)); - - m_spinBoxes.insert(QStringLiteral("H264Crf"), createSpinBox(0, 51)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("H264Crf"), - createFieldBlock(tr("H.264 CRF"), - QString(), - m_spinBoxes.value(QStringLiteral("H264Crf")), - qualityCard, - true)); - - m_spinBoxes.insert(QStringLiteral("H265Crf"), createSpinBox(0, 51)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("H265Crf"), - createFieldBlock(tr("H.265 CRF"), - QString(), - m_spinBoxes.value(QStringLiteral("H265Crf")), - qualityCard, - true)); - - auto* deinterlaceCombo = createComboBox(); - addComboOption(deinterlaceCombo, tr("YADIF"), QStringLiteral("yadif")); - addComboOption(deinterlaceCombo, tr("BWDIF"), QStringLiteral("bwdif")); - m_comboBoxes.insert(QStringLiteral("DeinterlaceMethod"), deinterlaceCombo); - addBlock(qualityBody, - qualityCard, - QStringLiteral("DeinterlaceMethod"), - createFieldBlock(tr("Deinterlace Method"), - QString(), - deinterlaceCombo, - qualityCard)); - - m_switches.insert(QStringLiteral("DeinterlaceDoubleRate"), new ModernSwitch(qualityCard)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("DeinterlaceDoubleRate"), - createSwitchBlock(tr("Double-Rate Deinterlace"), - QString(), - m_switches.value(QStringLiteral("DeinterlaceDoubleRate")), - qualityCard)); - - m_switches.insert(QStringLiteral("EnableSubtitleExtraction"), - new ModernSwitch(qualityCard)); - addBlock(qualityBody, - qualityCard, - QStringLiteral("EnableSubtitleExtraction"), - createSwitchBlock(tr("Enable Subtitle Extraction"), - QString(), - m_switches.value(QStringLiteral("EnableSubtitleExtraction")), - qualityCard)); - - QVBoxLayout* throttlingBody = nullptr; - QWidget* throttlingCard = createSectionCard( - tr("Throttling and Segments"), QString(), throttlingBody, m_contentContainer); - contentLayout->addWidget(throttlingCard); - - m_switches.insert(QStringLiteral("EnableThrottling"), new ModernSwitch(throttlingCard)); - addBlock(throttlingBody, - throttlingCard, - QStringLiteral("EnableThrottling"), - createSwitchBlock(tr("Enable Throttling"), - QString(), - m_switches.value(QStringLiteral("EnableThrottling")), - throttlingCard)); - - m_spinBoxes.insert(QStringLiteral("ThrottleDelaySeconds"), createSpinBox(0, 100000)); - addBlock(throttlingBody, - throttlingCard, - QStringLiteral("ThrottleDelaySeconds"), - createFieldBlock(tr("Throttle Delay"), - QString(), - m_spinBoxes.value(QStringLiteral("ThrottleDelaySeconds")), - throttlingCard, - true)); - - m_switches.insert(QStringLiteral("EnableSegmentDeletion"), - new ModernSwitch(throttlingCard)); - addBlock(throttlingBody, - throttlingCard, - QStringLiteral("EnableSegmentDeletion"), - createSwitchBlock(tr("Enable Segment Deletion"), - QString(), - m_switches.value(QStringLiteral("EnableSegmentDeletion")), - throttlingCard)); - - m_spinBoxes.insert(QStringLiteral("SegmentKeepSeconds"), createSpinBox(0, 1000000)); - addBlock(throttlingBody, - throttlingCard, - QStringLiteral("SegmentKeepSeconds"), - createFieldBlock(tr("Segment Keep Seconds"), - QString(), - m_spinBoxes.value(QStringLiteral("SegmentKeepSeconds")), - throttlingCard, - true)); - - m_tagInputs.insert( - QStringLiteral("AllowOnDemandMetadataBasedKeyframeExtractionForExtensions"), - createTagInput({QStringLiteral("mkv"), QStringLiteral("ts"), QStringLiteral("mpg")})); - addBlock( - throttlingBody, - throttlingCard, - QStringLiteral("AllowOnDemandMetadataBasedKeyframeExtractionForExtensions"), - createFieldBlock( - tr("Metadata Keyframe Extensions"), - QString(), - m_tagInputs.value( - QStringLiteral("AllowOnDemandMetadataBasedKeyframeExtractionForExtensions")), - throttlingCard)); -} - -void PageTranscoding::buildEmbyLayout(QVBoxLayout* contentLayout) { - auto* modeCombo = createComboBox(); - addComboOption(modeCombo, tr("Yes"), 1); - addComboOption(modeCombo, tr("No"), 0); - addComboOption(modeCombo, tr("Advanced"), 2); - m_comboBoxes.insert(QStringLiteral("HardwareAccelerationMode"), modeCombo); - - auto* hardwareCard = new QFrame(m_contentContainer); - hardwareCard->setObjectName("TranscodingSectionCard"); - hardwareCard->setAttribute(Qt::WA_StyledBackground, true); - - auto* hardwareLayout = new QVBoxLayout(hardwareCard); - hardwareLayout->setContentsMargins(18, 16, 18, 18); - hardwareLayout->setSpacing(0); - - auto* hardwareRow = new QWidget(hardwareCard); - auto* hardwareRowLayout = new QHBoxLayout(hardwareRow); - hardwareRowLayout->setContentsMargins(0, 0, 0, 0); - hardwareRowLayout->setSpacing(16); - - auto* hardwareTitleLabel = new QLabel(tr("Hardware Acceleration"), hardwareRow); - hardwareTitleLabel->setObjectName("TranscodingSectionTitle"); - hardwareTitleLabel->setWordWrap(true); - hardwareRowLayout->addWidget(hardwareTitleLabel, 1); - hardwareRowLayout->addWidget(modeCombo, 0, Qt::AlignTop); - - hardwareLayout->addWidget(hardwareRow); - contentLayout->addWidget(hardwareCard); - - m_sectionCards.append(hardwareCard); - m_blocks.insert(QStringLiteral("HardwareAccelerationMode"), hardwareRow); - m_sectionBlocks[hardwareCard].append(QStringLiteral("HardwareAccelerationMode")); - m_actualBlockKeys.insert(QStringLiteral("HardwareAccelerationMode")); - - QVBoxLayout* decoderBody = nullptr; - QWidget* decoderCard = createSectionCard( - tr("Preferred Hardware Decoders"), QString(), decoderBody, m_contentContainer); - contentLayout->addWidget(decoderCard); - - auto* decoderContainer = new QWidget(decoderCard); - m_embyDecoderLayout = new QVBoxLayout(decoderContainer); - m_embyDecoderLayout->setContentsMargins(0, 0, 0, 0); - m_embyDecoderLayout->setSpacing(12); - addBlock(decoderBody, - decoderCard, - QStringLiteral("@emby_decoder_codecs"), - decoderContainer, - false); - - QVBoxLayout* encoderBody = nullptr; - QWidget* encoderCard = createSectionCard( - tr("Preferred Hardware Encoders"), QString(), encoderBody, m_contentContainer); - contentLayout->addWidget(encoderCard); - - auto* encoderContainer = new QWidget(encoderCard); - m_embyEncoderLayout = new QVBoxLayout(encoderContainer); - m_embyEncoderLayout->setContentsMargins(0, 0, 0, 0); - m_embyEncoderLayout->setSpacing(12); - addBlock(encoderBody, - encoderCard, - QStringLiteral("@emby_encoder_codecs"), - encoderContainer, - false); - - QVBoxLayout* softwareBody = nullptr; - QWidget* softwareCard = - createSectionCard(tr("Software Encoders"), QString(), softwareBody, m_contentContainer); - contentLayout->addWidget(softwareCard); - - auto* softwareContainer = new QWidget(softwareCard); - m_embySoftwareLayout = new QVBoxLayout(softwareContainer); - m_embySoftwareLayout->setContentsMargins(0, 0, 0, 0); - m_embySoftwareLayout->setSpacing(12); - addBlock(softwareBody, - softwareCard, - QStringLiteral("@emby_software_encoders"), - softwareContainer, - false); - - QVBoxLayout* transcodingBody = nullptr; - QWidget* transcodingCard = - createSectionCard(tr("Transcoding"), QString(), transcodingBody, m_contentContainer); - contentLayout->addWidget(transcodingCard); - - m_lineEdits.insert(QStringLiteral("TranscodingTempPath"), createTextInput()); - addBlock(transcodingBody, - transcodingCard, - QStringLiteral("TranscodingTempPath"), - createFieldBlock(tr("Transcoding Temp Path"), - QString(), - m_lineEdits.value(QStringLiteral("TranscodingTempPath")), - transcodingCard)); - - m_switches.insert(QStringLiteral("EnableThrottling"), - new ModernSwitch(transcodingCard)); - addBlock(transcodingBody, - transcodingCard, - QStringLiteral("EnableThrottling"), - createSwitchBlock(tr("Enable Throttling"), - QString(), - m_switches.value(QStringLiteral("EnableThrottling")), - transcodingCard)); - - m_doubleSpinBoxes.insert(QStringLiteral("DownMixAudioBoost"), - createDoubleSpinBox(0.5, 3.0, 0.1, 1)); - addBlock(transcodingBody, - transcodingCard, - QStringLiteral("DownMixAudioBoost"), - createFieldBlock(tr("Downmix Audio Boost"), - QString(), - m_doubleSpinBoxes.value(QStringLiteral("DownMixAudioBoost")), - transcodingCard, - true)); - - auto* toneCombo = createComboBox(); - addComboOption(toneCombo, tr("Disabled"), QStringLiteral("disabled")); - addComboOption(toneCombo, tr("Hardware"), QStringLiteral("hardware")); - addComboOption(toneCombo, tr("Software"), QStringLiteral("software")); - addComboOption(toneCombo, tr("Hardware + Software"), QStringLiteral("mixed")); - m_comboBoxes.insert(QStringLiteral("EmbyToneMappingMode"), toneCombo); - addBlock(transcodingBody, - transcodingCard, - QStringLiteral("@emby_tone_mapping_mode"), - createFieldBlock(tr("Tone Mapping"), QString(), toneCombo, transcodingCard), - false); - - m_switches.insert(QStringLiteral("EnableSubtitleExtraction"), - new ModernSwitch(transcodingCard)); - addBlock(transcodingBody, - transcodingCard, - QStringLiteral("EnableSubtitleExtraction"), - createSwitchBlock(tr("Enable Subtitle Extraction"), - QString(), - m_switches.value(QStringLiteral("EnableSubtitleExtraction")), - transcodingCard)); - - QVBoxLayout* advancedBody = nullptr; - QWidget* advancedCard = - createSectionCard(tr("Advanced"), QString(), advancedBody, m_contentContainer); - contentLayout->addWidget(advancedCard); - - auto* encodingThreads = createSpinBox(-1, 256, 1, 168); - encodingThreads->setSpecialValueText(tr("Auto")); - m_spinBoxes.insert(QStringLiteral("EncodingThreadCount"), encodingThreads); - addBlock(advancedBody, - advancedCard, - QStringLiteral("EncodingThreadCount"), - createFieldBlock(tr("Encoding Threads"), - QString(), - encodingThreads, - advancedCard, - true)); - - auto* extractionThreads = createSpinBox(-1, 256, 1, 168); - extractionThreads->setSpecialValueText(tr("Auto")); - m_spinBoxes.insert(QStringLiteral("ExtractionThreadCount"), extractionThreads); - addBlock(advancedBody, - advancedCard, - QStringLiteral("ExtractionThreadCount"), - createFieldBlock(tr("Extraction Threads"), - QString(), - extractionThreads, - advancedCard, - true)); - - m_spinBoxes.insert(QStringLiteral("H264Crf"), createSpinBox(0, 51)); - addBlock(advancedBody, - advancedCard, - QStringLiteral("H264Crf"), - createFieldBlock(tr("H.264 CRF"), - QString(), - m_spinBoxes.value(QStringLiteral("H264Crf")), - advancedCard, - true)); - - m_spinBoxes.insert(QStringLiteral("ThrottleBufferSize"), createSpinBox(0, 100000)); - addBlock(advancedBody, - advancedCard, - QStringLiteral("ThrottleBufferSize"), - createFieldBlock(tr("Throttle Buffer"), - QString(), - m_spinBoxes.value(QStringLiteral("ThrottleBufferSize")), - advancedCard, - true)); - - m_spinBoxes.insert(QStringLiteral("ThrottleHysteresis"), createSpinBox(0, 100000)); - addBlock(advancedBody, - advancedCard, - QStringLiteral("ThrottleHysteresis"), - createFieldBlock(tr("Throttle Hysteresis"), - QString(), - m_spinBoxes.value(QStringLiteral("ThrottleHysteresis")), - advancedCard, - true)); - - auto* throttlingMethodCombo = createComboBox(); - addComboOption(throttlingMethodCombo, - tr("By Segment Request"), - QStringLiteral("BySegmentRequest")); - m_comboBoxes.insert(QStringLiteral("ThrottlingMethod"), throttlingMethodCombo); - addBlock(advancedBody, - advancedCard, - QStringLiteral("ThrottlingMethod"), - createFieldBlock(tr("Throttling Method"), - QString(), - throttlingMethodCombo, - advancedCard)); -} - -void PageTranscoding::connectInputs() { - connect(m_btnRefresh, &QPushButton::clicked, this, - [this]() { m_pendingTask = loadData(true); }); - connect(m_btnSave, &QPushButton::clicked, this, - [this]() { m_pendingTask = saveData(); }); - - auto refreshStatusOnly = [this]() { - if (m_rawConfig.isEmpty()) { - m_statsLabel->setText(tr("No transcoding configuration available")); - return; - } - updateStatusText(collectConfig()); - }; - - for (auto it = m_lineEdits.cbegin(); it != m_lineEdits.cend(); ++it) { - connect(it.value(), &QLineEdit::textChanged, this, [this]() { refreshDerivedUi(); }); - } - for (auto it = m_spinBoxes.cbegin(); it != m_spinBoxes.cend(); ++it) { - connect(it.value(), qOverload(&QSpinBox::valueChanged), this, - [this](int) { refreshDerivedUi(); }); - } - for (auto it = m_doubleSpinBoxes.cbegin(); it != m_doubleSpinBoxes.cend(); ++it) { - connect(it.value(), qOverload(&QDoubleSpinBox::valueChanged), this, - [this](double) { refreshDerivedUi(); }); - } - for (auto it = m_comboBoxes.cbegin(); it != m_comboBoxes.cend(); ++it) { - connect(it.value(), qOverload(&QComboBox::currentIndexChanged), this, - [this](int) { refreshDerivedUi(); }); - } - for (auto it = m_switches.cbegin(); it != m_switches.cend(); ++it) { - const QString switchKey = it.key(); - connect(it.value(), &ModernSwitch::toggled, this, - [this, switchKey, refreshStatusOnly](bool) { - if (!m_isJellyfin) { - refreshDerivedUi(); - return; - } - - if (switchKey == QStringLiteral("EnableTonemapping") || - switchKey == QStringLiteral("EnableVppTonemapping") || - switchKey == QStringLiteral("EnableThrottling") || - switchKey == QStringLiteral("EnableSegmentDeletion")) { - refreshDerivedUi(); - return; - } - - if (switchKey == QStringLiteral("PreferSystemNativeHwDecoder") || - switchKey == QStringLiteral("EnableVideoToolboxTonemapping")) { - refreshStatusOnly(); - } - }); - } - for (auto it = m_tagInputs.cbegin(); it != m_tagInputs.cend(); ++it) { - connect(it.value(), &ModernTagInput::valueChanged, this, - [this](const QString&) { refreshDerivedUi(); }); - } - for (auto it = m_jellyfinCodecSwitches.cbegin(); it != m_jellyfinCodecSwitches.cend(); ++it) { - connect(it.value(), &ModernSwitch::toggled, this, - [refreshStatusOnly](bool) { refreshStatusOnly(); }); - } -} - -void PageTranscoding::showEvent(QShowEvent* event) { - ManagePageBase::showEvent(event); - if (!m_loaded) { - m_pendingTask = loadData(); - m_loaded = true; - } -} - -QCoro::Task PageTranscoding::loadData(bool isManual) { - QPointer safeThis(this); - if (m_isLoading) { - co_return; - } - - m_isLoading = true; - m_btnRefresh->setEnabled(false); - m_btnSave->setEnabled(false); - m_statsLabel->setText(tr("Loading...")); - if (isManual) { - playRefreshAnimation(); - } - - try { - if (m_core && m_core->serverManager()) { - const ServerProfile profile = m_core->serverManager()->activeProfile(); - qDebug() << "[PageTranscoding] Loading transcoding data" - << "| profileUrl=" << profile.url - << "| serverType=" - << (profile.type == ServerProfile::Jellyfin ? "Jellyfin" : "Emby"); - } - - const QJsonObject config = - co_await m_core->adminService()->getEncodingConfiguration(); - if (!safeThis) { - co_return; - } - - m_embyCodecInfo = QJsonArray(); - m_embyDefaultCodecConfigs = QJsonArray(); - if (!m_isJellyfin) { - try { - m_embyCodecInfo = - co_await m_core->adminService()->getVideoCodecInformation(); - } catch (const std::exception& e) { - qWarning() << "[PageTranscoding] Failed to load Emby codec information" - << "| error=" << e.what(); - } - if (!safeThis) { - co_return; - } - try { - m_embyDefaultCodecConfigs = - co_await m_core->adminService()->getDefaultCodecConfigurations(); - } catch (const std::exception& e) { - qWarning() << "[PageTranscoding] Failed to load Emby default codec configurations" - << "| error=" << e.what(); - } - if (!safeThis) { - co_return; - } - } - - qDebug() << "[PageTranscoding] Loaded transcoding settings" - << "| serverType=" << (m_isJellyfin ? "Jellyfin" : "Emby") - << "| keyCount=" << config.keys().size() - << "| embyCodecInfoCount=" << m_embyCodecInfo.size() - << "| embyDefaultCodecCount=" << m_embyDefaultCodecConfigs.size(); - - m_emptyLabel->setText(tr("No transcoding configuration available")); - populateForm(config); - m_hasLoadedData = true; - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - - qWarning() << "[PageTranscoding] Failed to load transcoding settings" - << "| error=" << e.what(); - m_rawConfig = QJsonObject(); - m_supportedKeys.clear(); - m_embyCodecInfo = QJsonArray(); - m_embyDefaultCodecConfigs = QJsonArray(); - - if (isManual || !m_hasLoadedData) { - m_statsLabel->setText(tr("Refresh failed")); - } - if (!m_hasLoadedData) { - m_emptyLabel->setText(tr("Failed to load transcoding settings")); - updateContentState(false); - } - - ModernToast::showMessage( - tr("Failed to load transcoding settings: %1") - .arg(QString::fromUtf8(e.what())), - 3000); - } - - if (!safeThis) { - co_return; - } - - m_isLoading = false; - m_btnRefresh->setEnabled(true); - m_btnSave->setEnabled(m_hasLoadedData && !m_rawConfig.isEmpty() && !m_isSaving); -} - -QCoro::Task PageTranscoding::saveData() { - QPointer safeThis(this); - if (!m_hasLoadedData || m_isLoading || m_isSaving || m_rawConfig.isEmpty()) { - co_return; - } - - m_isSaving = true; - m_btnRefresh->setEnabled(false); - m_btnSave->setEnabled(false); - m_btnSave->setToolTip(tr("Saving...")); - m_statsLabel->setText(tr("Saving...")); - - const QJsonObject config = collectConfig(); - bool saved = false; - - qDebug() << "[PageTranscoding] Saving transcoding settings" - << "| serverType=" << (m_isJellyfin ? "Jellyfin" : "Emby") - << "| keyCount=" << config.keys().size(); - - try { - co_await m_core->adminService()->updateEncodingConfiguration(config); - if (!safeThis) { - co_return; - } - saved = true; - ModernToast::showMessage(tr("Transcoding settings saved"), 2000); - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - qWarning() << "[PageTranscoding] Failed to save transcoding settings" - << "| error=" << e.what(); - ModernToast::showMessage( - tr("Failed to save transcoding settings: %1") - .arg(QString::fromUtf8(e.what())), - 3000); - } - - if (!safeThis) { - co_return; - } - - m_isSaving = false; - m_btnSave->setToolTip(tr("Save")); - m_btnRefresh->setEnabled(true); - m_btnSave->setEnabled(m_hasLoadedData && !m_rawConfig.isEmpty()); - if (saved) { - co_await loadData(false); - } else { - refreshDerivedUi(); - } -} - -void PageTranscoding::updateSectionVisibility() { - for (QWidget* card : std::as_const(m_sectionCards)) { - bool hasVisibleBlock = false; - for (const QString& key : m_sectionBlocks.value(card)) { - - - if (auto* block = m_blocks.value(key); block && !block->isHidden()) { - hasVisibleBlock = true; - break; - } - } - card->setVisible(hasVisibleBlock); - } -} - -void PageTranscoding::updateDynamicVisibility() { - for (const QString& key : std::as_const(m_actualBlockKeys)) { - setBlockVisible(key, m_supportedKeys.contains(key)); - } - - if (m_isJellyfin) { - const QString hardwareType = - comboCurrentValue(m_comboBoxes.value(QStringLiteral("HardwareAccelerationType"))) - .toString() - .trimmed() - .toLower(); - const bool hardwareAvailable = - !hardwareType.isEmpty() && hardwareType != QStringLiteral("none"); - const bool toneEnabled = - m_switches.value(QStringLiteral("EnableTonemapping")) && - m_switches.value(QStringLiteral("EnableTonemapping"))->isChecked(); - const bool vppEnabled = - m_switches.value(QStringLiteral("EnableVppTonemapping")) && - m_switches.value(QStringLiteral("EnableVppTonemapping"))->isChecked(); - const bool throttlingEnabled = - m_switches.value(QStringLiteral("EnableThrottling")) && - m_switches.value(QStringLiteral("EnableThrottling"))->isChecked(); - const bool segmentDeletionEnabled = - m_switches.value(QStringLiteral("EnableSegmentDeletion")) && - m_switches.value(QStringLiteral("EnableSegmentDeletion"))->isChecked(); - - setBlockVisible(QStringLiteral("VaapiDevice"), - m_supportedKeys.contains(QStringLiteral("VaapiDevice")) && - hardwareType == QStringLiteral("vaapi")); - setBlockVisible(QStringLiteral("QsvDevice"), - m_supportedKeys.contains(QStringLiteral("QsvDevice")) && - hardwareType == QStringLiteral("qsv")); - setBlockVisible( - QStringLiteral("@jellyfin_decoding_codecs"), - m_supportedKeys.contains(QStringLiteral("HardwareDecodingCodecs")) && - hardwareAvailable); - setBlockVisible( - QStringLiteral("@jellyfin_depth_group"), - (m_supportedKeys.contains(QStringLiteral("EnableDecodingColorDepth10Hevc")) || - m_supportedKeys.contains(QStringLiteral("EnableDecodingColorDepth10Vp9"))) && - hardwareAvailable); - setBlockVisible( - QStringLiteral("@jellyfin_rext_group"), - (m_supportedKeys.contains(QStringLiteral("EnableDecodingColorDepth10HevcRext")) || - m_supportedKeys.contains(QStringLiteral("EnableDecodingColorDepth12HevcRext"))) && - hardwareAvailable); - setBlockVisible( - QStringLiteral("PreferSystemNativeHwDecoder"), - m_supportedKeys.contains(QStringLiteral("PreferSystemNativeHwDecoder")) && - hardwareAvailable); - setBlockVisible( - QStringLiteral("EnableEnhancedNvdecDecoder"), - m_supportedKeys.contains(QStringLiteral("EnableEnhancedNvdecDecoder")) && - hardwareType == QStringLiteral("nvenc")); - setBlockVisible( - QStringLiteral("@jellyfin_hardware_encoding_group"), - hardwareAvailable && - (m_supportedKeys.contains(QStringLiteral("EnableHardwareEncoding")) || - m_supportedKeys.contains( - QStringLiteral("EnableIntelLowPowerH264HwEncoder")) || - m_supportedKeys.contains( - QStringLiteral("EnableIntelLowPowerHevcHwEncoder")))); - setBlockVisible( - QStringLiteral("EnableIntelLowPowerH264HwEncoder"), - m_supportedKeys.contains(QStringLiteral("EnableIntelLowPowerH264HwEncoder")) && - hardwareType == QStringLiteral("qsv")); - setBlockVisible( - QStringLiteral("EnableIntelLowPowerHevcHwEncoder"), - m_supportedKeys.contains(QStringLiteral("EnableIntelLowPowerHevcHwEncoder")) && - hardwareType == QStringLiteral("qsv")); - setBlockVisible( - QStringLiteral("@jellyfin_encoding_formats_group"), - hardwareAvailable && - (m_supportedKeys.contains(QStringLiteral("AllowHevcEncoding")) || - m_supportedKeys.contains(QStringLiteral("AllowAv1Encoding")))); - - for (auto it = m_jellyfinCodecBlocks.begin(); it != m_jellyfinCodecBlocks.end(); ++it) { - it.value()->setVisible( - jellyfinCodecSupportedByHardware(it.key(), hardwareType)); - } - - setBlockVisible(QStringLiteral("EnableVppTonemapping"), - m_supportedKeys.contains(QStringLiteral("EnableVppTonemapping")) && - toneEnabled && hardwareType == QStringLiteral("qsv")); - setBlockVisible( - QStringLiteral("EnableVideoToolboxTonemapping"), - m_supportedKeys.contains(QStringLiteral("EnableVideoToolboxTonemapping")) && - toneEnabled && hardwareType == QStringLiteral("videotoolbox")); - setBlockVisible(QStringLiteral("TonemappingAlgorithm"), - m_supportedKeys.contains(QStringLiteral("TonemappingAlgorithm")) && - toneEnabled); - setBlockVisible(QStringLiteral("TonemappingMode"), - m_supportedKeys.contains(QStringLiteral("TonemappingMode")) && - toneEnabled); - setBlockVisible(QStringLiteral("TonemappingRange"), - m_supportedKeys.contains(QStringLiteral("TonemappingRange")) && - toneEnabled); - setBlockVisible(QStringLiteral("TonemappingDesat"), - m_supportedKeys.contains(QStringLiteral("TonemappingDesat")) && - toneEnabled); - setBlockVisible(QStringLiteral("TonemappingPeak"), - m_supportedKeys.contains(QStringLiteral("TonemappingPeak")) && - toneEnabled); - setBlockVisible(QStringLiteral("TonemappingParam"), - m_supportedKeys.contains(QStringLiteral("TonemappingParam")) && - toneEnabled); - setBlockVisible(QStringLiteral("@jellyfin_vpp_group"), - (m_supportedKeys.contains(QStringLiteral("VppTonemappingBrightness")) || - m_supportedKeys.contains( - QStringLiteral("VppTonemappingContrast"))) && - toneEnabled && vppEnabled && - hardwareType == QStringLiteral("qsv")); - setBlockVisible(QStringLiteral("ThrottleDelaySeconds"), - m_supportedKeys.contains(QStringLiteral("ThrottleDelaySeconds")) && - throttlingEnabled); - setBlockVisible(QStringLiteral("SegmentKeepSeconds"), - m_supportedKeys.contains(QStringLiteral("SegmentKeepSeconds")) && - segmentDeletionEnabled); - } else { - const QJsonObject currentConfig = collectConfig(); - const bool advancedMode = isEmbyAdvancedMode(currentConfig); - const bool toneMappingAvailable = - m_supportedKeys.contains(QStringLiteral("EnableHardwareToneMapping")) || - m_supportedKeys.contains(QStringLiteral("EnableSoftwareToneMapping")); - const bool throttlingEnabled = - m_switches.value(QStringLiteral("EnableThrottling")) && - m_switches.value(QStringLiteral("EnableThrottling"))->isChecked(); - - setBlockVisible(QStringLiteral("@emby_tone_mapping_mode"), toneMappingAvailable); - setBlockVisible(QStringLiteral("@emby_decoder_codecs"), - advancedMode && m_embyDecoderLayout && m_embyDecoderLayout->count() > 0); - setBlockVisible(QStringLiteral("@emby_encoder_codecs"), - advancedMode && m_embyEncoderLayout && m_embyEncoderLayout->count() > 0); - setBlockVisible(QStringLiteral("@emby_software_encoders"), - m_embySoftwareLayout && m_embySoftwareLayout->count() > 0); - setBlockVisible(QStringLiteral("ThrottleBufferSize"), - m_supportedKeys.contains(QStringLiteral("ThrottleBufferSize")) && - throttlingEnabled); - setBlockVisible(QStringLiteral("ThrottleHysteresis"), - m_supportedKeys.contains(QStringLiteral("ThrottleHysteresis")) && - throttlingEnabled); - setBlockVisible(QStringLiteral("ThrottlingMethod"), - m_supportedKeys.contains(QStringLiteral("ThrottlingMethod")) && - throttlingEnabled); - } - - updateSectionVisibility(); -} - -void PageTranscoding::refreshDerivedUi() { - if (m_rawConfig.isEmpty()) { - m_statsLabel->setText(tr("No transcoding configuration available")); - return; - } - - const QJsonObject currentConfig = collectConfig(); - updateDynamicVisibility(); - updateStatusText(currentConfig); -} - -void PageTranscoding::updateStatusText(const QJsonObject& config) { - if (config.isEmpty()) { - m_statsLabel->setText(tr("No transcoding configuration available")); - return; - } - - QStringList parts; - parts.append(serverTypeText()); - parts.append(accelerationSummary(config)); - parts.append(decoderSummary(config)); - parts.append(toneMappingSummary(config)); - m_statsLabel->setText(parts.join(QStringLiteral(" · "))); -} - -void PageTranscoding::updateContentState(bool hasConfig) { - m_contentContainer->setVisible(hasConfig); - m_emptyStateContainer->setVisible(!hasConfig); -} - -void PageTranscoding::populateForm(const QJsonObject& config) { - m_rawConfig = config; - m_supportedKeys.clear(); - for (const QString& key : config.keys()) { - m_supportedKeys.insert(key); - } - - for (auto it = m_switches.cbegin(); it != m_switches.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - it.value()->setChecked(config.value(it.key()).toBool(false)); - } - } - for (auto it = m_spinBoxes.cbegin(); it != m_spinBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - it.value()->setValue(config.value(it.key()).toInt(it.value()->minimum())); - } - } - for (auto it = m_doubleSpinBoxes.cbegin(); it != m_doubleSpinBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - it.value()->setValue(config.value(it.key()).toDouble(it.value()->minimum())); - } - } - for (auto it = m_lineEdits.cbegin(); it != m_lineEdits.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - it.value()->setText(config.value(it.key()).toString()); - } - } - for (auto it = m_comboBoxes.cbegin(); it != m_comboBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - setComboCurrentValue(it.value(), config.value(it.key()).toVariant()); - } - } - for (auto it = m_tagInputs.cbegin(); it != m_tagInputs.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - QSignalBlocker blocker(it.value()); - it.value()->setValue(stringListFromJsonValue(config.value(it.key())).join(',')); - } - } - - if (m_encoderAppPathDisplay && - m_supportedKeys.contains(QStringLiteral("EncoderAppPathDisplay"))) { - QSignalBlocker blocker(m_encoderAppPathDisplay); - m_encoderAppPathDisplay->setText( - config.value(QStringLiteral("EncoderAppPathDisplay")).toString()); - } - - if (m_isJellyfin) { - populateJellyfinCodecSwitches(config); - } else { - if (auto* combo = m_comboBoxes.value(QStringLiteral("EmbyToneMappingMode"))) { - QSignalBlocker blocker(combo); - const bool hardwareToneMapping = - config.value(QStringLiteral("EnableHardwareToneMapping")).toBool(false); - const bool softwareToneMapping = - config.value(QStringLiteral("EnableSoftwareToneMapping")).toBool(false); - QString mode = QStringLiteral("disabled"); - if (hardwareToneMapping && softwareToneMapping) { - mode = QStringLiteral("mixed"); - } else if (hardwareToneMapping) { - mode = QStringLiteral("hardware"); - } else if (softwareToneMapping) { - mode = QStringLiteral("software"); - } - setComboCurrentValue(combo, mode); - } - populateEmbyCodecLists(config); - } - - updateContentState(!config.isEmpty()); - refreshDerivedUi(); - m_btnSave->setEnabled(!config.isEmpty() && !m_isLoading && !m_isSaving); -} - -QJsonObject PageTranscoding::collectConfig() const { - QJsonObject config = m_rawConfig; - - for (auto it = m_switches.cbegin(); it != m_switches.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), it.value()->isChecked()); - } - } - for (auto it = m_spinBoxes.cbegin(); it != m_spinBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), it.value()->value()); - } - } - for (auto it = m_doubleSpinBoxes.cbegin(); it != m_doubleSpinBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), it.value()->value()); - } - } - for (auto it = m_lineEdits.cbegin(); it != m_lineEdits.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), it.value()->text().trimmed()); - } - } - for (auto it = m_comboBoxes.cbegin(); it != m_comboBoxes.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), QJsonValue::fromVariant(comboCurrentValue(it.value()))); - } - } - for (auto it = m_tagInputs.cbegin(); it != m_tagInputs.cend(); ++it) { - if (m_supportedKeys.contains(it.key())) { - config.insert(it.key(), - jsonArrayFromStringList( - it.value()->value().split(',', Qt::SkipEmptyParts))); - } - } - - if (m_isJellyfin && - m_supportedKeys.contains(QStringLiteral("HardwareDecodingCodecs"))) { - config.insert(QStringLiteral("HardwareDecodingCodecs"), - jsonArrayFromStringList(collectJellyfinCodecValues())); - } - - if (!m_isJellyfin) { - const QString toneMode = - comboCurrentValue(m_comboBoxes.value(QStringLiteral("EmbyToneMappingMode"))) - .toString() - .trimmed(); - if (m_supportedKeys.contains(QStringLiteral("EnableHardwareToneMapping"))) { - config.insert(QStringLiteral("EnableHardwareToneMapping"), - toneMode == QStringLiteral("hardware") || - toneMode == QStringLiteral("mixed")); - } - if (m_supportedKeys.contains(QStringLiteral("EnableSoftwareToneMapping"))) { - config.insert(QStringLiteral("EnableSoftwareToneMapping"), - toneMode == QStringLiteral("software") || - toneMode == QStringLiteral("mixed")); - } - if (m_supportedKeys.contains(QStringLiteral("CodecConfigurations"))) { - config.insert(QStringLiteral("CodecConfigurations"), - isEmbyAdvancedMode(config) - ? collectEmbyCodecConfigurations() - : QJsonArray()); - } - } - - return config; -} - -void PageTranscoding::populateJellyfinCodecSwitches(const QJsonObject& config) { - const QStringList selectedCodecs = - stringListFromJsonValue(config.value(QStringLiteral("HardwareDecodingCodecs"))); - - m_unknownJellyfinCodecValues.clear(); - for (const QString& codec : selectedCodecs) { - if (!m_jellyfinCodecSwitches.contains(codec)) { - m_unknownJellyfinCodecValues.append(codec); - } - } - - for (auto it = m_jellyfinCodecSwitches.cbegin(); it != m_jellyfinCodecSwitches.cend(); ++it) { - const QSignalBlocker blocker(it.value()); - it.value()->setChecked(selectedCodecs.contains(it.key())); - } -} - -QStringList PageTranscoding::collectJellyfinCodecValues() const { - QStringList values = m_unknownJellyfinCodecValues; - for (const QString& codec : knownJellyfinDecoderCodecs()) { - if (auto* codecSwitch = m_jellyfinCodecSwitches.value(codec); - codecSwitch && codecSwitch->isChecked() && !values.contains(codec)) { - values.append(codec); - } - } - return values; -} - -QStringList PageTranscoding::knownJellyfinDecoderCodecs() const { - return {QStringLiteral("h264"), - QStringLiteral("hevc"), - QStringLiteral("mpeg1video"), - QStringLiteral("mpeg2video"), - QStringLiteral("mpeg4"), - QStringLiteral("vc1"), - QStringLiteral("vp8"), - QStringLiteral("vp9"), - QStringLiteral("av1")}; -} - -QString PageTranscoding::jellyfinCodecLabel(const QString& codec) const { - if (codec == QStringLiteral("h264")) { - return tr("H264"); - } - if (codec == QStringLiteral("hevc")) { - return tr("HEVC"); - } - if (codec == QStringLiteral("mpeg1video")) { - return tr("MPEG1"); - } - if (codec == QStringLiteral("mpeg2video")) { - return tr("MPEG2"); - } - if (codec == QStringLiteral("mpeg4")) { - return tr("MPEG4"); - } - if (codec == QStringLiteral("vc1")) { - return tr("VC1"); - } - if (codec == QStringLiteral("vp8")) { - return tr("VP8"); - } - if (codec == QStringLiteral("vp9")) { - return tr("VP9"); - } - if (codec == QStringLiteral("av1")) { - return tr("AV1"); - } - return codec.toUpper(); -} - -bool PageTranscoding::jellyfinCodecSupportedByHardware(const QString& codec, - const QString& hardwareType) const { - const QString type = hardwareType.trimmed().toLower(); - if (type.isEmpty() || type == QStringLiteral("none")) { - return false; - } - if (codec == QStringLiteral("h264")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi") || - type == QStringLiteral("rkmpp") || - type == QStringLiteral("videotoolbox") || - type == QStringLiteral("v4l2m2m"); - } - if (codec == QStringLiteral("hevc")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi") || - type == QStringLiteral("rkmpp") || - type == QStringLiteral("videotoolbox"); - } - if (codec == QStringLiteral("mpeg1video")) { - return type == QStringLiteral("rkmpp"); - } - if (codec == QStringLiteral("mpeg2video")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi") || - type == QStringLiteral("rkmpp"); - } - if (codec == QStringLiteral("mpeg4")) { - return type == QStringLiteral("nvenc") || type == QStringLiteral("rkmpp"); - } - if (codec == QStringLiteral("vc1")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi"); - } - if (codec == QStringLiteral("vp8")) { - return type == QStringLiteral("nvenc") || type == QStringLiteral("qsv") || - type == QStringLiteral("vaapi") || type == QStringLiteral("rkmpp") || - type == QStringLiteral("videotoolbox"); - } - if (codec == QStringLiteral("vp9")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi") || - type == QStringLiteral("rkmpp") || - type == QStringLiteral("videotoolbox"); - } - if (codec == QStringLiteral("av1")) { - return type == QStringLiteral("amf") || type == QStringLiteral("nvenc") || - type == QStringLiteral("qsv") || type == QStringLiteral("vaapi") || - type == QStringLiteral("rkmpp"); - } - return true; -} - -void PageTranscoding::populateEmbyCodecLists(const QJsonObject& config) { - m_embyCodecStates.clear(); - m_embyCodecOrders.clear(); - m_embyCodecGroupOrder.clear(); - - LayoutUtils::clearLayout(m_embyDecoderLayout); - LayoutUtils::clearLayout(m_embyEncoderLayout); - LayoutUtils::clearLayout(m_embySoftwareLayout); - - const QJsonArray codecConfigs = - !config.value(QStringLiteral("CodecConfigurations")).toArray().isEmpty() - ? config.value(QStringLiteral("CodecConfigurations")).toArray() - : m_embyDefaultCodecConfigs; - - auto addHardwareGroup = [this, &codecConfigs](QVBoxLayout* targetLayout, - const QString& direction, - const QString& mediaType) { - const QStringList codecIds = sortedEmbyCodecIds(direction, mediaType, codecConfigs); - if (!targetLayout || codecIds.isEmpty()) { - qDebug() << "[PageTranscoding] Skip empty Emby codec group" - << "| direction=" << direction - << "| mediaType=" << mediaType; - return; - } - - qDebug() << "[PageTranscoding] Build Emby codec group" - << "| direction=" << direction - << "| mediaType=" << mediaType - << "| codecCount=" << codecIds.size() - << "| codecIds=" << codecIds; - - const QString groupKey = embyGroupKey(direction, mediaType); - m_embyCodecGroupOrder.append(groupKey); - m_embyCodecOrders.insert(groupKey, codecIds); - - QVBoxLayout* groupBody = nullptr; - QWidget* groupBlock = createGroupBlock(mediaType, - QString(), - groupBody, - targetLayout->parentWidget()); - targetLayout->addWidget(groupBlock); - - auto* listWidget = new TranscodingCodecListWidget(groupBlock); - groupBody->addWidget(listWidget); - - QList rows; - rows.reserve(codecIds.size()); - - for (const QString& codecId : codecIds) { - const QJsonObject codecInfo = embyCodecInfoById(codecId); - const QString title = - codecInfo.value(QStringLiteral("Name")).toString(codecId).trimmed(); - const QString description = - codecInfo.value(QStringLiteral("Description")).toString().trimmed(); - - bool enabled = codecConfigs.isEmpty() - ? codecInfo.value(QStringLiteral("IsEnabledByDefault")).toBool(false) - : false; - int priority = codecConfigs.isEmpty() - ? codecInfo.value(QStringLiteral("DefaultPriority")).toInt(0) - : 0; - for (const QJsonValue& value : codecConfigs) { - const QJsonObject item = value.toObject(); - if (item.value(QStringLiteral("CodecId")).toString() == codecId) { - enabled = item.value(QStringLiteral("IsEnabled")).toBool(enabled); - priority = item.value(QStringLiteral("Priority")).toInt(priority); - break; - } - } - - auto* row = new TranscodingCodecRowWidget(codecId, - title, - description, - tr("Codec Information"), - tr("Drag to Reorder"), - listWidget); - row->setCodecEnabled(enabled); - rows.append(row); - - EmbyCodecState state; - state.codecId = codecId; - state.direction = direction; - state.mediaType = mediaType; - state.title = title; - state.description = description; - state.priority = priority; - state.enabled = enabled; - state.row = row; - m_embyCodecStates.insert(codecId, state); - - qDebug() << "[PageTranscoding] Emby codec row" - << "| direction=" << direction - << "| mediaType=" << mediaType - << "| codecId=" << codecId - << "| title=" << title - << "| enabled=" << enabled - << "| priority=" << priority; - - connect(row, &TranscodingCodecRowWidget::enabledChanged, this, - [this, codecId](const QString&, bool enabledState) { - if (m_embyCodecStates.contains(codecId)) { - EmbyCodecState state = m_embyCodecStates.value(codecId); - state.enabled = enabledState; - m_embyCodecStates.insert(codecId, state); - } - }); - connect(row, &TranscodingCodecRowWidget::infoRequested, this, - [this](const QString& requestedCodecId) { - showEmbyCodecInfo(requestedCodecId); - }); - } - - listWidget->setCodecRows(rows); - connect(listWidget, &TranscodingCodecListWidget::orderChanged, this, - [this, groupKey](const QStringList& orderedCodecIds) { - m_embyCodecOrders.insert(groupKey, orderedCodecIds); - }); - }; - - QStringList decoderGroups; - QStringList encoderGroups; - for (const QJsonValue& value : m_embyCodecInfo) { - const QJsonObject codecInfo = value.toObject(); - if (codecInfo.isEmpty() || - !codecInfo.value(QStringLiteral("IsHardwareCodec")).toBool(false)) { - continue; - } - - const QString direction = codecInfo.value(QStringLiteral("Direction")).toString().trimmed(); - const QString mediaType = - codecInfo.value(QStringLiteral("MediaTypeName")).toString().trimmed(); - if (mediaType.isEmpty()) { - continue; - } - - if (direction.compare(QStringLiteral("Decoder"), Qt::CaseInsensitive) == 0 && - !decoderGroups.contains(mediaType)) { - decoderGroups.append(mediaType); - } - if (direction.compare(QStringLiteral("Encoder"), Qt::CaseInsensitive) == 0 && - !encoderGroups.contains(mediaType)) { - encoderGroups.append(mediaType); - } - } - - std::sort(decoderGroups.begin(), decoderGroups.end()); - std::sort(encoderGroups.begin(), encoderGroups.end()); - - for (const QString& mediaType : std::as_const(decoderGroups)) { - addHardwareGroup(m_embyDecoderLayout, QStringLiteral("Decoder"), mediaType); - } - for (const QString& mediaType : std::as_const(encoderGroups)) { - addHardwareGroup(m_embyEncoderLayout, QStringLiteral("Encoder"), mediaType); - } - - QList softwareEncoders; - for (const QJsonValue& value : m_embyCodecInfo) { - const QJsonObject codecInfo = value.toObject(); - if (codecInfo.isEmpty() || - codecInfo.value(QStringLiteral("IsHardwareCodec")).toBool(false) || - codecInfo.value(QStringLiteral("Direction")).toString().compare( - QStringLiteral("Encoder"), Qt::CaseInsensitive) != 0 || - !codecInfo.value(QStringLiteral("SupportsParameters")).toBool(false)) { - continue; - } - softwareEncoders.append(codecInfo); - } - - std::sort(softwareEncoders.begin(), softwareEncoders.end(), [](const QJsonObject& left, - const QJsonObject& right) { - const QString leftTitle = left.value(QStringLiteral("MediaTypeName")) - .toString(left.value(QStringLiteral("Name")).toString()) - .trimmed(); - const QString rightTitle = right.value(QStringLiteral("MediaTypeName")) - .toString(right.value(QStringLiteral("Name")).toString()) - .trimmed(); - return leftTitle.localeAwareCompare(rightTitle) < 0; - }); - - for (const QJsonObject& codecInfo : std::as_const(softwareEncoders)) { - const QString codecId = - codecInfo.value(QStringLiteral("Id")).toString().trimmed(); - const QString title = codecInfo.value(QStringLiteral("MediaTypeName")) - .toString(codecInfo.value(QStringLiteral("Name")).toString()) - .trimmed(); - QStringList descriptionParts; - const QString name = codecInfo.value(QStringLiteral("Name")).toString().trimmed(); - const QString description = - codecInfo.value(QStringLiteral("Description")).toString().trimmed(); - if (!name.isEmpty() && name != title) { - descriptionParts.append(name); - } - if (!description.isEmpty() && !descriptionParts.contains(description)) { - descriptionParts.append(description); - } - - auto* row = new QFrame(m_embySoftwareLayout ? m_embySoftwareLayout->parentWidget() - : nullptr); - row->setObjectName("TranscodingCodecItem"); - row->setAttribute(Qt::WA_StyledBackground, true); - - auto* rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(12, 10, 12, 10); - rowLayout->setSpacing(10); - - auto* textLayout = new QVBoxLayout(); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(4); - - auto* titleLabel = new QLabel(title, row); - titleLabel->setObjectName("TranscodingCodecName"); - titleLabel->setWordWrap(true); - textLayout->addWidget(titleLabel); - - auto* descriptionLabel = - new QLabel(descriptionParts.join(QStringLiteral(" · ")), row); - descriptionLabel->setObjectName("TranscodingCodecDescription"); - descriptionLabel->setWordWrap(true); - descriptionLabel->setVisible(!descriptionLabel->text().trimmed().isEmpty()); - textLayout->addWidget(descriptionLabel); - - rowLayout->addLayout(textLayout, 1); - - auto* settingsButton = new QPushButton(tr("Settings"), row); - settingsButton->setObjectName("SettingsCardButton"); - settingsButton->setCursor(Qt::PointingHandCursor); - settingsButton->setFixedHeight(30); - settingsButton->setToolTip(tr("Settings")); - rowLayout->addWidget(settingsButton, 0, Qt::AlignVCenter); - - m_embySoftwareLayout->addWidget(row); - - connect(settingsButton, &QPushButton::clicked, this, - [this, codecId]() { showEmbyCodecParameters(codecId); }); - } - -} - -QJsonObject PageTranscoding::embyCodecInfoById(const QString& codecId) const { - for (const QJsonValue& value : m_embyCodecInfo) { - const QJsonObject codecInfo = value.toObject(); - if (codecInfo.value(QStringLiteral("Id")).toString().trimmed() == codecId) { - return codecInfo; - } - } - return {}; -} - -QStringList PageTranscoding::sortedEmbyCodecIds(const QString& direction, - const QString& mediaType, - const QJsonArray& configs) const { - struct Entry { - QString codecId; - QString title; - int priority = std::numeric_limits::max(); - }; - - QHash configById; - for (const QJsonValue& value : configs) { - const QJsonObject item = value.toObject(); - const QString codecId = item.value(QStringLiteral("CodecId")).toString().trimmed(); - if (!codecId.isEmpty()) { - configById.insert(codecId, item); - } - } - - QList entries; - for (const QJsonValue& value : m_embyCodecInfo) { - const QJsonObject codecInfo = value.toObject(); - if (codecInfo.isEmpty() || - !codecInfo.value(QStringLiteral("IsHardwareCodec")).toBool(false) || - codecInfo.value(QStringLiteral("Direction")).toString().compare( - direction, Qt::CaseInsensitive) != 0 || - codecInfo.value(QStringLiteral("MediaTypeName")).toString().trimmed() != mediaType) { - continue; - } - - const QString codecId = codecInfo.value(QStringLiteral("Id")).toString().trimmed(); - if (codecId.isEmpty()) { - continue; - } - - const QJsonObject configItem = configById.value(codecId); - Entry entry; - entry.codecId = codecId; - entry.title = codecInfo.value(QStringLiteral("Name")).toString(codecId); - entry.priority = configItem.isEmpty() - ? codecInfo.value(QStringLiteral("DefaultPriority")).toInt(9999) - : configItem.value(QStringLiteral("Priority")).toInt(9999); - entries.append(entry); - } - - std::sort(entries.begin(), entries.end(), [](const Entry& left, const Entry& right) { - if (left.priority != right.priority) { - return left.priority > right.priority; - } - return left.title.localeAwareCompare(right.title) < 0; - }); - - QStringList codecIds; - for (const Entry& entry : std::as_const(entries)) { - codecIds.append(entry.codecId); - } - return codecIds; -} - -QString PageTranscoding::embyGroupKey(const QString& direction, const QString& mediaType) const { - return direction + QStringLiteral("::") + mediaType; -} - -void PageTranscoding::showEmbyCodecInfo(const QString& codecId) { - const QJsonObject codecInfo = embyCodecInfoById(codecId); - if (codecInfo.isEmpty()) { - return; - } - - qDebug() << "[PageTranscoding] Opening codec info dialog" - << "| codecId=" << codecId - << "| name=" << codecInfo.value(QStringLiteral("Name")).toString() - << "| mediaType=" - << codecInfo.value(QStringLiteral("MediaTypeName")).toString(); - - EmbyCodecInfoDialog dialog(codecInfo, this); - dialog.exec(); -} - -void PageTranscoding::showEmbyCodecParameters(const QString& codecId) { - const QJsonObject codecInfo = embyCodecInfoById(codecId); - if (codecInfo.isEmpty()) { - qWarning() << "[PageTranscoding] Codec info missing for parameter editor" - << "| codecId=" << codecId; - return; - } - - qDebug() << "[PageTranscoding] Opening codec parameter editor" - << "| codecId=" << codecId - << "| title=" - << codecInfo.value(QStringLiteral("MediaTypeName")) - .toString(codecInfo.value(QStringLiteral("Name")).toString()); - - EmbyCodecParametersDialog dialog(m_core, codecInfo, this); - dialog.exec(); -} - -QJsonArray PageTranscoding::collectEmbyCodecConfigurations() const { - QJsonArray configs = m_rawConfig.value(QStringLiteral("CodecConfigurations")).toArray(); - if (configs.isEmpty()) { - configs = m_embyDefaultCodecConfigs; - } - - QHash priorities; - for (int groupIndex = 0; groupIndex < m_embyCodecGroupOrder.size(); ++groupIndex) { - const QString groupKey = m_embyCodecGroupOrder.at(groupIndex); - const QStringList codecIds = m_embyCodecOrders.value(groupKey); - for (int index = 0; index < codecIds.size(); ++index) { - priorities.insert(codecIds.at(index), 100 - index); - } - } - - QSet seenCodecIds; - QJsonArray result; - for (const QJsonValue& value : configs) { - QJsonObject item = value.toObject(); - const QString codecId = item.value(QStringLiteral("CodecId")).toString().trimmed(); - if (codecId.isEmpty()) { - result.append(item); - continue; - } - - seenCodecIds.insert(codecId); - if (const EmbyCodecState state = m_embyCodecStates.value(codecId); state.row) { - item.insert(QStringLiteral("IsEnabled"), state.row->isCodecEnabled()); - item.insert(QStringLiteral("Priority"), - priorities.value(codecId, state.priority)); - } - result.append(item); - } - - for (const QString& groupKey : m_embyCodecGroupOrder) { - for (const QString& codecId : m_embyCodecOrders.value(groupKey)) { - if (seenCodecIds.contains(codecId)) { - continue; - } - const EmbyCodecState state = m_embyCodecStates.value(codecId); - if (!state.row) { - continue; - } - - QJsonObject item; - item.insert(QStringLiteral("CodecId"), codecId); - item.insert(QStringLiteral("IsEnabled"), state.row->isCodecEnabled()); - item.insert(QStringLiteral("Priority"), - priorities.value(codecId, state.priority)); - result.append(item); - } - } - - return result; -} - -QString PageTranscoding::serverTypeText() const { - return m_isJellyfin ? tr("Jellyfin") : tr("Emby"); -} - -QString PageTranscoding::hardwareTypeText(QString type) const { - type = type.trimmed().toLower(); - if (type.isEmpty()) { - return tr("Unknown"); - } - if (type == QStringLiteral("auto")) { - return tr("Auto"); - } - if (type == QStringLiteral("none")) { - return tr("None"); - } - if (auto* combo = m_comboBoxes.value(QStringLiteral("HardwareAccelerationType"))) { - const int index = combo->findData(type); - if (index >= 0) { - return combo->itemText(index); - } - } - return type.toUpper(); -} - -QString PageTranscoding::embyHardwareModeText(int mode) const { - switch (mode) { - case 0: - return tr("No"); - case 1: - return tr("Yes"); - case 2: - return tr("Advanced"); - default: - return tr("Mode %1").arg(mode); - } -} - -QString PageTranscoding::accelerationSummary(const QJsonObject& config) const { - if (m_isJellyfin) { - const QString type = - config.value(QStringLiteral("HardwareAccelerationType")).toString(); - if (type.trimmed().isEmpty() || type.compare(QStringLiteral("none"), Qt::CaseInsensitive) == 0) { - return tr("Disabled"); - } - return hardwareTypeText(type); - } - return embyHardwareModeText( - config.value(QStringLiteral("HardwareAccelerationMode")).toInt(0)); -} - -QString PageTranscoding::decoderSummary(const QJsonObject& config) const { - if (m_isJellyfin) { - const QString hardwareType = - config.value(QStringLiteral("HardwareAccelerationType")).toString(); - if (hardwareType.trimmed().isEmpty() || - hardwareType.compare(QStringLiteral("none"), Qt::CaseInsensitive) == 0) { - return tr("Disabled"); - } - if (config.value(QStringLiteral("PreferSystemNativeHwDecoder")).toBool(false)) { - return tr("System Native"); - } - QStringList preview = - stringListFromJsonValue(config.value(QStringLiteral("HardwareDecodingCodecs"))) - .mid(0, 2); - if (preview.isEmpty()) { - return tr("Automatic"); - } - for (QString& value : preview) { - value = jellyfinCodecLabel(value); - } - return preview.join(QStringLiteral(", ")); - } - - const int mode = config.value(QStringLiteral("HardwareAccelerationMode")).toInt(0); - if (mode <= 0) { - return tr("Disabled"); - } - if (mode == 2 && !m_embyCodecStates.isEmpty()) { - return tr("Custom Order"); - } - return tr("Automatic"); -} - -QString PageTranscoding::toneMappingSummary(const QJsonObject& config) const { - if (m_isJellyfin) { - if (!isToneMappingEnabled(config)) { - return tr("Disabled"); - } - if (config.value(QStringLiteral("EnableVppTonemapping")).toBool(false)) { - return tr("VPP"); - } - if (config.value(QStringLiteral("EnableVideoToolboxTonemapping")).toBool(false)) { - return tr("VideoToolbox"); - } - const QString algorithm = config.value(QStringLiteral("TonemappingAlgorithm")).toString(); - if (auto* combo = m_comboBoxes.value(QStringLiteral("TonemappingAlgorithm"))) { - const int index = combo->findData(algorithm); - if (index >= 0) { - return combo->itemText(index); - } - } - return algorithm.trimmed().isEmpty() ? tr("Enabled") : algorithm; - } - - const bool hardwareToneMapping = - config.value(QStringLiteral("EnableHardwareToneMapping")).toBool(false); - const bool softwareToneMapping = - config.value(QStringLiteral("EnableSoftwareToneMapping")).toBool(false); - if (hardwareToneMapping && softwareToneMapping) { - return tr("Hardware + Software"); - } - if (hardwareToneMapping) { - return tr("Hardware"); - } - if (softwareToneMapping) { - return tr("Software"); - } - return tr("Disabled"); -} - -bool PageTranscoding::isToneMappingEnabled(const QJsonObject& config) const { - if (m_isJellyfin) { - return config.value(QStringLiteral("EnableTonemapping")).toBool(false); - } - return config.value(QStringLiteral("EnableHardwareToneMapping")).toBool(false) || - config.value(QStringLiteral("EnableSoftwareToneMapping")).toBool(false); -} - -bool PageTranscoding::isEmbyAdvancedMode(const QJsonObject& config) const { - return config.value(QStringLiteral("HardwareAccelerationMode")).toInt(0) == 2; -} - -void PageTranscoding::playRefreshAnimation() { - RefreshSpin::start(m_btnRefresh); +void PageTranscoding::showCodecParameters(const QString &codecId) { + const QJsonObject info = m_viewModel->codecInfo(codecId); + if (info.isEmpty()) + return; + auto *dialog = new EmbyCodecParametersDialog(m_core, info, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); } diff --git a/src/qEmbyApp/views/admin/pagetranscoding.h b/src/qEmbyApp/views/admin/pagetranscoding.h index 82669f3..22a4bc3 100644 --- a/src/qEmbyApp/views/admin/pagetranscoding.h +++ b/src/qEmbyApp/views/admin/pagetranscoding.h @@ -2,183 +2,28 @@ #define PAGETRANSCODING_H #include "managepagebase.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -class QDoubleSpinBox; -class QLabel; -class QLineEdit; -class QPushButton; class QScrollArea; -class QShowEvent; -class QSpinBox; -class QVBoxLayout; -class QWidget; -class ModernComboBox; -class ModernSwitch; -class ModernTagInput; -class TranscodingCodecRowWidget; +class QuickPageHost; +class TranscodingViewModel; + +// Compatibility shell retained for ManageView. The page body and editable state +// live in AdminTranscodingPage.qml; API serialization remains in the ViewModel. +class PageTranscoding final : public ManagePageBase { + Q_OBJECT -class PageTranscoding : public ManagePageBase { - Q_OBJECT public: - explicit PageTranscoding(QEmbyCore* core, QWidget* parent = nullptr); - QScrollArea* scrollArea() const { return m_scrollArea; } - -protected: - void showEvent(QShowEvent* event) override; + explicit PageTranscoding(QEmbyCore *core, QWidget *parent = nullptr); + QScrollArea *scrollArea() const { return m_scrollArea; } private: - struct EmbyCodecState { - QString codecId; - QString direction; - QString mediaType; - QString title; - QString description; - int priority = 0; - bool enabled = false; - TranscodingCodecRowWidget* row = nullptr; - }; + void choosePath(const QString &key, bool directory); + void showCodecInfo(const QString &codecId); + void showCodecParameters(const QString &codecId); - void setupToolbar(); - void setupContentArea(); - void buildPageLayout(); - void buildJellyfinLayout(QVBoxLayout* contentLayout); - void buildEmbyLayout(QVBoxLayout* contentLayout); - void connectInputs(); - - QCoro::Task loadData(bool isManual = false); - QCoro::Task saveData(); - - QWidget* createSectionCard(const QString& title, - const QString& subtitle, - QVBoxLayout*& bodyLayout, - QWidget* parent); - QWidget* createFieldBlock(const QString& title, - const QString& description, - QWidget* control, - QWidget* parent, - bool compactControl = false); - QWidget* createSwitchBlock(const QString& title, - const QString& description, - ModernSwitch* control, - QWidget* parent); - QWidget* createGroupBlock(const QString& title, - const QString& description, - QVBoxLayout*& bodyLayout, - QWidget* parent); - QLineEdit* createTextInput(int width = 240, bool readOnly = false); - QSpinBox* createSpinBox(int minimum, - int maximum, - int singleStep = 1, - int width = 168); - QDoubleSpinBox* createDoubleSpinBox(double minimum, - double maximum, - double singleStep, - int decimals = 1, - int width = 168); - ModernComboBox* createComboBox(int width = 220); - ModernTagInput* createTagInput(const QStringList& presetValues = {}); - void addComboOption(ModernComboBox* combo, const QString& text, const QVariant& value); - void setComboCurrentValue(ModernComboBox* combo, const QVariant& value); - QVariant comboCurrentValue(ModernComboBox* combo) const; - QStringList stringListFromJsonValue(const QJsonValue& value) const; - QJsonArray jsonArrayFromStringList(const QStringList& values) const; - - void addBlock(QVBoxLayout* body, - QWidget* sectionCard, - const QString& key, - QWidget* block, - bool actualKey = true); - void setBlockVisible(const QString& key, bool visible); - void updateSectionVisibility(); - void updateDynamicVisibility(); - void refreshDerivedUi(); - void updateStatusText(const QJsonObject& config); - void updateContentState(bool hasConfig); - - void populateForm(const QJsonObject& config); - QJsonObject collectConfig() const; - - void populateJellyfinCodecSwitches(const QJsonObject& config); - QStringList collectJellyfinCodecValues() const; - QStringList knownJellyfinDecoderCodecs() const; - QString jellyfinCodecLabel(const QString& codec) const; - bool jellyfinCodecSupportedByHardware(const QString& codec, - const QString& hardwareType) const; - - void populateEmbyCodecLists(const QJsonObject& config); - QJsonObject embyCodecInfoById(const QString& codecId) const; - QStringList sortedEmbyCodecIds(const QString& direction, - const QString& mediaType, - const QJsonArray& configs) const; - QString embyGroupKey(const QString& direction, const QString& mediaType) const; - void showEmbyCodecInfo(const QString& codecId); - void showEmbyCodecParameters(const QString& codecId); - QJsonArray collectEmbyCodecConfigurations() const; - - QString serverTypeText() const; - QString hardwareTypeText(QString type) const; - QString embyHardwareModeText(int mode) const; - QString accelerationSummary(const QJsonObject& config) const; - QString decoderSummary(const QJsonObject& config) const; - QString toneMappingSummary(const QJsonObject& config) const; - bool isToneMappingEnabled(const QJsonObject& config) const; - bool isEmbyAdvancedMode(const QJsonObject& config) const; - void playRefreshAnimation(); - - QPushButton* m_btnRefresh = nullptr; - QPushButton* m_btnSave = nullptr; - QLabel* m_statsLabel = nullptr; - QScrollArea* m_scrollArea = nullptr; - QWidget* m_scrollContentWidget = nullptr; - QWidget* m_contentContainer = nullptr; - QVBoxLayout* m_contentLayout = nullptr; - QWidget* m_emptyStateContainer = nullptr; - QLabel* m_emptyLabel = nullptr; - - QList m_sectionCards; - QHash m_sectionBlocks; - QHash m_blocks; - QSet m_actualBlockKeys; - - QHash m_switches; - QHash m_spinBoxes; - QHash m_doubleSpinBoxes; - QHash m_lineEdits; - QHash m_comboBoxes; - QHash m_tagInputs; - - QLineEdit* m_encoderAppPathDisplay = nullptr; - QHash m_jellyfinCodecSwitches; - QHash m_jellyfinCodecBlocks; - QStringList m_unknownJellyfinCodecValues; - - QVBoxLayout* m_embyDecoderLayout = nullptr; - QVBoxLayout* m_embyEncoderLayout = nullptr; - QVBoxLayout* m_embySoftwareLayout = nullptr; - QHash m_embyCodecStates; - QHash m_embyCodecOrders; - QStringList m_embyCodecGroupOrder; - - QJsonObject m_rawConfig; - QJsonArray m_embyCodecInfo; - QJsonArray m_embyDefaultCodecConfigs; - QSet m_supportedKeys; - bool m_isJellyfin = false; - bool m_loaded = false; - bool m_hasLoadedData = false; - bool m_isLoading = false; - bool m_isSaving = false; - std::optional> m_pendingTask; + QScrollArea *m_scrollArea = nullptr; + QuickPageHost *m_quickHost = nullptr; + TranscodingViewModel *m_viewModel = nullptr; }; -#endif +#endif diff --git a/src/qEmbyApp/views/admin/pageusers.cpp b/src/qEmbyApp/views/admin/pageusers.cpp index b4f53c8..61ad8c5 100644 --- a/src/qEmbyApp/views/admin/pageusers.cpp +++ b/src/qEmbyApp/views/admin/pageusers.cpp @@ -1,291 +1,55 @@ #include "pageusers.h" -#include "../../components/adminusercard.h" + #include "../../components/adduserdialog.h" -#include "../../components/flowlayout.h" -#include "../../components/moderntoast.h" #include "../../components/usereditdialog.h" -#include "../../components/modernmessagebox.h" -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" - - - +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "usersviewmodel.h" +#include PageUsers::PageUsers(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, tr("Users"), parent) { - m_mainLayout->setContentsMargins(16, 30, 0, 0); + : ManagePageBase(core, QString(), parent) { + setObjectName(QStringLiteral("admin-users-page")); + setAttribute(Qt::WA_StyledBackground, true); + m_mainLayout->setContentsMargins(0, 0, 0, 0); + m_mainLayout->setSpacing(0); - - auto *titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, 12, 0); + m_quickHost = new QuickPageHost(this); + m_viewModel = new UsersViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("usersPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminUsersPage.qml"))); + m_mainLayout->addWidget(m_quickHost, 1); - m_btnRefresh = new QPushButton(this); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addSpacing(6); - - m_btnAddUser = new QPushButton(this); - m_btnAddUser->setObjectName("ManageLibCreateBtn"); - m_btnAddUser->setCursor(Qt::PointingHandCursor); - m_btnAddUser->setFixedSize(32, 32); - m_btnAddUser->setToolTip(tr("Add user")); - titleRow->addWidget(m_btnAddUser); - - titleRow->addStretch(2); - - m_statsLabel = new QLabel(this); - m_statsLabel->setObjectName("ManageInfoValue"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - titleRow->addWidget(m_statsLabel); - - m_mainLayout->addLayout(titleRow); - - auto *scrollContent = new QWidget(this); - scrollContent->setAttribute(Qt::WA_StyledBackground, true); - auto *scrollLayout = new QVBoxLayout(scrollContent); - scrollLayout->setContentsMargins(0, 0, 12, 40); - scrollLayout->setSpacing(16); - - m_cardsContainer = new QWidget(scrollContent); - m_userCardsLayout = new FlowLayout(m_cardsContainer, 0, 12, 12); - scrollLayout->addWidget(m_cardsContainer, 0, Qt::AlignTop); - - - m_emptyStateContainer = new QWidget(scrollContent); - m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Expanding); - auto *emptyLayout = new QVBoxLayout(m_emptyStateContainer); - emptyLayout->setContentsMargins(0, 0, 0, 0); - emptyLayout->setSpacing(0); - emptyLayout->addStretch(1); - m_emptyLabel = new QLabel(tr("No users found"), m_emptyStateContainer); - m_emptyLabel->setObjectName("ManageEmptyLabel"); - m_emptyLabel->setAlignment(Qt::AlignCenter); - m_emptyLabel->hide(); - emptyLayout->addWidget(m_emptyLabel); - emptyLayout->addStretch(1); - m_emptyStateContainer->hide(); - scrollLayout->addWidget(m_emptyStateContainer, 1); - - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName("SettingsScrollArea"); - m_scrollArea->setWidget(scrollContent); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->viewport()->setAutoFillBackground(false); - - m_mainLayout->addWidget(m_scrollArea, 1); - - - connect(m_btnRefresh, &QPushButton::clicked, this, - [this]() { m_pendingTask = loadData(true); }); - connect(m_btnAddUser, &QPushButton::clicked, this, [this]() { - auto *dialog = new AddUserDialog(m_core, this); - connect(dialog, &AddUserDialog::userCreated, this, - [this]() { m_pendingTask = loadData(false); }); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); - }); + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this]() { m_viewModel->setActive(false); }); + connect(m_viewModel, &UsersViewModel::addUserRequested, this, + &PageUsers::openAddUserDialog); + connect(m_viewModel, &UsersViewModel::editUserRequested, this, + &PageUsers::openUserEditor); } -void PageUsers::showEvent(QShowEvent *event) { - ManagePageBase::showEvent(event); - if (!m_loaded) { - m_pendingTask = loadData(); - m_loaded = true; - } +void PageUsers::openAddUserDialog() { + auto *dialog = new AddUserDialog(m_core, this); + connect(dialog, &AddUserDialog::userCreated, m_viewModel, + &UsersViewModel::reloadAfterDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); } -void PageUsers::clearUserCards() { - while (QLayoutItem *item = m_userCardsLayout->takeAt(0)) { - if (QWidget *widget = item->widget()) { - widget->deleteLater(); - } - delete item; - } -} - -void PageUsers::updateContentState(int userCount) { - const bool hasUsers = userCount > 0; - m_cardsContainer->setVisible(hasUsers); - m_emptyStateContainer->setVisible(!hasUsers); - m_emptyLabel->setVisible(!hasUsers); - m_emptyLabel->setText(tr("No users found")); - - if (hasUsers) { - m_statsLabel->setText(tr("%n user(s)", "", userCount)); - } else { - m_statsLabel->setText(tr("No users")); - } -} - -QCoro::Task PageUsers::loadData(bool isManual) { - QPointer safeThis(this); - - if (m_isLoading && !isManual) co_return; - m_isLoading = true; - m_btnRefresh->setEnabled(false); - - if (isManual) { - RefreshSpin::start(m_btnRefresh); - } - - try { - auto users = co_await m_core->adminService()->getUsers(); - if (!safeThis) co_return; - - qDebug() << "[PageUsers] Loaded users" - << "| count=" << users.size(); - - - const QString currentUserId = - m_core->serverManager()->activeProfile().userId; - - clearUserCards(); - - updateContentState(users.size()); - if (users.isEmpty()) { - qDebug() << "[PageUsers] No users available"; - } else { - qDebug() << "[PageUsers] Rendering user cards" - << "| count=" << users.size() - << "| currentUserId=" << currentUserId; - - for (const UserInfo &user : users) { - auto *card = - new AdminUserCard(user, currentUserId, m_cardsContainer); - connect(card, &AdminUserCard::clicked, this, - [this](const QString &userId) { onCardClicked(userId); }); - connect(card, &AdminUserCard::toggleRequested, this, - [this](QString userId, QString userName, bool enable) { - m_pendingTask = - onToggleUserEnabled(userId, userName, enable); - }); - connect(card, &AdminUserCard::deleteRequested, this, - [this](QString userId, QString userName) { - m_pendingTask = onDeleteUser(userId, userName); - }); - m_userCardsLayout->addWidget(card); - } - } - - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Failed to load users: %1") - .arg(QString::fromUtf8(e.what())), - 3000); - } - - if (!safeThis) co_return; - m_isLoading = false; - m_btnRefresh->setEnabled(true); -} - -QCoro::Task PageUsers::onToggleUserEnabled(QString userId, - QString userName, - bool enable) { - QPointer safeThis(this); - const bool isDisabled = !enable; - - qDebug() << "[PageUsers] Setting user policy" - << "| userId=" << userId - << "| userName=" << userName - << "| isDisabled=" << isDisabled; - - bool needRefresh = false; - try { - QJsonObject policy; - policy["IsDisabled"] = isDisabled; - co_await m_core->adminService()->setUserPolicy(userId, policy); - if (!safeThis) co_return; - - if (isDisabled) { - ModernToast::showMessage( - tr("User \"%1\" disabled").arg(userName), 2000); - } else { - ModernToast::showMessage( - tr("User \"%1\" enabled").arg(userName), 2000); - } - - needRefresh = true; - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Failed to update user: %1") - .arg(QString::fromUtf8(e.what())), - 3000); - needRefresh = true; - } - - if (needRefresh && safeThis) { - co_await loadData(false); - } -} - -QCoro::Task PageUsers::onDeleteUser(QString userId, QString userName) { - QPointer safeThis(this); - - bool confirmed = ModernMessageBox::question( - this, - tr("Delete User"), - tr("Are you sure you want to delete user \"%1\"? This action cannot be undone.") - .arg(userName), - tr("Delete"), - tr("Cancel"), - ModernMessageBox::Danger, - ModernMessageBox::Warning); - - if (!confirmed) co_return; - - qDebug() << "[PageUsers] Deleting user" - << "| userId=" << userId - << "| userName=" << userName; - - try { - co_await m_core->adminService()->deleteUser(userId); - if (!safeThis) co_return; - - ModernToast::showMessage( - tr("User \"%1\" deleted").arg(userName), 2000); - co_await loadData(false); - } catch (const std::exception &e) { - if (!safeThis) co_return; - ModernToast::showMessage( - tr("Failed to delete user: %1") - .arg(QString::fromUtf8(e.what())), - 3000); - } -} - -void PageUsers::onCardClicked(const QString &userId) { - qDebug() << "[PageUsers] Opening user editor" - << "| userId=" << userId; - auto *dialog = new UserEditDialog(m_core, userId, this); - connect(dialog, &UserEditDialog::userUpdated, this, - [this]() { m_pendingTask = loadData(false); }); - connect(dialog, &UserEditDialog::userDeleted, this, - [this]() { m_pendingTask = loadData(false); }); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); +void PageUsers::openUserEditor(const QString &userId) { + auto *dialog = new UserEditDialog(m_core, userId, this); + connect(dialog, &UserEditDialog::userUpdated, m_viewModel, + &UsersViewModel::reloadAfterDialog); + connect(dialog, &UserEditDialog::userDeleted, m_viewModel, + &UsersViewModel::reloadAfterDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); } diff --git a/src/qEmbyApp/views/admin/pageusers.h b/src/qEmbyApp/views/admin/pageusers.h index bd1243e..f9d3528 100644 --- a/src/qEmbyApp/views/admin/pageusers.h +++ b/src/qEmbyApp/views/admin/pageusers.h @@ -2,45 +2,24 @@ #define PAGEUSERS_H #include "managepagebase.h" -#include -#include -class FlowLayout; -class QLabel; -class QPushButton; -class QScrollArea; -class QWidget; +class QuickPageHost; +class UsersViewModel; + +// 保留 ManageView 依赖的 QWidget 构造与生命周期契约;页面主体由 +// AdminUsersPage.qml 渲染,复杂的新增/编辑对话框暂由 Widgets 承载。 +class PageUsers final : public ManagePageBase { + Q_OBJECT -class PageUsers : public ManagePageBase { - Q_OBJECT public: - explicit PageUsers(QEmbyCore* core, QWidget* parent = nullptr); - -protected: - void showEvent(QShowEvent* event) override; + explicit PageUsers(QEmbyCore *core, QWidget *parent = nullptr); private: - QCoro::Task loadData(bool isManual = false); - QCoro::Task onToggleUserEnabled(QString userId, QString userName, bool enable); - QCoro::Task onDeleteUser(QString userId, QString userName); - void onCardClicked(const QString &userId); - void clearUserCards(); - void updateContentState(int userCount); + void openAddUserDialog(); + void openUserEditor(const QString &userId); - QPushButton* m_btnRefresh; - QPushButton* m_btnAddUser; - QLabel* m_statsLabel; - - QScrollArea* m_scrollArea; - QWidget* m_cardsContainer; - FlowLayout* m_userCardsLayout; - QWidget* m_emptyStateContainer; - QLabel* m_emptyLabel; - - bool m_loaded = false; - bool m_isLoading = false; - - std::optional> m_pendingTask; + QuickPageHost *m_quickHost = nullptr; + UsersViewModel *m_viewModel = nullptr; }; -#endif +#endif // PAGEUSERS_H diff --git a/src/qEmbyApp/views/admin/transcodingviewmodel.cpp b/src/qEmbyApp/views/admin/transcodingviewmodel.cpp new file mode 100644 index 0000000..7b99570 --- /dev/null +++ b/src/qEmbyApp/views/admin/transcodingviewmodel.cpp @@ -0,0 +1,370 @@ +#include "transcodingviewmodel.h" + +#include +#include +#include + +#include +#include + +#include + +namespace { +QString codecId(const QJsonObject &object) { + QString id = object.value(QStringLiteral("CodecId")).toString().trimmed(); + if (id.isEmpty()) + id = object.value(QStringLiteral("Id")).toString().trimmed(); + return id; +} +} // namespace + +TranscodingViewModel::TranscodingViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core) { + if (m_core && m_core->serverManager()) + m_jellyfin = m_core->serverManager()->activeProfile().type == + ServerProfile::Jellyfin; +} + +bool TranscodingViewModel::canSave() const { + return m_loaded && !m_rawConfig.isEmpty() && !m_loading && !m_refreshing && + !m_saving && dirty(); +} + +QString TranscodingViewModel::statusText() const { + if (m_saving) + return tr("Saving…"); + if (m_loading || m_refreshing) + return tr("Loading…"); + if (!m_errorText.isEmpty() && !m_loaded) + return tr("Unavailable"); + if (!m_loaded) + return tr("Not loaded"); + return m_jellyfin ? tr("Jellyfin encoding configuration") + : tr("Emby encoding configuration"); +} + +QVariant TranscodingViewModel::value(const QString &key) const { + return m_form.value(key); +} + +bool TranscodingViewModel::supports(const QString &key) const { + return m_form.contains(key); +} + +void TranscodingViewModel::setValue(const QString &key, + const QVariant &newValue) { + if (!m_form.contains(key) || m_loading || m_refreshing || m_saving) + return; + QVariant normalized = newValue; + const QVariant current = m_form.value(key); + if (current.metaType().id() == QMetaType::Bool) + normalized = newValue.toBool(); + else if (current.metaType().id() == QMetaType::Int || + current.metaType().id() == QMetaType::LongLong) + normalized = newValue.toInt(); + else if (current.metaType().id() == QMetaType::Double) + normalized = newValue.toDouble(); + else if (current.metaType().id() == QMetaType::QString) + normalized = newValue.toString(); + if (current == normalized) + return; + m_form.insert(key, normalized); + emit formChanged(); +} + +void TranscodingViewModel::setCodecEnabled(const QString &id, bool enabled) { + if (m_loading || m_refreshing || m_saving) + return; + QVariantList configurations = + m_form.value(QStringLiteral("CodecConfigurations")).toList(); + bool found = false; + for (QVariant &entry : configurations) { + QVariantMap item = entry.toMap(); + if (item.value(QStringLiteral("CodecId")).toString() != id) + continue; + item.insert(QStringLiteral("IsEnabled"), enabled); + entry = item; + found = true; + break; + } + if (!found) { + const QJsonObject info = codecInfo(id); + configurations.append( + QVariantMap{{QStringLiteral("CodecId"), id}, + {QStringLiteral("IsEnabled"), enabled}, + {QStringLiteral("Priority"), + info.value(QStringLiteral("DefaultPriority")).toInt(0)}}); + } + m_form.insert(QStringLiteral("CodecConfigurations"), configurations); + rebuildCodecRows(); + emit formChanged(); +} + +void TranscodingViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_generation; + if (m_loading || m_refreshing) { + m_loading = false; + m_refreshing = false; + m_loaded = false; + emit stateChanged(); + emit formChanged(); + } + return; + } + if (!m_loaded) + QCoro::connect(load(false), this, [] {}); +} + +void TranscodingViewModel::refresh() { + if (!m_active || m_loading || m_refreshing || m_saving) + return; + QCoro::connect(load(true), this, [] {}); +} + +void TranscodingViewModel::retry() { refresh(); } + +void TranscodingViewModel::save() { + if (canSave()) + QCoro::connect(store(), this, [] {}); +} + +void TranscodingViewModel::reset() { + if (m_saving || m_loading || m_refreshing || !m_loaded) + return; + m_form = m_originalForm; + rebuildCodecRows(); + emit formChanged(); +} + +void TranscodingViewModel::requestPath(const QString &key, bool directory) { + if (m_form.contains(key)) + emit pathPickerRequested(key, directory); +} + +void TranscodingViewModel::requestCodecInfo(const QString &id) { + if (!codecInfo(id).isEmpty()) + emit codecInfoRequested(id); +} + +void TranscodingViewModel::requestCodecParameters(const QString &id) { + if (!codecInfo(id).isEmpty()) + emit codecParametersRequested(id); +} + +QJsonObject TranscodingViewModel::codecInfo(const QString &id) const { + for (const QJsonValue &value : m_codecInformation) { + const QJsonObject item = value.toObject(); + if (codecId(item) == id) + return item; + } + return {}; +} + +QCoro::Task TranscodingViewModel::load(bool manual) { + if (!m_core || !m_core->adminService() || !m_active) + co_return; + const quint64 generation = ++m_generation; + m_loading = !manual && !m_loaded; + m_refreshing = manual || m_loaded; + m_errorText.clear(); + emit stateChanged(); + emit formChanged(); + + QPointer guard(this); + try { + const QJsonObject configuration = + co_await m_core->adminService()->getEncodingConfiguration(); + if (!guard || !m_active || generation != m_generation) + co_return; + + QJsonArray codecInformation; + QJsonArray defaults; + if (!m_jellyfin) { + try { + codecInformation = + co_await m_core->adminService()->getVideoCodecInformation(); + } catch (const std::exception &) { + // The common encoding form remains usable when optional codec metadata + // is not supported by an older Emby server. + } + if (!guard || !m_active || generation != m_generation) + co_return; + try { + defaults = + co_await m_core->adminService()->getDefaultCodecConfigurations(); + } catch (const std::exception &) { + } + if (!guard || !m_active || generation != m_generation) + co_return; + } + m_codecInformation = codecInformation; + m_defaultCodecConfigurations = defaults; + applyConfiguration(configuration); + m_loaded = !configuration.isEmpty(); + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + m_errorText = tr("Failed to load transcoding settings: %1") + .arg(QString::fromUtf8(error.what())); + if (!m_loaded) { + m_rawConfig = {}; + m_form.clear(); + m_originalForm.clear(); + m_codecRows.clear(); + } + } + if (!guard || !m_active || generation != m_generation) + co_return; + m_loading = false; + m_refreshing = false; + emit stateChanged(); + emit formChanged(); +} + +QCoro::Task TranscodingViewModel::store() { + if (!m_core || !m_core->adminService() || !canSave()) + co_return; + m_saving = true; + emit stateChanged(); + emit formChanged(); + QPointer guard(this); + bool saved = false; + const QVariantMap submittedForm = m_form; + try { + const QJsonObject config = collectedConfiguration(); + co_await m_core->adminService()->updateEncodingConfiguration(config); + if (!guard) + co_return; + m_rawConfig = config; + m_originalForm = submittedForm; + saved = true; + emit notificationRequested(tr("Transcoding settings saved"), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested(tr("Failed to save transcoding settings: %1") + .arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + if (!guard) + co_return; + m_saving = false; + emit stateChanged(); + emit formChanged(); + // Match the former QWidget page: reload after a successful update so values + // normalized by the server immediately become the new reset baseline. + if (saved && m_active) + co_await load(false); +} + +void TranscodingViewModel::applyConfiguration(const QJsonObject &config) { + m_rawConfig = config; + m_form = config.toVariantMap(); + if (!m_jellyfin) { + const bool hardware = + config.value(QStringLiteral("EnableHardwareToneMapping")).toBool(); + const bool software = + config.value(QStringLiteral("EnableSoftwareToneMapping")).toBool(); + const QString mode = hardware && software ? QStringLiteral("mixed") + : hardware ? QStringLiteral("hardware") + : software ? QStringLiteral("software") + : QStringLiteral("disabled"); + m_form.insert(QStringLiteral("EmbyToneMappingMode"), mode); + if (m_form.value(QStringLiteral("CodecConfigurations")) + .toList() + .isEmpty() && + !m_defaultCodecConfigurations.isEmpty()) + m_form.insert(QStringLiteral("CodecConfigurations"), + m_defaultCodecConfigurations.toVariantList()); + } + m_originalForm = m_form; + rebuildCodecRows(); +} + +void TranscodingViewModel::rebuildCodecRows() { + m_codecRows.clear(); + if (m_jellyfin) + return; + const QVariantList configs = + m_form.value(QStringLiteral("CodecConfigurations")).toList(); + for (const QJsonValue &value : m_codecInformation) { + const QJsonObject info = value.toObject(); + const QString id = codecId(info); + if (id.isEmpty()) + continue; + bool enabled = info.value(QStringLiteral("IsEnabledByDefault")).toBool(); + int priority = info.value(QStringLiteral("DefaultPriority")).toInt(); + for (const QVariant &entry : configs) { + const QVariantMap item = entry.toMap(); + if (item.value(QStringLiteral("CodecId")).toString() == id) { + enabled = item.value(QStringLiteral("IsEnabled"), enabled).toBool(); + priority = item.value(QStringLiteral("Priority"), priority).toInt(); + break; + } + } + m_codecRows.append( + QVariantMap{{QStringLiteral("codecId"), id}, + {QStringLiteral("title"), + info.value(QStringLiteral("Name")).toString(id)}, + {QStringLiteral("description"), + info.value(QStringLiteral("Description")).toString()}, + {QStringLiteral("direction"), + info.value(QStringLiteral("Direction")).toString()}, + {QStringLiteral("mediaType"), + info.value(QStringLiteral("MediaTypeName")).toString()}, + {QStringLiteral("hardware"), + info.value(QStringLiteral("IsHardwareCodec")).toBool()}, + {QStringLiteral("supportsParameters"), + info.value(QStringLiteral("SupportsParameters")).toBool()}, + {QStringLiteral("enabled"), enabled}, + {QStringLiteral("priority"), priority}}); + } + std::sort(m_codecRows.begin(), m_codecRows.end(), + [](const QVariant &left, const QVariant &right) { + const QVariantMap a = left.toMap(); + const QVariantMap b = right.toMap(); + if (a.value(QStringLiteral("direction")) != + b.value(QStringLiteral("direction"))) + return a.value(QStringLiteral("direction")).toString() < + b.value(QStringLiteral("direction")).toString(); + return a.value(QStringLiteral("priority")).toInt() < + b.value(QStringLiteral("priority")).toInt(); + }); +} + +QJsonObject TranscodingViewModel::collectedConfiguration() const { + QJsonObject result = m_rawConfig; + const QJsonObject edited = QJsonObject::fromVariantMap(m_form); + for (auto it = edited.constBegin(); it != edited.constEnd(); ++it) { + if (m_rawConfig.contains(it.key())) + result.insert(it.key(), it.value()); + } + if (!m_jellyfin) { + const QString mode = + m_form.value(QStringLiteral("EmbyToneMappingMode")).toString(); + if (m_rawConfig.contains(QStringLiteral("EnableHardwareToneMapping"))) + result.insert(QStringLiteral("EnableHardwareToneMapping"), + mode == QStringLiteral("hardware") || + mode == QStringLiteral("mixed")); + if (m_rawConfig.contains(QStringLiteral("EnableSoftwareToneMapping"))) + result.insert(QStringLiteral("EnableSoftwareToneMapping"), + mode == QStringLiteral("software") || + mode == QStringLiteral("mixed")); + if (m_rawConfig.contains(QStringLiteral("CodecConfigurations"))) { + const bool advanced = + m_form.value(QStringLiteral("HardwareAccelerationMode")).toInt() == 2; + result.insert( + QStringLiteral("CodecConfigurations"), + advanced ? QJsonArray::fromVariantList( + m_form.value(QStringLiteral("CodecConfigurations")) + .toList()) + : QJsonArray()); + } + } + return result; +} diff --git a/src/qEmbyApp/views/admin/transcodingviewmodel.h b/src/qEmbyApp/views/admin/transcodingviewmodel.h new file mode 100644 index 0000000..04fa1f0 --- /dev/null +++ b/src/qEmbyApp/views/admin/transcodingviewmodel.h @@ -0,0 +1,92 @@ +#ifndef TRANSCODINGVIEWMODEL_H +#define TRANSCODINGVIEWMODEL_H + +#include +#include +#include +#include +#include +#include +#include + +class QEmbyCore; + +class TranscodingViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool jellyfin READ jellyfin CONSTANT) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(bool saving READ saving NOTIFY stateChanged) + Q_PROPERTY(bool loaded READ loaded NOTIFY stateChanged) + Q_PROPERTY(bool dirty READ dirty NOTIFY formChanged) + Q_PROPERTY(bool canSave READ canSave NOTIFY formChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) + Q_PROPERTY(QVariantMap form READ form NOTIFY formChanged) + Q_PROPERTY(QVariantList codecRows READ codecRows NOTIFY formChanged) + +public: + explicit TranscodingViewModel(QEmbyCore *core, QObject *parent = nullptr); + + bool jellyfin() const { return m_jellyfin; } + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + bool saving() const { return m_saving; } + bool loaded() const { return m_loaded; } + bool dirty() const { return m_form != m_originalForm; } + bool canSave() const; + QString errorText() const { return m_errorText; } + QString statusText() const; + QVariantMap form() const { return m_form; } + QVariantList codecRows() const { return m_codecRows; } + + Q_INVOKABLE QVariant value(const QString &key) const; + Q_INVOKABLE bool supports(const QString &key) const; + Q_INVOKABLE void setValue(const QString &key, const QVariant &value); + Q_INVOKABLE void setCodecEnabled(const QString &codecId, bool enabled); + Q_INVOKABLE void refresh(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void save(); + Q_INVOKABLE void reset(); + Q_INVOKABLE void requestPath(const QString &key, bool directory = true); + Q_INVOKABLE void requestCodecInfo(const QString &codecId); + Q_INVOKABLE void requestCodecParameters(const QString &codecId); + + QJsonObject codecInfo(const QString &codecId) const; + +public slots: + void setActive(bool active); + +signals: + void stateChanged(); + void formChanged(); + void notificationRequested(const QString &message, const QString &kind); + void pathPickerRequested(const QString &key, bool directory); + void codecInfoRequested(const QString &codecId); + void codecParametersRequested(const QString &codecId); + +private: + QCoro::Task load(bool manual); + QCoro::Task store(); + void applyConfiguration(const QJsonObject &config); + void rebuildCodecRows(); + QJsonObject collectedConfiguration() const; + + QPointer m_core; + QJsonObject m_rawConfig; + QJsonArray m_codecInformation; + QJsonArray m_defaultCodecConfigurations; + QVariantMap m_form; + QVariantMap m_originalForm; + QVariantList m_codecRows; + QString m_errorText; + quint64 m_generation = 0; + bool m_jellyfin = false; + bool m_active = false; + bool m_loading = false; + bool m_refreshing = false; + bool m_saving = false; + bool m_loaded = false; +}; + +#endif diff --git a/src/qEmbyApp/views/admin/usersviewmodel.cpp b/src/qEmbyApp/views/admin/usersviewmodel.cpp new file mode 100644 index 0000000..2526946 --- /dev/null +++ b/src/qEmbyApp/views/admin/usersviewmodel.cpp @@ -0,0 +1,303 @@ +#include "usersviewmodel.h" + +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace { + +QString displayName(const UserInfo &user) { + const QString name = user.name.trimmed(); + return name.isEmpty() ? user.id : name; +} + +QString formatDateTime(const QString &isoDate) { + if (isoDate.isEmpty()) + return QStringLiteral("-"); + + QDateTime dateTime = QDateTime::fromString(isoDate, Qt::ISODate); + if (!dateTime.isValid()) + dateTime = QDateTime::fromString(isoDate, Qt::ISODateWithMs); + if (!dateTime.isValid()) + return isoDate; + + dateTime.setTimeZone(QTimeZone::UTC); + return dateTime.toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); +} + +QString imageSource(const UserInfo &user) { + if (user.id.isEmpty() || user.primaryImageTag.isEmpty()) + return {}; + return QStringLiteral("image://emby/%1,Primary,%2") + .arg(user.id, user.primaryImageTag); +} + +} // namespace + +UsersListModel::UsersListModel(QObject *parent) : QAbstractListModel(parent) {} + +int UsersListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_users.size(); +} + +QVariant UsersListModel::data(const QModelIndex &index, int role) const { + const UserInfo *user = userAt(index.row()); + if (!index.isValid() || !user) + return {}; + + const QString name = displayName(*user); + switch (role) { + case UserIdRole: + return user->id; + case DisplayNameRole: + return name; + case InitialRole: + return name.isEmpty() ? QStringLiteral("?") : name.left(1).toUpper(); + case AdministratorRole: + return user->isAdministrator; + case DisabledRole: + return user->isDisabled; + case HasPasswordRole: + return user->hasPassword; + case LastLoginRole: + return formatDateTime(user->lastLoginDate); + case LastActivityRole: + return formatDateTime(user->lastActivityDate); + case ImageSourceRole: + return imageSource(*user); + case CurrentUserRole: + return user->id == m_currentUserId; + case CanModifyRole: + return user->id != m_currentUserId; + case BusyRole: + return m_busyUserIds.contains(user->id); + default: + return {}; + } +} + +QHash UsersListModel::roleNames() const { + return { + {UserIdRole, "userId"}, {DisplayNameRole, "displayName"}, + {InitialRole, "initial"}, {AdministratorRole, "administrator"}, + {DisabledRole, "disabled"}, {HasPasswordRole, "hasPassword"}, + {LastLoginRole, "lastLogin"}, {LastActivityRole, "lastActivity"}, + {ImageSourceRole, "imageSource"}, {CurrentUserRole, "currentUser"}, + {CanModifyRole, "canModify"}, {BusyRole, "busy"}}; +} + +const UserInfo *UsersListModel::userAt(int row) const { + return row >= 0 && row < m_users.size() ? &m_users.at(row) : nullptr; +} + +const UserInfo *UsersListModel::userById(const QString &userId) const { + const int row = indexOf(userId); + return row < 0 ? nullptr : &m_users.at(row); +} + +bool UsersListModel::isBusy(const QString &userId) const { + return m_busyUserIds.contains(userId); +} + +void UsersListModel::setUsers(QList users, + const QString ¤tUserId) { + beginResetModel(); + m_users = std::move(users); + m_currentUserId = currentUserId; + endResetModel(); +} + +void UsersListModel::setBusy(const QString &userId, bool busy) { + const int row = indexOf(userId); + if (busy) + m_busyUserIds.insert(userId); + else + m_busyUserIds.remove(userId); + if (row < 0) + return; + const QModelIndex changed = index(row); + emit dataChanged(changed, changed, {BusyRole}); +} + +int UsersListModel::indexOf(const QString &userId) const { + for (int row = 0; row < m_users.size(); ++row) { + if (m_users.at(row).id == userId) + return row; + } + return -1; +} + +UsersViewModel::UsersViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_users(this) {} + +void UsersViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_loadGeneration; + if (m_loading || m_refreshing) { + m_loaded = false; + m_loading = false; + m_refreshing = false; + emit stateChanged(); + } + return; + } + if (!m_loaded) + QCoro::connect(load(false), this, []() {}); +} + +void UsersViewModel::refresh() { + if (!m_active || m_loading || m_refreshing) + return; + QCoro::connect(load(true), this, []() {}); +} + +void UsersViewModel::retry() { refresh(); } + +void UsersViewModel::reloadAfterDialog() { + if (!m_active) { + m_loaded = false; + return; + } + QCoro::connect(load(false), this, []() {}); +} + +void UsersViewModel::requestAddUser() { emit addUserRequested(); } + +void UsersViewModel::requestEditUser(const QString &userId) { + if (m_users.userById(userId)) + emit editUserRequested(userId); +} + +void UsersViewModel::setUserEnabled(const QString &userId, bool enabled) { + const UserInfo *user = m_users.userById(userId); + if (!m_core || !m_core->serverManager() || !user || + m_users.isBusy(userId) || + user->id == m_core->serverManager()->activeProfile().userId) + return; + QCoro::connect(updateEnabled(user->id, displayName(*user), enabled), this, + []() {}); +} + +void UsersViewModel::deleteUser(const QString &userId) { + const UserInfo *user = m_users.userById(userId); + if (!m_core || !m_core->serverManager() || !user || + m_users.isBusy(userId) || + user->id == m_core->serverManager()->activeProfile().userId) + return; + QCoro::connect(removeUser(user->id, displayName(*user)), this, []() {}); +} + +QCoro::Task UsersViewModel::load(bool manualRefresh) { + if (!m_core || !m_core->adminService() || !m_active) + co_return; + + const quint64 generation = ++m_loadGeneration; + m_loading = !manualRefresh && !m_loaded; + m_refreshing = manualRefresh || m_loaded; + m_errorText.clear(); + emit stateChanged(); + + QPointer guard(this); + try { + QList users = co_await m_core->adminService()->getUsers(); + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + const QString currentUserId = + m_core->serverManager()->activeProfile().userId; + m_users.setUsers(std::move(users), currentUserId); + m_loaded = true; + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + m_errorText = + tr("Failed to load users: %1").arg(QString::fromUtf8(error.what())); + } + + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + m_loading = false; + m_refreshing = false; + emit stateChanged(); +} + +QCoro::Task UsersViewModel::updateEnabled(QString userId, + QString displayName, + bool enabled) { + if (!m_core || !m_core->adminService() || m_users.userById(userId) == nullptr) + co_return; + + m_users.setBusy(userId, true); + QPointer guard(this); + try { + const QJsonObject userJson = + co_await m_core->adminService()->getUserById(userId); + if (!guard) + co_return; + const QJsonValue policyValue = userJson.value(QStringLiteral("Policy")); + if (!policyValue.isObject()) + throw std::runtime_error("User response does not contain a policy object"); + QJsonObject policy = policyValue.toObject(); + policy.insert(QStringLiteral("IsDisabled"), !enabled); + co_await m_core->adminService()->setUserPolicy(userId, policy); + if (!guard) + co_return; + emit notificationRequested( + enabled ? tr("User \"%1\" enabled").arg(displayName) + : tr("User \"%1\" disabled").arg(displayName), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested( + tr("Failed to update user: %1").arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + + if (!guard) + co_return; + m_users.setBusy(userId, false); + if (m_active) + co_await load(false); + else + m_loaded = false; +} + +QCoro::Task UsersViewModel::removeUser(QString userId, + QString displayName) { + if (!m_core || !m_core->adminService() || m_users.userById(userId) == nullptr) + co_return; + + m_users.setBusy(userId, true); + QPointer guard(this); + try { + co_await m_core->adminService()->deleteUser(userId); + if (!guard) + co_return; + emit notificationRequested(tr("User \"%1\" deleted").arg(displayName), + QStringLiteral("success")); + } catch (const std::exception &error) { + if (!guard) + co_return; + emit notificationRequested( + tr("Failed to delete user: %1").arg(QString::fromUtf8(error.what())), + QStringLiteral("error")); + } + + if (!guard) + co_return; + m_users.setBusy(userId, false); + if (m_active) + co_await load(false); + else + m_loaded = false; +} diff --git a/src/qEmbyApp/views/admin/usersviewmodel.h b/src/qEmbyApp/views/admin/usersviewmodel.h new file mode 100644 index 0000000..2b18787 --- /dev/null +++ b/src/qEmbyApp/views/admin/usersviewmodel.h @@ -0,0 +1,105 @@ +#ifndef USERSVIEWMODEL_H +#define USERSVIEWMODEL_H + +#include +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class UsersListModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + UserIdRole = Qt::UserRole + 1, + DisplayNameRole, + InitialRole, + AdministratorRole, + DisabledRole, + HasPasswordRole, + LastLoginRole, + LastActivityRole, + ImageSourceRole, + CurrentUserRole, + CanModifyRole, + BusyRole, + }; + + explicit UsersListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const UserInfo *userAt(int row) const; + const UserInfo *userById(const QString &userId) const; + bool isBusy(const QString &userId) const; + void setUsers(QList users, const QString ¤tUserId); + void setBusy(const QString &userId, bool busy); + +private: + int indexOf(const QString &userId) const; + + QList m_users; + QString m_currentUserId; + QSet m_busyUserIds; +}; + +class UsersViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(int userCount READ userCount NOTIFY stateChanged) + Q_PROPERTY(bool hasUsers READ hasUsers NOTIFY stateChanged) + Q_PROPERTY(QAbstractItemModel *usersModel READ usersModel CONSTANT) + +public: + explicit UsersViewModel(QEmbyCore *core, QObject *parent = nullptr); + + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + QString errorText() const { return m_errorText; } + int userCount() const { return m_users.rowCount(); } + bool hasUsers() const { return userCount() > 0; } + QAbstractItemModel *usersModel() { return &m_users; } + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void requestAddUser(); + Q_INVOKABLE void requestEditUser(const QString &userId); + Q_INVOKABLE void setUserEnabled(const QString &userId, bool enabled); + Q_INVOKABLE void deleteUser(const QString &userId); + +public slots: + void setActive(bool active); + void reloadAfterDialog(); + +signals: + void stateChanged(); + void addUserRequested(); + void editUserRequested(const QString &userId); + void notificationRequested(const QString &message, const QString &kind); + +private: + QCoro::Task load(bool manualRefresh); + QCoro::Task updateEnabled(QString userId, QString displayName, + bool enabled); + QCoro::Task removeUser(QString userId, QString displayName); + + QPointer m_core; + UsersListModel m_users; + QString m_errorText; + quint64 m_loadGeneration = 0; + bool m_loading = false; + bool m_refreshing = false; + bool m_active = false; + bool m_loaded = false; +}; + +#endif // USERSVIEWMODEL_H diff --git a/src/qEmbyApp/views/media/detailview.cpp b/src/qEmbyApp/views/media/detailview.cpp index d45636d..cf877d5 100644 --- a/src/qEmbyApp/views/media/detailview.cpp +++ b/src/qEmbyApp/views/media/detailview.cpp @@ -1,3778 +1,160 @@ #include "detailview.h" -#include "../../tokens/AnimationTokens.h" -#include "../../utils/fadehelper.h" -#include "../../components/detailactionwidget.h" -#include "../../components/detailbottominfowidget.h" -#include "../../components/detailcontentwidget.h" -#include "../../components/detailtagbutton.h" -#include "../../components/flowlayout.h" -#include "../../components/horizontallistviewgallery.h" -#include "../../components/mediaactionmenu.h" -#include "../../components/mediasectionwidget.h" -#include "../../components/modernmenubutton.h" -#include "../../components/moderntoast.h" -#include "../../managers/playbackmanager.h" -#include "../../managers/thememanager.h" + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" #include "../../utils/mediaitemutils.h" -#include "../../utils/detailcacheutils.h" -#include "../../utils/mediasourcepreferenceutils.h" -#include "../../utils/numberextractor.h" #include "../../utils/playerpreferenceutils.h" -#include "../../utils/smoothscrollcontroller.h" -#include "overviewdialog.h" +#include "detailviewmodel.h" + #include #include +#include #include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include -#include -#include -#include - -#include - -namespace { - -QWidget *createTransparentReserveWidget(QWidget *parent, const char *objectName, - int height) { - auto *widget = new QWidget(parent); - widget->setObjectName(QString::fromLatin1(objectName)); - widget->setFixedHeight(height); - widget->hide(); - return widget; -} - -int reserveHeightFor(QWidget *widget, int fallbackHeight) { - if (!widget) - return fallbackHeight; - - const int hintHeight = widget->sizeHint().height(); - return qMax(fallbackHeight, hintHeight); -} - -void showReserveWidget(QWidget *reserve, int height = -1) { - if (!reserve) - return; - if (height > 0 && reserve->height() != height) - reserve->setFixedHeight(height); - reserve->show(); -} - -void hideReserveWidget(QWidget *reserve) { - if (reserve) - reserve->hide(); -} - -void prepareReservedSection(QWidget *reserve, MediaSectionWidget *section, - int height = -1) { - showReserveWidget(reserve, height); - if (section) - section->clear(); -} - -void applyReservedSectionItems(QWidget *reserve, MediaSectionWidget *section, - const QList &items) { - if (!section) { - hideReserveWidget(reserve); - return; - } - - if (items.isEmpty()) { - section->clear(); - hideReserveWidget(reserve); - return; - } - - hideReserveWidget(reserve); - section->setItems(items); -} - - - - - - - -QString computeSourcesFingerprint(const QList &sources) { - if (sources.isEmpty()) - return QStringLiteral(""); - QStringList parts; - parts.reserve(sources.size()); - for (const auto &src : sources) { - QStringList streamParts; - streamParts.reserve(src.mediaStreams.size()); - for (const auto &s : src.mediaStreams) { - streamParts << QStringLiteral("%1:%2:%3:%4") - .arg(s.type, QString::number(s.index), s.codec, - s.language); - } - parts << QStringLiteral("%1#%2#%3") - .arg(src.id, QString::number(src.mediaStreams.size()), - streamParts.join(QLatin1Char('|'))); - } - return parts.join(QLatin1Char(';')); -} - - - - -QString computeBottomInfoFingerprint(const MediaItem &item, - const QList &sources) { - QStringList studioParts; - studioParts.reserve(item.studios.size()); - for (const auto &s : item.studios) - studioParts << s.name; - - QStringList urlParts; - urlParts.reserve(item.externalUrls.size()); - for (const auto &u : item.externalUrls) - urlParts << (u.name + QLatin1Char('=') + u.url); - - return QStringLiteral("%1|tags=%2|studios=%3|urls=%4|src=%5") - .arg(item.id, item.tags.join(QLatin1Char(',')), - studioParts.join(QLatin1Char(',')), - urlParts.join(QLatin1Char(',')), computeSourcesFingerprint(sources)); -} - -QString formatDetailReleaseDate(const MediaItem &item) { - QString dateText = item.premiereDate.trimmed(); - if (!dateText.isEmpty()) { - const int timeSeparator = dateText.indexOf(QLatin1Char('T')); - if (timeSeparator > 0) - dateText = dateText.left(timeSeparator); - - - const int dateLen = dateText.length(); - if (dateLen > 0) { - const QString dateOnly = dateText.left(qMin(dateLen, 10)); - const QDate date = QDate::fromString(dateOnly, Qt::ISODate); - if (date.isValid()) - return date.toString(QStringLiteral("yyyy-MM-dd")); - return dateOnly; - } - } - - if (item.productionYear > 0) - return QString::number(item.productionYear); - - return QString(); -} - -QString formatEpisodeTag(const MediaItem &item) { - QString tag; - if (item.parentIndexNumber >= 0) { - tag += QStringLiteral("S%1").arg(item.parentIndexNumber, 2, 10, - QLatin1Char('0')); - } - if (item.indexNumber >= 0) { - tag += QStringLiteral("E%1").arg(item.indexNumber, 2, 10, - QLatin1Char('0')); - } - return tag; -} - -MediaItem resolveSeasonPlaybackEpisode(const QList &episodes) { - for (const MediaItem &episode : episodes) { - if (episode.userData.playbackPositionTicks > 0 && - !episode.userData.played && episode.userData.playedPercentage < 99.0) { - return episode; - } - } - - for (const MediaItem &episode : episodes) { - if (!episode.userData.played) - return episode; - } - - return episodes.isEmpty() ? MediaItem{} : episodes.first(); -} - -MediaItem withSeedContext(MediaItem cachedItem, const MediaItem &seedItem) { - if (seedItem.id.isEmpty() || seedItem.id != cachedItem.id) - return cachedItem; - - if (cachedItem.name.trimmed().isEmpty()) - cachedItem.name = seedItem.name; - if (cachedItem.type.trimmed().isEmpty()) - cachedItem.type = seedItem.type; - if (cachedItem.mediaType.trimmed().isEmpty()) - cachedItem.mediaType = seedItem.mediaType; - if (cachedItem.overview.trimmed().isEmpty()) - cachedItem.overview = seedItem.overview; - if (cachedItem.genres.isEmpty()) - cachedItem.genres = seedItem.genres; - if (cachedItem.taglines.isEmpty()) - cachedItem.taglines = seedItem.taglines; - if (cachedItem.mediaSources.isEmpty()) - cachedItem.mediaSources = seedItem.mediaSources; - if (cachedItem.images.primaryTag.isEmpty() && - cachedItem.images.thumbTag.isEmpty() && - cachedItem.images.backdropTag.isEmpty() && - cachedItem.images.logoTag.isEmpty()) { - cachedItem.images = seedItem.images; - } - - cachedItem.playlistId = seedItem.playlistId; - cachedItem.playlistItemId = seedItem.playlistItemId; - cachedItem.hasResumeContext = seedItem.hasResumeContext; - cachedItem.resumeItemId = seedItem.resumeItemId; - cachedItem.resumeUserData = seedItem.resumeUserData; - return cachedItem; -} - -bool mediaItemListsEqual(const QList &left, - const QList &right) { - if (left.size() != right.size()) - return false; - - for (int i = 0; i < left.size(); ++i) { - if (DetailCacheUtils::fingerprint(left[i]) != - DetailCacheUtils::fingerprint(right[i])) { - return false; - } - } - return true; -} - -QCoro::Task mediaItemListsEqualAsync(QList left, - QList right) { - co_return co_await QtConcurrent::run( - [left = std::move(left), right = std::move(right)]() mutable { - return mediaItemListsEqual(left, right); - }); -} - -QString castFingerprint(const MediaItem &item) { - QStringList parts; - parts.reserve(item.people.size()); - for (const auto &person : item.people) { - parts << QStringLiteral("%1:%2:%3:%4") - .arg(person.id, person.name, person.type, person.role); - } - return parts.join(QLatin1Char('|')); -} - -bool episodesBelongToSeason(const QList &episodes, - const MediaItem &season) { - if (episodes.isEmpty() || season.indexNumber < 0) - return true; - - for (const MediaItem &episode : episodes) { - if (episode.parentIndexNumber < 0) - continue; - if (episode.parentIndexNumber != season.indexNumber) - return false; - } - return true; -} - -bool cacheEntryHasAnySecondaryData( - const DetailCacheUtils::DetailCacheEntry &entry) { - return entry.hasPlayableItem || entry.hasSeasons || entry.hasSeasonEpisodes || - entry.hasSimilarItems || entry.hasCollections || - entry.hasAdditionalParts || !entry.item.people.isEmpty(); -} - -struct CachedDetailImages { - std::optional poster; - std::optional backdrop; - std::optional logo; -}; - -void saveDetailCacheAsync(QString serverId, QString userId, MediaItem item, - QString fingerprint) { - if (serverId.isEmpty() || userId.isEmpty() || item.id.isEmpty()) - return; - - (void)QtConcurrent::run([serverId = std::move(serverId), - userId = std::move(userId), item = std::move(item), - fingerprint = std::move(fingerprint)]() mutable { - QString errorString; - const QString itemId = item.id; - DetailCacheUtils::DetailCacheEntry entry; - const auto existing = DetailCacheUtils::load(serverId, userId, itemId); - if (existing.has_value()) - entry = *existing; - entry.item = item; - if (entry.hasPlayableItem && !entry.playableItemFromServer) { - entry.hasPlayableItem = false; - entry.playableItem = MediaItem{}; - } - const bool saved = - DetailCacheUtils::save(serverId, userId, entry, &errorString); - if (saved) { - qDebug() << "[DetailView] Detail cache saved" - << "itemId=" << itemId - << "fingerprint=" << fingerprint.left(12); - } else { - qWarning() << "[DetailView] Detail cache save failed" - << "itemId=" << itemId << "error=" << errorString; - } - }); -} - -void saveDetailCacheEntryAsync(QString serverId, QString userId, - DetailCacheUtils::DetailCacheEntry entry, - QString reason) { - if (serverId.isEmpty() || userId.isEmpty() || entry.item.id.isEmpty()) - return; - - (void)QtConcurrent::run([serverId = std::move(serverId), - userId = std::move(userId), entry = std::move(entry), - reason = std::move(reason)]() mutable { - QString errorString; - const QString itemId = entry.item.id; - const auto existing = DetailCacheUtils::load(serverId, userId, itemId); - if (existing.has_value()) { - if (!entry.hasPlayableItem && existing->hasPlayableItem && - existing->playableItemFromServer) { - entry.hasPlayableItem = true; - entry.playableItem = existing->playableItem; - entry.playableItemFromServer = true; - } - if (!entry.hasSeasons && existing->hasSeasons) { - entry.hasSeasons = true; - entry.seasons = existing->seasons; - } - if (!entry.hasSeasonEpisodes && existing->hasSeasonEpisodes) { - entry.hasSeasonEpisodes = true; - entry.seasonEpisodes = existing->seasonEpisodes; - entry.seasonIndex = existing->seasonIndex; - entry.seasonId = existing->seasonId; - } - if (!entry.hasSimilarItems && existing->hasSimilarItems) { - entry.hasSimilarItems = true; - entry.similarItems = existing->similarItems; - } - if (!entry.hasCollections && existing->hasCollections) { - entry.hasCollections = true; - entry.collections = existing->collections; - } - if (!entry.hasAdditionalParts && existing->hasAdditionalParts) { - entry.hasAdditionalParts = true; - entry.additionalParts = existing->additionalParts; - } - } - if (entry.hasPlayableItem && !entry.playableItemFromServer) { - entry.hasPlayableItem = false; - entry.playableItem = MediaItem{}; - } - const QString itemFp = DetailCacheUtils::fingerprint(entry.item); - const QString sectionsFp = DetailCacheUtils::sectionsFingerprint(entry); - const bool saved = - DetailCacheUtils::save(serverId, userId, entry, &errorString); - if (saved) { - qDebug() << "[DetailView] Detail cache snapshot saved" - << "itemId=" << itemId << "reason=" << reason - << "fingerprint=" << itemFp.left(12) - << "sections=" << sectionsFp.left(12); - } else { - qWarning() << "[DetailView] Detail cache snapshot save failed" - << "itemId=" << itemId << "reason=" << reason - << "error=" << errorString; - } - }); -} - -void saveDetailImageAsync(QString serverId, QString userId, - QString ownerItemId, QString role, - QString imageItemId, QString imageType, - QString imageTag, int maxWidth, QImage image) { - if (serverId.isEmpty() || userId.isEmpty() || ownerItemId.isEmpty() || - imageTag.isEmpty() || image.isNull()) - return; - - (void)QtConcurrent::run([serverId = std::move(serverId), - userId = std::move(userId), - ownerItemId = std::move(ownerItemId), - role = std::move(role), - imageItemId = std::move(imageItemId), - imageType = std::move(imageType), - imageTag = std::move(imageTag), maxWidth, - image = std::move(image)]() mutable { - QString errorString; - const bool saved = DetailCacheUtils::saveImage( - serverId, userId, ownerItemId, role, imageItemId, imageType, imageTag, - maxWidth, image, &errorString); - if (!saved) { - qWarning() << "[DetailView] Detail image cache save failed" - << "itemId=" << ownerItemId << "role=" << role - << "error=" << errorString; - } - }); -} - -} +#include DetailView::DetailView(QEmbyCore *core, QWidget *parent) : BaseView(core, parent) { + setObjectName(QStringLiteral("detail-view")); setAttribute(Qt::WA_StyledBackground, true); - setObjectName("detail-view"); - setProperty("showGlobalSearch", true); - setupUi(); -} -void DetailView::scrollToTop() { - if (m_mainScrollArea && m_mainScrollArea->verticalScrollBar()) { - if (m_vScrollController) { - m_vScrollController->scrollTo(0, false); - } else { - m_mainScrollArea->verticalScrollBar()->setValue(0); - } - } -} + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); -void DetailView::setupUi() { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + m_quickHost = new QuickPageHost(this); + m_viewModel = new DetailViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("detailPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/DetailPage.qml"))); + layout->addWidget(m_quickHost); - m_mainScrollArea = new QScrollArea(this); - m_mainScrollArea->setWidgetResizable(true); - m_mainScrollArea->setFrameShape(QFrame::NoFrame); - m_mainScrollArea->setObjectName("detail-scrollarea"); - m_mainScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_mainScrollArea->setStyleSheet("QScrollArea { background: transparent; }"); + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this]() { m_viewModel->setActive(false); }); - m_vScrollController = - new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this); - m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast); - - m_contentWidget = new DetailContentWidget(m_mainScrollArea); - m_contentWidget->setObjectName("detail-content-container"); - - auto *contentLayout = new QVBoxLayout(m_contentWidget); - contentLayout->setContentsMargins(0, 0, 0, 10); - contentLayout->setSpacing(0); - - QWidget *infoContainer = new QWidget(m_contentWidget); - infoContainer->setObjectName("detail-info-container"); - infoContainer->setStyleSheet( - "QWidget#detail-info-container { background: transparent; }"); - - m_infoLayout = new QGridLayout(infoContainer); - m_infoLayout->setContentsMargins(40, 40, 40, 0); - m_infoLayout->setHorizontalSpacing(40); - m_infoLayout->setVerticalSpacing(0); - m_infoLayout->setRowMinimumHeight(0, 110); - - m_logoLabel = new QLabel(infoContainer); - m_logoLabel->setObjectName("detail-logo-label"); - m_logoLabel->setAlignment(Qt::AlignRight | Qt::AlignTop); - m_infoLayout->addWidget(m_logoLabel, 0, 1, 1, 2, - Qt::AlignRight | Qt::AlignTop); - - m_posterLabel = new QLabel(infoContainer); - m_posterLabel->setFixedSize(250, 375); - m_posterLabel->setObjectName("detail-poster-label"); - m_posterLabel->setAlignment(Qt::AlignCenter); - - - - - m_posterShadow = new QGraphicsDropShadowEffect(this); - m_posterShadow->setBlurRadius(20); - m_posterShadow->setColor(QColor(0, 0, 0, 80)); - m_posterShadow->setOffset(0, 8); - m_posterShadow->setEnabled(false); - m_posterLabel->setGraphicsEffect(m_posterShadow); - m_infoLayout->addWidget(m_posterLabel, 1, 0, 1, 1, Qt::AlignTop); - - m_textContainer = new QWidget(infoContainer); - m_textContainer->setMaximumWidth(1200); - m_textContainer->setSizePolicy(QSizePolicy::Expanding, - QSizePolicy::Preferred); - auto *textLayout = new QVBoxLayout(m_textContainer); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(10); - textLayout->setAlignment(Qt::AlignTop); - - m_titleLabel = new QLabel(m_textContainer); - m_titleLabel->setObjectName("detail-title"); - m_titleLabel->setWordWrap(true); - - m_metaRowWidget = new QWidget(m_textContainer); - m_metaRowWidget->setObjectName("detail-meta-row"); - auto *metaLayout = new QHBoxLayout(m_metaRowWidget); - metaLayout->setContentsMargins(0, 0, 0, 0); - metaLayout->setSpacing(6); - - m_ratingStarLabel = new QLabel(m_metaRowWidget); - m_ratingStarLabel->setFixedWidth(16); - m_ratingStarLabel->setAlignment(Qt::AlignCenter); - m_ratingStarLabel->setPixmap( - QIcon(":/svg/dark/star-filled.svg").pixmap(16, 16)); - m_ratingStarLabel->setContentsMargins(0, 2, 0, 0); - m_ratingStarLabel->hide(); - - m_metaLabel = new QLabel(m_metaRowWidget); - m_metaLabel->setObjectName("detail-meta"); - m_metaLabel->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); - - m_numberButton = new QPushButton(m_metaRowWidget); - m_numberButton->setObjectName("detail-number-chip"); - m_numberButton->setCursor(Qt::PointingHandCursor); - m_numberButton->setFocusPolicy(Qt::NoFocus); - m_numberButton->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed); - m_numberButton->hide(); - connect(m_numberButton, &QPushButton::clicked, this, - &DetailView::copyDisplayedNumber); - - metaLayout->addWidget(m_ratingStarLabel); - metaLayout->addWidget(m_metaLabel, 0, Qt::AlignLeft); - metaLayout->addWidget(m_numberButton, 0, Qt::AlignLeft); - metaLayout->addStretch(1); - m_metaRowWidget->hide(); - - m_tagsWidget = new QWidget(m_textContainer); - m_tagsWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); - m_tagsLayout = new FlowLayout(m_tagsWidget, 0, 8, 8); - - m_actionWidget = new DetailActionWidget(m_textContainer); - connect(m_actionWidget, &DetailActionWidget::playRequested, this, - [this]() -> QCoro::Task { - co_await executePlay(m_currentPlayableItem, 0); - }); - connect(m_actionWidget, &DetailActionWidget::resumeRequested, this, - [this]() -> QCoro::Task { - co_await executePlay( - m_currentPlayableItem, - m_currentPlayableItem.userData.playbackPositionTicks); - }); - connect(m_actionWidget, &DetailActionWidget::favoriteRequested, this, - [this]() { - if (!m_currentMediaItem.id.isEmpty()) - handleFavoriteRequested(m_currentMediaItem); - }); - connect(m_actionWidget, &DetailActionWidget::playedToggleRequested, this, - [this]() { - const MediaItem &target = - m_currentPlayableItem.type == "Episode" ? m_currentPlayableItem - : m_currentMediaItem; - if (target.id.isEmpty()) - return; - if (target.userData.played) - handleMarkUnplayedRequested(target); - else - handleMarkPlayedRequested(target); - }); - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [this]() { - const MediaItem &target = - m_currentPlayableItem.type == "Episode" ? m_currentPlayableItem - : m_currentMediaItem; - if (!target.id.isEmpty()) - m_actionWidget->setPlayedState(target.userData.played); - }); - connect(m_actionWidget, &DetailActionWidget::sourceVersionChanged, this, - &DetailView::onVersionChanged); - connect(m_actionWidget, &DetailActionWidget::externalPlayRequested, this, - [this](const QString &playerPath) -> QCoro::Task { - co_await executeExternalPlay(m_currentPlayableItem, playerPath); - }); - - m_taglineLabel = new QLabel(m_textContainer); - m_taglineLabel->setObjectName("detail-tagline"); - - m_overviewLabel = new QLabel(m_textContainer); - m_overviewLabel->setObjectName("detail-overview"); - m_overviewLabel->setWordWrap(true); - m_overviewLabel->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Preferred); - m_overviewLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - m_overviewLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); - connect(m_overviewLabel, &QLabel::linkActivated, this, - &DetailView::onOverviewMoreClicked); - - textLayout->addWidget(m_titleLabel); - textLayout->addWidget(m_metaRowWidget); - textLayout->addWidget(m_tagsWidget); - textLayout->addWidget(m_actionWidget); - textLayout->addSpacing(4); - textLayout->addWidget(m_taglineLabel); - textLayout->addWidget(m_overviewLabel); - textLayout->addSpacing(10); - - m_infoLayout->addWidget(m_textContainer, 1, 1, 1, 1, Qt::AlignTop); - m_infoLayout->setColumnStretch(0, 0); - m_infoLayout->setColumnStretch(1, 10); - m_infoLayout->setColumnStretch(2, 1); - contentLayout->addWidget(infoContainer); - - - - - m_seasonWidget = - new MediaSectionWidget(tr("Seasons"), m_core, m_contentWidget); - m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); - m_seasonWidget->setGalleryHeight(300); - - - m_episodeWidget = - new MediaSectionWidget(tr("Episodes"), m_core, m_contentWidget); - m_episodeWidget->setCardStyle(MediaCardDelegate::LibraryTile); - m_episodeWidget->gallery()->setHoverControls( - MediaCardDelegate::HoverControlPlay | - MediaCardDelegate::HoverControlMore); - - - { - int imgH = 100; - int imgW = qRound(imgH * 16.0 / 9.0); - const QSize episodeTileSize(imgW + 16, 8 + imgH + 6 + 20); - m_episodeTileWidth = episodeTileSize.width(); - m_episodeWidget->setTileSize(episodeTileSize); - } - m_episodeWidget->setGalleryHeight(145); - - - m_episodeHeaderControls = new QWidget(m_episodeWidget); - m_episodeHeaderControls->setObjectName("detail-episode-header-controls"); - m_episodeHeaderControls->setSizePolicy(QSizePolicy::Fixed, - QSizePolicy::Fixed); - auto *episodeHeaderLayout = new QHBoxLayout(m_episodeHeaderControls); - episodeHeaderLayout->setContentsMargins(0, 0, 0, 0); - episodeHeaderLayout->setSpacing(6); - episodeHeaderLayout->setSizeConstraint(QLayout::SetFixedSize); - - m_seasonSwitcher = new ModernMenuButton(m_episodeHeaderControls); - m_seasonSwitcher->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - m_seasonSwitcher->hide(); - - m_episodeJumpEdit = new QLineEdit(m_episodeHeaderControls); - m_episodeJumpEdit->setObjectName("detail-episode-jump-edit"); - m_episodeJumpEdit->setPlaceholderText(tr("Ep #")); - m_episodeJumpEdit->setToolTip(tr("Jump to episode number")); - m_episodeJumpEdit->setAlignment(Qt::AlignCenter); - m_episodeJumpEdit->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - m_episodeJumpEdit->setFixedWidth(64); - m_episodeJumpEdit->setFixedHeight(m_seasonSwitcher->sizeHint().height()); - m_episodeJumpEdit->setMaxLength(4); - m_episodeJumpValidator = new QIntValidator(1, 9999, m_episodeJumpEdit); - m_episodeJumpEdit->setValidator(m_episodeJumpValidator); - m_episodeJumpEdit->hide(); - - episodeHeaderLayout->addWidget(m_seasonSwitcher, 0, Qt::AlignVCenter); - episodeHeaderLayout->addWidget(m_episodeJumpEdit, 0, Qt::AlignVCenter); - m_episodeHeaderControls->hide(); - m_episodeWidget->setHeaderWidget(m_episodeHeaderControls); - m_episodeHeaderControls->hide(); - - m_episodeSectionReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-episode-section-reserve", - reserveHeightFor(m_episodeWidget, 190)); - m_seasonSectionReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-season-section-reserve", - reserveHeightFor(m_seasonWidget, 320)); - - connect(m_seasonSwitcher, &ModernMenuButton::currentIndexChanged, this, - [this](int idx) { - if (idx >= 0 && idx != m_currentSeasonIndex) { - markManualSeriesSelection(QStringLiteral("season-switcher")); - m_currentSeasonIndex = idx; - QCoro::connect(switchToSeason(idx, true), this, []() {}); - } - }); - connect(m_episodeJumpEdit, &QLineEdit::editingFinished, this, - &DetailView::submitEpisodeJump); - - m_castWidget = - new MediaSectionWidget(tr("Cast & Crew"), m_core, m_contentWidget); - m_castWidget->setCardStyle(MediaCardDelegate::Cast); - m_castWidget->setGalleryHeight(280); - - m_castSectionReserveWidget = new QWidget(m_contentWidget); - m_castSectionReserveWidget->setObjectName("detail-cast-section-reserve"); - m_castSectionReserveWidget->setFixedHeight( - qMax(320, m_castWidget->sizeHint().height())); - m_castSectionReserveWidget->hide(); - - m_additionalPartsWidget = - new MediaSectionWidget(tr("Additional Parts"), m_core, m_contentWidget); - m_additionalPartsWidget->setCardStyle(MediaCardDelegate::Poster); - m_additionalPartsWidget->setGalleryHeight(300); - m_additionalPartsSectionReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-additional-section-reserve", - reserveHeightFor(m_additionalPartsWidget, 350)); - - m_collectionWidget = - new MediaSectionWidget(tr("Collection"), m_core, m_contentWidget); - m_collectionWidget->setCardStyle(MediaCardDelegate::Poster); - m_collectionWidget->setGalleryHeight(300); - m_collectionSectionReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-collection-section-reserve", - reserveHeightFor(m_collectionWidget, 350)); - - m_similarWidget = - new MediaSectionWidget(tr("More Like This"), m_core, m_contentWidget); - m_similarWidget->setCardStyle(MediaCardDelegate::Poster); - m_similarWidget->setGalleryHeight(300); - m_similarSectionReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-similar-section-reserve", - reserveHeightFor(m_similarWidget, 350)); - - m_seasonWidget->gallery()->listView()->viewport()->installEventFilter(this); - m_episodeWidget->gallery()->listView()->viewport()->installEventFilter(this); - m_castWidget->gallery()->listView()->viewport()->installEventFilter(this); - m_additionalPartsWidget->gallery() - ->listView() - ->viewport() - ->installEventFilter(this); - m_collectionWidget->gallery()->listView()->viewport()->installEventFilter( - this); - m_similarWidget->gallery()->listView()->viewport()->installEventFilter(this); - - auto connectGallerySignals = [this](MediaSectionWidget *widget) { - connect(widget, &MediaSectionWidget::playRequested, this, - &BaseView::handlePlayRequested); - connect(widget, &MediaSectionWidget::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(widget, &MediaSectionWidget::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - }; - - connectGallerySignals(m_castWidget); - connectGallerySignals(m_additionalPartsWidget); - connectGallerySignals(m_collectionWidget); - connectGallerySignals(m_similarWidget); - - connect(m_seasonWidget, &MediaSectionWidget::playRequested, this, - [this](MediaItem item) -> QCoro::Task { - if (item.type == "Season") { - markManualSeriesSelection(QStringLiteral("season-play")); - co_await executePlaySeason(item); - } else { - handlePlayRequested(item); - } - }); - connect(m_seasonWidget, &MediaSectionWidget::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_seasonWidget, &MediaSectionWidget::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - - connect(m_episodeWidget, &MediaSectionWidget::playRequested, this, - [this](MediaItem item) -> QCoro::Task { - if (item.type == "Episode") { - markManualSeriesSelection(QStringLiteral("episode-play")); - co_await applySeriesPlayableItem(item, false); - if (m_currentPlayableItem.id == item.id) { - co_await executePlay( - m_currentPlayableItem, - m_currentPlayableItem.userData.playbackPositionTicks); - } - } else { - handlePlayRequested(item); - } - }); - connect(m_episodeWidget, &MediaSectionWidget::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_episodeWidget, &MediaSectionWidget::moreMenuRequested, this, - [this](const MediaItem &item, const QPoint &globalPos) { - MediaActionMenu menu(item, m_core, this, - {CardContextMenuAction::MarkPlayed, - CardContextMenuAction::MarkUnplayed}); - CardContextMenuRequest request = menu.execRequest(globalPos); - dispatchCardContextMenuRequest(item, request); - }); - - auto commonClicked = [this](const MediaItem &item) { - if (item.type == "BoxSet" || item.type == "Playlist" || - item.type == "Folder") - Q_EMIT navigateToFolder(item.id, item.name); - else if (item.type == "Person") - Q_EMIT navigateToPerson(item.id, item.name); - else - Q_EMIT navigateToDetail(item.id, item.name, item); - }; - - - connect(m_seasonWidget, &MediaSectionWidget::itemClicked, this, + connect(m_viewModel, &DetailViewModel::itemActivated, this, [this](const MediaItem &item) { - if (item.type == "Season") - Q_EMIT navigateToSeason(m_currentItemId, item.id, item.name); - else - Q_EMIT navigateToDetail(item.id, item.name, item); - }); - - - connect(m_episodeWidget, &MediaSectionWidget::itemClicked, this, - [this, commonClicked](const MediaItem &item) { - if (item.type == "Episode") { - markManualSeriesSelection(QStringLiteral("episode-click")); - QCoro::connect(applySeriesPlayableItem(item, true), this, - []() {}); + if (item.type == QLatin1String("Season")) { + const QString seriesId = item.seriesId.isEmpty() + ? m_viewModel->item().id + : item.seriesId; + emit navigateToSeason(seriesId, item.id, item.name); + } else if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) { + emit navigateToFolder(item.id, item.name); + } else if (item.type == QLatin1String("Person")) { + emit navigateToPerson(item.id, item.name); } else { - commonClicked(item); + emit navigateToDetail(item.id, item.name, item); } }); - - connect(m_castWidget, &MediaSectionWidget::itemClicked, this, + connect(m_viewModel, &DetailViewModel::genreActivated, this, + [this](const QString &genre) { + emit navigateToFilteredView(QStringLiteral("Genre"), genre); + }); + connect(m_viewModel, &DetailViewModel::playRequested, this, + [this](const MediaItem &item, int sourceIndex, bool resume) { + playSelected(item, sourceIndex, resume); + }); + connect(m_viewModel, &DetailViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &DetailViewModel::playedRequested, this, + [this](const MediaItem &item, bool played) { + if (played) + handleMarkPlayedRequested(item); + else + handleMarkUnplayedRequested(item); + }); + connect(m_viewModel, &DetailViewModel::moreRequested, this, [this](const MediaItem &item) { - Q_EMIT navigateToPerson(item.id, item.name); + handleMoreMenuRequested(item, QCursor::pos()); }); - connect(m_additionalPartsWidget, &MediaSectionWidget::itemClicked, this, - commonClicked); - connect(m_collectionWidget, &MediaSectionWidget::itemClicked, this, - [this, commonClicked](const MediaItem &item) { - if (!item.id.isEmpty()) { - qDebug() << "[DetailView] Navigate collection membership" - << "itemId=" << item.id << "name=" << item.name - << "type=" << item.type - << "collectionType=" << item.collectionType; - Q_EMIT navigateToFolder(item.id, item.name); - return; - } - commonClicked(item); - }); - connect(m_similarWidget, &MediaSectionWidget::itemClicked, this, - commonClicked); - - contentLayout->addWidget(m_episodeSectionReserveWidget); - contentLayout->addWidget(m_episodeWidget); - contentLayout->addWidget(m_seasonSectionReserveWidget); - contentLayout->addWidget(m_seasonWidget); - contentLayout->addWidget(m_castSectionReserveWidget); - contentLayout->addWidget(m_castWidget); - contentLayout->addWidget(m_additionalPartsSectionReserveWidget); - contentLayout->addWidget(m_additionalPartsWidget); - contentLayout->addWidget(m_collectionSectionReserveWidget); - contentLayout->addWidget(m_collectionWidget); - contentLayout->addWidget(m_similarSectionReserveWidget); - contentLayout->addWidget(m_similarWidget); - - m_bottomInfoWidget = new DetailBottomInfoWidget(m_contentWidget); - connect(m_bottomInfoWidget, &DetailBottomInfoWidget::filterClicked, this, - [this](const QString &type, const QString &value) { - Q_EMIT navigateToFilteredView(type, value); - }); - m_bottomInfoReserveWidget = createTransparentReserveWidget( - m_contentWidget, "detail-bottom-info-reserve", 220); - contentLayout->addWidget(m_bottomInfoReserveWidget); - contentLayout->addWidget(m_bottomInfoWidget); - - contentLayout->addStretch(); - m_mainScrollArea->setWidget(m_contentWidget); - mainLayout->addWidget(m_mainScrollArea); - m_mainScrollArea->viewport()->installEventFilter(this); } QCoro::Task DetailView::loadItem(const QString &itemId, const MediaItem &seedItem) { - QPointer safeThis(this); - setDeferredPresentationBatchActive(false); - m_currentItemId = itemId; - - - m_skipNextSilentRefresh = true; - m_pendingDeferredFetchId = itemId; - m_pendingFetchedItem = MediaItem{}; - m_pendingFetchReady = false; - m_pendingAnimationGuardDone = false; - m_pendingFetchedItemFromCache = false; - m_deferBottomInfoReveal = false; - m_currentPlayableItem = MediaItem{}; - m_currentSeasonEpisodes.clear(); - updateEpisodeJumpControl(0); - - m_appliedSourcesFingerprint.clear(); - m_appliedBottomInfoFingerprint.clear(); - m_detailCacheServerId.clear(); - m_detailCacheUserId.clear(); - m_cachedDetailFingerprint.clear(); - m_cachedSectionsFingerprint.clear(); - m_cachedSeriesPlayableItem = MediaItem{}; - m_cachedSeriesSeasons.clear(); - m_cachedSeasonEpisodes.clear(); - m_cachedSimilarItems.clear(); - m_cachedCollections.clear(); - m_cachedAdditionalParts.clear(); - m_cachedSeasonId.clear(); - m_manualSelectedSeasonId.clear(); - m_manualSelectedEpisodeId.clear(); - m_cachedSeasonIndex = -1; - m_manualSelectedSeasonIndex = -1; - m_hasManualSeriesSelection = false; - m_hasCachedSeriesPlayableItem = false; - m_hasCachedSeriesSeasons = false; - m_hasCachedSeasonEpisodes = false; - m_hasCachedSimilarItems = false; - m_hasCachedCollections = false; - m_hasCachedAdditionalParts = false; - m_hasCachedCastData = false; - m_appliedCachedCastToUi = false; - m_appliedCachedSeriesSeasonsToUi = false; - m_appliedCachedSeasonEpisodesToUi = false; - m_appliedCachedSimilarItemsToUi = false; - m_appliedCachedCollectionsToUi = false; - m_appliedCachedAdditionalPartsToUi = false; - m_appliedCastFingerprint.clear(); - m_appliedSeriesPlayableFingerprint.clear(); - - if (m_core && m_core->serverManager()) { - const ServerProfile profile = m_core->serverManager()->activeProfile(); - m_detailCacheServerId = profile.id; - m_detailCacheUserId = profile.userId; - } - - - - - - - - - - - - - - - - - - const bool hasSeed = !seedItem.id.isEmpty(); - - - - m_deferBottomInfoReveal = hasSeed; - - if (hasSeed) { - m_currentPlayableItem = seedItem; - applySeedToUi(seedItem); - if (seedItem.type == "Series") { - prepareReservedSection(m_episodeSectionReserveWidget, m_episodeWidget, - reserveHeightFor(m_episodeWidget, 190)); - prepareReservedSection(m_seasonSectionReserveWidget, m_seasonWidget, - reserveHeightFor(m_seasonWidget, 320)); - } else { - hideReserveWidget(m_episodeSectionReserveWidget); - hideReserveWidget(m_seasonSectionReserveWidget); - m_seasonWidget->clear(); - m_episodeWidget->clear(); - } - } else { - - m_titleLabel->setText(tr("Loading...")); - m_logoLabel->clear(); - m_logoLabel->hide(); - m_metaLabel->clear(); - updateDisplayNumber(QString()); - m_metaRowWidget->hide(); - m_overviewLabel->clear(); - m_taglineLabel->hide(); - - m_currentBackdropPix = QPixmap(); - m_currentPosterPix = QPixmap(); - updateBackdrop(); - m_posterLabel->setPixmap(QPixmap()); - - m_actionWidget->clear(); - m_bottomInfoWidget->clear(); - clearLayout(m_tagsLayout); - - m_seasonWidget->clear(); - m_episodeWidget->clear(); - hideReserveWidget(m_episodeSectionReserveWidget); - hideReserveWidget(m_seasonSectionReserveWidget); - - m_seriesSeasons.clear(); - m_currentSeasonIndex = 0; - if (m_seasonSwitcher) { - QSignalBlocker blocker(m_seasonSwitcher); - m_seasonSwitcher->clear(); - m_seasonSwitcher->hide(); - } - updateEpisodeHeaderControlsVisibility(); - } - - - - prepareReservedSection(m_castSectionReserveWidget, m_castWidget, - reserveHeightFor(m_castWidget, 320)); - prepareReservedSection(m_additionalPartsSectionReserveWidget, - m_additionalPartsWidget, - reserveHeightFor(m_additionalPartsWidget, 350)); - prepareReservedSection(m_collectionSectionReserveWidget, m_collectionWidget, - reserveHeightFor(m_collectionWidget, 350)); - prepareReservedSection(m_similarSectionReserveWidget, m_similarWidget, - reserveHeightFor(m_similarWidget, 350)); - - scrollToTop(); - - - - if (!itemId.isEmpty()) { - QCoro::connect(loadDetailCacheAndStartPrefetch( - itemId, seedItem, m_detailCacheServerId, - m_detailCacheUserId), - this, []() {}); - } - - - - // Guard tracks the page-slide duration (kSlow) so the poster shadow / deferred - // content reveal lands as the incoming slide settles; kept in sync via the token. - QTimer::singleShot(AnimationTokens::DurationMs::kSlow, this, [safeThis, itemId]() { - if (!safeThis || safeThis->m_pendingDeferredFetchId != itemId) - return; - if (safeThis->m_posterShadow) - safeThis->m_posterShadow->setEnabled(true); - safeThis->m_pendingAnimationGuardDone = true; - safeThis->maybeFlushDeferredUpdate(itemId); - }); - - co_return; + co_await m_viewModel->loadItem(itemId, seedItem); } -QCoro::Task -DetailView::loadDetailCacheAndStartPrefetch(QString itemId, MediaItem seedItem, - QString cacheServerId, - QString cacheUserId) { - QPointer safeThis(this); - QString targetId = std::move(itemId); - MediaItem seed = std::move(seedItem); - QString serverId = std::move(cacheServerId); - QString userId = std::move(cacheUserId); - QString cachedFingerprint; +void DetailView::prepareForStackLeave() { m_viewModel->setActive(false); } - if (!serverId.isEmpty() && !userId.isEmpty() && !targetId.isEmpty()) { - const auto cachedEntry = co_await QtConcurrent::run( - [serverId, userId, targetId]() mutable { - return DetailCacheUtils::load(serverId, userId, targetId); - }); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - if (cachedEntry.has_value()) { - MediaItem cachedItem = withSeedContext(cachedEntry->item, seed); - cachedFingerprint = cachedEntry->fingerprint; - - safeThis->m_cachedDetailFingerprint = cachedEntry->fingerprint; - safeThis->m_cachedSectionsFingerprint = - cachedEntry->sectionsFingerprint; - safeThis->m_cachedSeriesPlayableItem = - withSeedContext(cachedEntry->playableItem, MediaItem{}); - safeThis->m_cachedSeriesSeasons = cachedEntry->seasons; - safeThis->m_cachedSeasonEpisodes = cachedEntry->seasonEpisodes; - safeThis->m_cachedSimilarItems = cachedEntry->similarItems; - safeThis->m_cachedCollections = cachedEntry->collections; - safeThis->m_cachedAdditionalParts = cachedEntry->additionalParts; - safeThis->m_cachedSeasonId = cachedEntry->seasonId; - safeThis->m_cachedSeasonIndex = cachedEntry->seasonIndex; - safeThis->m_manualSelectedSeasonId.clear(); - safeThis->m_manualSelectedSeasonIndex = -1; - safeThis->m_manualSelectedEpisodeId.clear(); - safeThis->m_hasCachedSeriesPlayableItem = - cachedEntry->hasPlayableItem && cachedEntry->playableItemFromServer; - if (!safeThis->m_hasCachedSeriesPlayableItem) - safeThis->m_cachedSeriesPlayableItem = MediaItem{}; - safeThis->m_hasCachedSeriesSeasons = cachedEntry->hasSeasons; - safeThis->m_hasCachedSeasonEpisodes = - cachedEntry->hasSeasonEpisodes; - safeThis->m_hasCachedSimilarItems = cachedEntry->hasSimilarItems; - safeThis->m_hasCachedCollections = cachedEntry->hasCollections; - safeThis->m_hasCachedAdditionalParts = - cachedEntry->hasAdditionalParts; - safeThis->m_hasCachedCastData = !cachedItem.people.isEmpty(); - const bool hasCachedSecondaryData = - cacheEntryHasAnySecondaryData(*cachedEntry); - - if (!cachedItem.name.trimmed().isEmpty()) { - safeThis->m_pendingFetchedItem = cachedItem; - safeThis->m_pendingFetchReady = true; - safeThis->m_pendingFetchedItemFromCache = true; - - - - const bool hasSeedUi = - safeThis->m_currentMediaItem.id == targetId && - !safeThis->m_currentMediaItem.name.trimmed().isEmpty(); - if (!hasSeedUi) { - safeThis->applySeedToUi(cachedItem); - } - safeThis->maybeFlushDeferredUpdate(targetId); - - qDebug() << "[DetailView] Detail cache hit" - << "itemId=" << targetId - << "fingerprint=" << cachedFingerprint.left(12) - << "hasSecondaryCache=" << hasCachedSecondaryData - << "deferredSeedApply=" << hasSeedUi; - } else { - cachedFingerprint.clear(); - safeThis->m_cachedDetailFingerprint.clear(); - safeThis->m_cachedSectionsFingerprint.clear(); - safeThis->m_cachedSeriesPlayableItem = MediaItem{}; - safeThis->m_cachedSeriesSeasons.clear(); - safeThis->m_cachedSeasonEpisodes.clear(); - safeThis->m_cachedSimilarItems.clear(); - safeThis->m_cachedCollections.clear(); - safeThis->m_cachedAdditionalParts.clear(); - safeThis->m_cachedSeasonId.clear(); - safeThis->m_manualSelectedSeasonId.clear(); - safeThis->m_manualSelectedEpisodeId.clear(); - safeThis->m_cachedSeasonIndex = -1; - safeThis->m_manualSelectedSeasonIndex = -1; - safeThis->m_hasCachedSeriesPlayableItem = false; - safeThis->m_hasCachedSeriesSeasons = false; - safeThis->m_hasCachedSeasonEpisodes = false; - safeThis->m_hasCachedSimilarItems = false; - safeThis->m_hasCachedCollections = false; - safeThis->m_hasCachedAdditionalParts = false; - safeThis->m_hasCachedCastData = false; - qWarning() << "[DetailView] Ignore incomplete detail cache" - << "itemId=" << targetId - << "file=" << cachedEntry->filePath; - } - } else { - qDebug() << "[DetailView] Detail cache miss" - << "itemId=" << targetId; - } - } - - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - if (safeThis->m_core && safeThis->m_core->mediaService()) { - QCoro::connect(safeThis->prefetchItemDetail(targetId, serverId, userId, - cachedFingerprint), - safeThis.data(), []() {}); - } -} - -QCoro::Task DetailView::prefetchItemDetail(QString itemId, - QString cacheServerId, - QString cacheUserId, - QString cachedFingerprint) { - QPointer safeThis(this); - QString targetId = std::move(itemId); - QString serverId = std::move(cacheServerId); - QString userId = std::move(cacheUserId); - QString previousFingerprint = std::move(cachedFingerprint); - - try { - MediaItem item = - co_await m_core->mediaService()->getItemDetail(targetId); - const QString fetchedFingerprint = co_await QtConcurrent::run( - [item]() mutable { return DetailCacheUtils::fingerprint(item); }); - const bool differsFromCache = - previousFingerprint.isEmpty() || - fetchedFingerprint != previousFingerprint; - - if (differsFromCache) { - saveDetailCacheAsync(serverId, userId, item, fetchedFingerprint); - } else { - qDebug() << "[DetailView] Detail cache unchanged" - << "itemId=" << targetId - << "fingerprint=" << fetchedFingerprint.left(12); - } - - if (!safeThis || (safeThis->m_pendingDeferredFetchId != targetId && - safeThis->m_currentItemId != targetId)) { - - co_return; - } - - safeThis->m_cachedDetailFingerprint = fetchedFingerprint; - - if (safeThis->m_pendingDeferredFetchId == targetId) { - if (!differsFromCache && safeThis->m_pendingFetchReady && - safeThis->m_pendingFetchedItemFromCache) { - safeThis->maybeFlushDeferredUpdate(targetId); - co_return; - } - - - - safeThis->m_pendingFetchedItem = std::move(item); - safeThis->m_pendingFetchReady = true; - safeThis->m_pendingFetchedItemFromCache = false; - safeThis->maybeFlushDeferredUpdate(targetId); - co_return; - } - - - if (safeThis->m_currentItemId == targetId && differsFromCache) { - qDebug() << "[DetailView] Applying refreshed detail after cache" - << "itemId=" << targetId; - QCoro::connect(safeThis->executeDeferredUpdate(std::move(item), true), - safeThis.data(), []() {}); - } - } catch (...) { - if (safeThis && safeThis->m_pendingDeferredFetchId == targetId) { - if (!safeThis->m_pendingFetchReady && - safeThis->m_currentMediaItem.id != targetId) { - safeThis->m_pendingDeferredFetchId.clear(); - const QString errorText = tr("Error Loading Item"); - QMetaObject::invokeMethod( - safeThis.data(), - [safeThis, targetId, errorText]() { - if (safeThis && safeThis->m_currentItemId == targetId) - safeThis->m_titleLabel->setText(errorText); - }, - Qt::QueuedConnection); - } else { - qDebug() << "[DetailView] Detail refresh failed; using existing data" - << "itemId=" << targetId; - } - } - } -} - -void DetailView::maybeFlushDeferredUpdate(const QString &itemId) { - if (m_pendingDeferredFetchId != itemId) - return; - if (!m_pendingFetchReady || !m_pendingAnimationGuardDone) - return; - - - m_pendingDeferredFetchId.clear(); - - MediaItem item = m_pendingFetchedItem; - const bool fromCache = m_pendingFetchedItemFromCache; - m_pendingFetchedItem = MediaItem{}; - m_pendingFetchReady = false; - m_pendingAnimationGuardDone = false; - m_pendingFetchedItemFromCache = false; - - if (item.type != "Series" || m_currentPlayableItem.type != "Episode" || - m_currentPlayableItem.id.isEmpty()) { - m_currentPlayableItem = item; - } - qDebug() << "[DetailView] Deferred detail update" - << "itemId=" << itemId << "fromCache=" << fromCache; - QCoro::connect(executeDeferredUpdate(std::move(item)), this, []() {}); -} - -QCoro::Task DetailView::executeDeferredUpdate(MediaItem item, - bool isSilentRefresh) { - QPointer safeThis(this); - const QString targetId = item.id; - const QString detectedType = item.type; - const bool batchPresentation = isVisible(); - - if (batchPresentation) { - - - - setDeferredPresentationBatchActive(true); - QTimer::singleShot(160, this, [safeThis]() { - if (safeThis && safeThis->m_deferredPresentationBatchActive) - safeThis->setDeferredPresentationBatchActive(false); - }); - } - - - - - co_await updateUi(item, isSilentRefresh); - if (!safeThis) - co_return; - safeThis->persistDetailCacheSnapshot(QStringLiteral("detail-ui")); - - if (isSilentRefresh) { - safeThis->setDeferredPresentationBatchActive(false); - co_return; - } - - if (batchPresentation) { - QTimer::singleShot(0, safeThis.data(), - [safeThis, targetId, detectedType]() { - if (!safeThis) - return; - safeThis->finishDeferredPresentationBatch(targetId, - detectedType); - }); - } else { - safeThis->startSecondaryFetches(targetId, detectedType); - } -} - -void DetailView::setDeferredPresentationBatchActive(bool active) { - m_deferredPresentationBatchActive = active; -} - -void DetailView::finishDeferredPresentationBatch( - const QString &targetId, const QString &detectedType) { - if (m_currentItemId != targetId) { - setDeferredPresentationBatchActive(false); - return; - } - - updateTagLayoutHeight(); - updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); - - if (m_contentWidget && m_contentWidget->layout()) - m_contentWidget->layout()->activate(); - if (layout()) - layout()->activate(); - - setDeferredPresentationBatchActive(false); - - startSecondaryFetches(targetId, detectedType); -} - -void DetailView::startSecondaryFetches(const QString &targetId, - const QString &detectedType) { - if (targetId.isEmpty() || !m_core || !m_core->mediaService()) - return; - - const auto startNetworkFetches = [this, targetId, detectedType]() { - qDebug() << "[DetailView][network] Start secondary fetches" - << "itemId=" << targetId << "type=" << detectedType; - QCoro::connect( - executeFetchSecondaries(QPointer(this), m_core, targetId, - detectedType), - this, []() {}); - }; - - if (detectedType == "Series") { - const bool hasCachedSeriesSecondaryUi = - m_hasCachedCastData || m_hasCachedSeriesPlayableItem || - m_hasCachedSeriesSeasons || m_hasCachedSeasonEpisodes || - m_hasCachedSimilarItems || m_hasCachedCollections || - m_hasCachedAdditionalParts; - startNetworkFetches(); - if (hasCachedSeriesSecondaryUi) { - applyCachedSecondaryData(targetId, detectedType); - } else { - qDebug() << "[DetailView][cache-ui] Skip cached secondary data" - << "itemId=" << targetId << "type=" << detectedType - << "reason=" << "no-cached-series-secondary-data"; - } - return; - } - - applyCachedSecondaryData(targetId, detectedType); - startNetworkFetches(); -} - -void DetailView::applyCachedSecondaryData(const QString &targetId, - const QString &detectedType, - bool revealBottomInfo) { - if (targetId != m_currentItemId) - return; - - qDebug() << "[DetailView][cache-ui] Applying cached secondary data" - << "itemId=" << targetId << "type=" << detectedType - << "sections=" << m_cachedSectionsFingerprint.left(12); - - if (detectedType == "Series") { - if (!m_hasManualSeriesSelection && m_hasCachedSeriesPlayableItem && - m_cachedSeriesPlayableItem.type == "Episode" && - !m_cachedSeriesPlayableItem.id.isEmpty()) { - if (m_currentPlayableItem.id != m_cachedSeriesPlayableItem.id) - applySeriesPlayableItemToUi(m_cachedSeriesPlayableItem); - } - - if (m_hasCachedSeriesSeasons && !m_appliedCachedSeriesSeasonsToUi) { - m_seriesSeasons = m_cachedSeriesSeasons; - int targetSeasonIdx = 0; - const int cachedEpisodeSeasonIndex = m_cachedSeasonIndex; - const QString cachedEpisodeSeasonId = m_cachedSeasonId; - bool selectedSeasonResolved = false; - if (m_hasManualSeriesSelection && m_currentSeasonIndex >= 0 && - m_currentSeasonIndex < m_seriesSeasons.size()) { - targetSeasonIdx = m_currentSeasonIndex; - selectedSeasonResolved = true; - } - const int playableParentIdx = m_currentPlayableItem.parentIndexNumber; - if (!selectedSeasonResolved && !m_hasManualSeriesSelection && - playableParentIdx >= 0) { - for (int i = 0; i < m_seriesSeasons.size(); ++i) { - if (m_seriesSeasons[i].indexNumber == playableParentIdx) { - targetSeasonIdx = i; - break; - } - } - } - if (!m_seriesSeasons.isEmpty()) { - targetSeasonIdx = qBound(0, targetSeasonIdx, - m_seriesSeasons.size() - 1); - m_currentSeasonIndex = targetSeasonIdx; - updateSeasonSwitcher(targetSeasonIdx); - } - - if (m_hasCachedSeasonEpisodes && - cachedEpisodeSeasonIndex == targetSeasonIdx && - cachedEpisodeSeasonId == - (m_seriesSeasons.isEmpty() - ? QString() - : m_seriesSeasons[targetSeasonIdx].id) && - !m_seriesSeasons.isEmpty() && - episodesBelongToSeason(m_cachedSeasonEpisodes, - m_seriesSeasons[targetSeasonIdx])) { - m_currentSeasonEpisodes = m_cachedSeasonEpisodes; - if (!m_appliedCachedSeasonEpisodesToUi && m_episodeWidget) { - applyReservedSectionItems(m_episodeSectionReserveWidget, - m_episodeWidget, - m_currentSeasonEpisodes); - updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); - const QString highlightedEpisodeId = m_currentPlayableItem.id; - if (!highlightedEpisodeId.isEmpty()) { - m_episodeWidget->gallery()->setHighlightedItemId( - highlightedEpisodeId); - m_episodeWidget->gallery()->scrollToItemId(highlightedEpisodeId); - } - } - m_appliedCachedSeasonEpisodesToUi = true; - } - - if (m_seasonWidget) { - m_seasonWidget->setTitle(tr("Seasons")); - m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); - m_seasonWidget->setGalleryHeight(300); - applyReservedSectionItems(m_seasonSectionReserveWidget, m_seasonWidget, - m_seriesSeasons); - m_appliedCachedSeriesSeasonsToUi = true; - } - } - } - - - if (m_hasCachedCastData && !m_appliedCachedCastToUi) { - applyCastSection(m_currentMediaItem); - m_appliedCachedCastToUi = true; - } - if (revealBottomInfo && m_deferBottomInfoReveal) { - m_deferBottomInfoReveal = false; - hideReserveWidget(m_bottomInfoReserveWidget); - if (m_bottomInfoWidget) - m_bottomInfoWidget->show(); - } - - - - - if (m_hasCachedAdditionalParts && !m_appliedCachedAdditionalPartsToUi) { - applyReservedSectionItems(m_additionalPartsSectionReserveWidget, - m_additionalPartsWidget, - m_cachedAdditionalParts); - m_appliedCachedAdditionalPartsToUi = true; - } else if (!m_hasCachedAdditionalParts && - (m_hasCachedCollections || m_hasCachedSimilarItems)) { - applyReservedSectionItems(m_additionalPartsSectionReserveWidget, - m_additionalPartsWidget, {}); - } - - if (m_hasCachedCollections && !m_appliedCachedCollectionsToUi) { - applyCollectionGalleryLayout(m_cachedCollections); - applyReservedSectionItems(m_collectionSectionReserveWidget, - m_collectionWidget, m_cachedCollections); - m_appliedCachedCollectionsToUi = true; - } else if (!m_hasCachedCollections && m_hasCachedSimilarItems) { - applyReservedSectionItems(m_collectionSectionReserveWidget, - m_collectionWidget, {}); - } - - if (m_hasCachedSimilarItems && !m_appliedCachedSimilarItemsToUi) { - applyReservedSectionItems(m_similarSectionReserveWidget, m_similarWidget, - m_cachedSimilarItems); - m_appliedCachedSimilarItemsToUi = true; - } -} - -void DetailView::persistDetailCacheSnapshot(const QString &reason) { - if (m_detailCacheServerId.isEmpty() || m_detailCacheUserId.isEmpty() || - m_currentMediaItem.id.isEmpty()) - return; - - DetailCacheUtils::DetailCacheEntry entry; - entry.item = m_currentMediaItem; - const bool isSeries = m_currentMediaItem.type == "Series"; - entry.hasPlayableItem = isSeries && m_currentPlayableItem.type == "Episode" && - !m_currentPlayableItem.id.isEmpty(); - if (entry.hasPlayableItem) - entry.playableItem = m_currentPlayableItem; - entry.hasSeasons = isSeries && m_hasCachedSeriesSeasons; - entry.seasons = m_cachedSeriesSeasons; - entry.hasSeasonEpisodes = isSeries && m_hasCachedSeasonEpisodes; - entry.seasonEpisodes = m_cachedSeasonEpisodes; - entry.seasonIndex = isSeries ? m_cachedSeasonIndex : -1; - entry.seasonId = isSeries ? m_cachedSeasonId : QString(); - entry.playableItemFromServer = entry.hasPlayableItem; - entry.hasSimilarItems = m_hasCachedSimilarItems; - entry.similarItems = m_cachedSimilarItems; - entry.hasCollections = m_hasCachedCollections; - entry.collections = m_cachedCollections; - entry.hasAdditionalParts = m_hasCachedAdditionalParts; - entry.additionalParts = m_cachedAdditionalParts; - - saveDetailCacheEntryAsync(m_detailCacheServerId, m_detailCacheUserId, entry, - reason); -} - -void DetailView::updateMetaRow(const MediaItem &item, - const QString &leadingMeta) { - QStringList metas; - if (item.communityRating > 0) { - m_ratingStarLabel->show(); - metas << QString::number(item.communityRating, 'f', 1); - } else { - m_ratingStarLabel->hide(); - } - if (!leadingMeta.isEmpty()) - metas << leadingMeta; - const QString releaseDate = formatDetailReleaseDate(item); - if (!releaseDate.isEmpty()) - metas << releaseDate; - if (item.runTimeTicks > 0) - metas << formatRunTime(item.runTimeTicks); - if (!item.officialRating.isEmpty()) - metas << item.officialRating; - - m_metaLabel->setText(metas.join(" \u2022 ")); - m_metaRowWidget->show(); -} - - - - - - -void DetailView::applySeedToUi(const MediaItem &seed) { - m_currentMediaItem = seed; - m_titleLabel->setText(seed.name); - - - updateMetaRow(seed, formatEpisodeTag(seed)); - - - const bool shouldShowNumber = shouldShowDisplayNumber(seed); - const QString displayNumber = - shouldShowNumber ? extractDisplayNumber(seed) : QString(); - updateDisplayNumber(displayNumber); - - - QString text = seed.overview; - text.replace(QRegularExpression("", - QRegularExpression::CaseInsensitiveOption), - "\n"); - text.replace("

", "\n\n"); - text.remove(QRegularExpression("<[^>]*>")); - QTextDocument doc; - doc.setHtml(text); - QString cleanText = doc.toPlainText(); - cleanText.replace(QChar::LineSeparator, '\n'); - cleanText.remove(QRegularExpression("<[^>]*>")); - m_cleanOverviewText = cleanText; - m_lastOverviewWidth = -1; - updateOverviewElidedText(); - - - if (!seed.taglines.isEmpty()) { - m_taglineLabel->setText(seed.taglines.first()); - m_taglineLabel->show(); - } else { - m_taglineLabel->hide(); - } - - - m_isFavorite = seed.isFavorite(); - m_actionWidget->setFavoriteState(m_isFavorite); - m_actionWidget->setPlayedState(seed.userData.played); - - - - - - if (seed.type == "Series") { - m_actionWidget->setSeriesLoadingMode(); - } else { - m_actionWidget->setupNormalMode(seed); - } - - - - - - - if (!seed.mediaSources.isEmpty()) { - m_actionWidget->setSources(seed.mediaSources, 0); - if (!seed.mediaSources.first().mediaStreams.isEmpty()) { - m_actionWidget->setStreams(seed.mediaSources.first()); - } - m_appliedSourcesFingerprint = computeSourcesFingerprint(seed.mediaSources); - } else { - m_actionWidget->setSources(QList{}, 0); - m_actionWidget->setStreams(MediaSourceInfo{}); - m_appliedSourcesFingerprint = - computeSourcesFingerprint(QList{}); - } - m_actionWidget->refreshExtPlayerButton(); - - - - - buildTagButtons(seed.genres); - - - - - - - - QList seedDefaultSource; - if (!seed.mediaSources.isEmpty()) - seedDefaultSource.append(seed.mediaSources.first()); - m_bottomInfoWidget->setInfo(seed, seedDefaultSource); - m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); - if (m_deferBottomInfoReveal) { - showReserveWidget(m_bottomInfoReserveWidget, - reserveHeightFor(m_bottomInfoWidget, 220)); - } else { - hideReserveWidget(m_bottomInfoReserveWidget); - } - m_appliedBottomInfoFingerprint = - computeBottomInfoFingerprint(seed, seedDefaultSource); - - - - m_currentBackdropPix = QPixmap(); - updateBackdrop(); - - - - - - - QCoro::connect(executeLoadImages(QPointer(this), m_core, seed, - false), - this, []() {}); -} - - - - - -void DetailView::buildTagButtons(const QStringList &genres) { - if (!m_tagsWidget || !m_tagsLayout) - return; - - - if (m_tagsLayout->count() == genres.size()) { - bool same = true; - for (int i = 0; i < genres.size(); ++i) { - QLayoutItem *layoutItem = m_tagsLayout->itemAt(i); - QWidget *w = layoutItem ? layoutItem->widget() : nullptr; - auto *btn = qobject_cast(w); - if (!btn || btn->text() != genres[i]) { - same = false; - break; - } - } - if (same) - return; - } - - m_tagsWidget->setUpdatesEnabled(false); - clearLayout(m_tagsLayout); - for (const QString &genre : genres) { - auto *tagBtn = new DetailTagButton(genre, m_tagsWidget); - connect(tagBtn, &QPushButton::clicked, this, - [this, genre]() { Q_EMIT navigateToFilteredView("Genre", genre); }); - m_tagsLayout->addWidget(tagBtn); - } - m_tagsWidget->setUpdatesEnabled(true); - updateTagLayoutHeight(); - QTimer::singleShot(0, this, [this]() { updateTagLayoutHeight(); }); -} - -void DetailView::applyCastSection(const MediaItem &item) { - if (!m_castWidget) - return; - - const QString fp = castFingerprint(item); - if (!fp.isEmpty() && fp == m_appliedCastFingerprint) - return; - - if (item.people.isEmpty()) { - applyReservedSectionItems(m_castSectionReserveWidget, m_castWidget, {}); - m_appliedCastFingerprint = fp; - return; - } - - QList castItems; - castItems.reserve(item.people.size()); - for (const auto &person : item.people) { - MediaItem fakeItem; - fakeItem.id = person.id; - fakeItem.name = person.name; - fakeItem.images.primaryTag = person.primaryImageTag; - - QStringList personParts; - if (!person.type.isEmpty()) { - - static const QHash typeMap = { - {"Actor", QT_TR_NOOP("Actor")}, - {"Director", QT_TR_NOOP("Director")}, - {"Writer", QT_TR_NOOP("Writer")}, - {"Producer", QT_TR_NOOP("Producer")}, - {"Composer", QT_TR_NOOP("Composer")}, - {"Conductor", QT_TR_NOOP("Conductor")}, - {"GuestStar", QT_TR_NOOP("GuestStar")}, - {"Editor", QT_TR_NOOP("Editor")}, - {"Lyricist", QT_TR_NOOP("Lyricist")}, - }; - auto it = typeMap.constFind(person.type); - personParts << (it != typeMap.constEnd() ? tr(it.value()) - : person.type); - } - if (!person.role.isEmpty()) - personParts << person.role; - fakeItem.overview = personParts.join(" · "); - fakeItem.type = "Person"; - castItems.append(fakeItem); - } - - applyReservedSectionItems(m_castSectionReserveWidget, m_castWidget, - castItems); - m_appliedCastFingerprint = fp; -} - -void DetailView::applyCollectionGalleryLayout( - const QList &collections) { - int landscapeSignals = 0; - int portraitSignals = 0; - - const auto hasLandscapeArtwork = [](const MediaItem &item) { - const double primaryAspectRatio = item.images.primaryImageAspectRatio; - if (primaryAspectRatio > 1.05) - return true; - if (primaryAspectRatio > 0.0) - return false; - - if (!item.images.primaryTag.isEmpty()) - return false; - - return !item.images.thumbTag.isEmpty() || - !item.images.backdropTag.isEmpty() || - !item.images.parentThumbTag.isEmpty() || - !item.images.parentBackdropTag.isEmpty(); - }; - - for (const MediaItem &collection : collections) { - if (hasLandscapeArtwork(collection)) { - ++landscapeSignals; - } else if (!collection.images.primaryTag.isEmpty() || - collection.images.primaryImageAspectRatio > 0.0) { - ++portraitSignals; - } - } - - const bool useLandscapeCards = - landscapeSignals > 0 && landscapeSignals > portraitSignals; - - if (useLandscapeCards) { - m_collectionWidget->setCardStyle(MediaCardDelegate::LibraryTile); - m_collectionWidget->setGalleryHeight(230); - } else { - m_collectionWidget->setCardStyle(MediaCardDelegate::Poster); - m_collectionWidget->setGalleryHeight(300); - } - - qDebug() << "[DetailView] Collection gallery layout" - << "itemCount=" << collections.size() - << "landscapeSignals=" << landscapeSignals - << "portraitSignals=" << portraitSignals - << "style=" - << (useLandscapeCards ? "LibraryTile" : "Poster"); -} - - - - - - - -QCoro::Task -DetailView::executeFetchSecondaries(QPointer safeThis, - QEmbyCore *core, QString targetId, - QString itemType) { - if (!safeThis) - co_return; - - - - if (itemType == "Series") { - if (!safeThis->m_hasManualSeriesSelection && - safeThis->m_hasCachedSeriesPlayableItem && - safeThis->m_cachedSeriesPlayableItem.type == "Episode" && - !safeThis->m_cachedSeriesPlayableItem.id.isEmpty()) { - safeThis->applySeriesPlayableItemToUi( - safeThis->m_cachedSeriesPlayableItem); - qDebug() << "[DetailView][cache-ui] Applied cached series playable item" - << "seriesId=" << targetId - << "episodeId=" << safeThis->m_cachedSeriesPlayableItem.id; - } - qDebug() << "[DetailView][network] Fetch series next-up" - << "seriesId=" << targetId - << "applyToUi=" << !safeThis->m_hasManualSeriesSelection; - co_await safeThis->fetchSeriesNextUp( - targetId, !safeThis->m_hasManualSeriesSelection); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - - qDebug() << "[DetailView][network] Fetch series seasons" - << "seriesId=" << targetId; - auto seasons = co_await core->mediaService()->getSeasons(targetId); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - bool seasonsUnchanged = false; - if (safeThis->m_appliedCachedSeriesSeasonsToUi) { - seasonsUnchanged = co_await mediaItemListsEqualAsync( - safeThis->m_cachedSeriesSeasons, seasons); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - } - safeThis->m_seriesSeasons = seasons; - safeThis->m_cachedSeriesSeasons = seasons; - safeThis->m_hasCachedSeriesSeasons = true; - - - - int targetSeasonIdx = 0; - bool selectedSeasonResolved = false; - if (safeThis->m_hasManualSeriesSelection && - !safeThis->m_manualSelectedSeasonId.isEmpty()) { - for (int i = 0; i < seasons.size(); ++i) { - if (seasons[i].id == safeThis->m_manualSelectedSeasonId) { - targetSeasonIdx = i; - selectedSeasonResolved = true; - break; - } - } - } - if (!selectedSeasonResolved && - safeThis->m_hasManualSeriesSelection && - safeThis->m_manualSelectedSeasonIndex >= 0 && - safeThis->m_manualSelectedSeasonIndex < seasons.size()) { - targetSeasonIdx = safeThis->m_manualSelectedSeasonIndex; - selectedSeasonResolved = true; - } - if (!selectedSeasonResolved && - safeThis->m_hasManualSeriesSelection && - safeThis->m_currentSeasonIndex >= 0 && - safeThis->m_currentSeasonIndex < seasons.size()) { - targetSeasonIdx = safeThis->m_currentSeasonIndex; - selectedSeasonResolved = true; - } - int playableParentIdx = safeThis->m_currentPlayableItem.parentIndexNumber; - if (!selectedSeasonResolved && - !safeThis->m_hasManualSeriesSelection && playableParentIdx >= 0) { - for (int i = 0; i < seasons.size(); ++i) { - if (seasons[i].indexNumber == playableParentIdx) { - targetSeasonIdx = i; - break; - } - } - } - if (!seasons.isEmpty()) - targetSeasonIdx = qBound(0, targetSeasonIdx, seasons.size() - 1); - safeThis->m_currentSeasonIndex = targetSeasonIdx; - - - if (!seasons.isEmpty() && safeThis->m_episodeWidget) { - safeThis->updateSeasonSwitcher(targetSeasonIdx); - - const QString playableItemId = - safeThis->m_hasManualSeriesSelection && - !safeThis->m_manualSelectedEpisodeId.isEmpty() - ? safeThis->m_manualSelectedEpisodeId - : safeThis->m_currentPlayableItem.id; - co_await safeThis->loadEpisodesForSeason( - targetSeasonIdx, playableItemId, !playableItemId.isEmpty(), - safeThis->m_hasManualSeriesSelection); - } else if (safeThis->m_episodeWidget) { - applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, - safeThis->m_episodeWidget, {}); - } - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - - if (safeThis->m_seasonWidget && !seasonsUnchanged) { - safeThis->m_seasonWidget->setTitle(tr("Seasons")); - safeThis->m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); - safeThis->m_seasonWidget->setGalleryHeight(300); - applyReservedSectionItems(safeThis->m_seasonSectionReserveWidget, - safeThis->m_seasonWidget, seasons); - } - } - - - if (safeThis->m_currentItemId != targetId) - co_return; - qDebug() << "[DetailView][ui] Apply cast section" - << "itemId=" << targetId - << "afterSeriesSections=" << (itemType == "Series"); - safeThis->applyCastSection(safeThis->m_currentMediaItem); - if (safeThis->m_deferBottomInfoReveal) { - safeThis->m_deferBottomInfoReveal = false; - hideReserveWidget(safeThis->m_bottomInfoReserveWidget); - if (safeThis->m_bottomInfoWidget) - safeThis->m_bottomInfoWidget->show(); - } - if (!safeThis) - co_return; - - - - - try { - qDebug() << "[DetailView][network] Fetch similar items" - << "itemId=" << targetId; - QList similar = - co_await core->mediaService()->getSimilarItems(targetId, 15); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - bool similarUnchanged = false; - if (safeThis->m_appliedCachedSimilarItemsToUi) { - similarUnchanged = co_await mediaItemListsEqualAsync( - safeThis->m_cachedSimilarItems, similar); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - } - safeThis->m_cachedSimilarItems = similar; - safeThis->m_hasCachedSimilarItems = true; - if (!similarUnchanged) { - applyReservedSectionItems(safeThis->m_similarSectionReserveWidget, - safeThis->m_similarWidget, similar); - } - } catch (...) { - if (safeThis && safeThis->m_currentItemId == targetId && - !safeThis->m_appliedCachedSimilarItemsToUi) { - applyReservedSectionItems(safeThis->m_similarSectionReserveWidget, - safeThis->m_similarWidget, {}); - } - } - if (!safeThis) - co_return; - - - - try { - qDebug() << "[DetailView][network] Fetch item collections" - << "itemId=" << targetId; - QList collections = - co_await core->mediaService()->getItemCollections(targetId); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - bool collectionsUnchanged = false; - if (safeThis->m_appliedCachedCollectionsToUi) { - collectionsUnchanged = co_await mediaItemListsEqualAsync( - safeThis->m_cachedCollections, collections); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - } - safeThis->m_cachedCollections = collections; - safeThis->m_hasCachedCollections = true; - if (!collectionsUnchanged) { - safeThis->applyCollectionGalleryLayout(collections); - applyReservedSectionItems(safeThis->m_collectionSectionReserveWidget, - safeThis->m_collectionWidget, collections); - } - } catch (...) { - if (safeThis && safeThis->m_currentItemId == targetId && - !safeThis->m_appliedCachedCollectionsToUi) { - applyReservedSectionItems(safeThis->m_collectionSectionReserveWidget, - safeThis->m_collectionWidget, {}); - } - } - if (!safeThis) - co_return; - - - try { - qDebug() << "[DetailView][network] Fetch additional parts" - << "itemId=" << targetId; - QList additionalParts = - co_await core->mediaService()->getAdditionalParts(targetId); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - - bool additionalPartsUnchanged = false; - if (safeThis->m_appliedCachedAdditionalPartsToUi) { - additionalPartsUnchanged = co_await mediaItemListsEqualAsync( - safeThis->m_cachedAdditionalParts, additionalParts); - if (!safeThis || safeThis->m_currentItemId != targetId) - co_return; - } - safeThis->m_cachedAdditionalParts = additionalParts; - safeThis->m_hasCachedAdditionalParts = true; - if (!additionalPartsUnchanged) { - applyReservedSectionItems(safeThis->m_additionalPartsSectionReserveWidget, - safeThis->m_additionalPartsWidget, - additionalParts); - } - } catch (...) { - if (safeThis && safeThis->m_currentItemId == targetId && - !safeThis->m_appliedCachedAdditionalPartsToUi) { - applyReservedSectionItems( - safeThis->m_additionalPartsSectionReserveWidget, - safeThis->m_additionalPartsWidget, {}); - } - } - - if (safeThis && safeThis->m_currentItemId == targetId) - safeThis->persistDetailCacheSnapshot(QStringLiteral("secondaries")); -} - -QCoro::Task DetailView::fetchSeriesNextUp(const QString &targetId, - bool applyToUi) { - QPointer safeThis(this); - try { - MediaItem playableItem; - qDebug() << "[DetailView][network] Fetch next-up" - << "seriesId=" << targetId; - auto nextUp = co_await m_core->mediaService()->getNextUp(targetId); - - if (!nextUp.isEmpty()) - playableItem = nextUp.first(); - else { - qDebug() << "[DetailView][network] Fetch seasons for next-up fallback" - << "seriesId=" << targetId; - auto seasons = co_await m_core->mediaService()->getSeasons(targetId); - if (!seasons.isEmpty()) { - qDebug() << "[DetailView][network] Fetch episodes for next-up fallback" - << "seriesId=" << targetId - << "seasonId=" << seasons.first().id; - auto episodes = co_await m_core->mediaService()->getEpisodes( - targetId, seasons.first().id); - if (!episodes.isEmpty()) - playableItem = episodes.first(); - } - } - - if (safeThis && safeThis->m_currentItemId == targetId && - !playableItem.id.isEmpty()) { - safeThis->m_cachedSeriesPlayableItem = playableItem; - safeThis->m_hasCachedSeriesPlayableItem = true; - - if (!applyToUi || safeThis->m_hasManualSeriesSelection) { - qDebug() << "[DetailView][network] Updated server next-up cache without " - "changing manual selection" - << "seriesId=" << targetId - << "episodeId=" << playableItem.id - << "applyToUi=" << applyToUi - << "manualSelection=" - << safeThis->m_hasManualSeriesSelection; - safeThis->persistDetailCacheSnapshot( - QStringLiteral("series-server-next-up")); - co_return; - } - - co_await safeThis->applySeriesPlayableItem(playableItem); - } - } catch (...) { - } -} - -void DetailView::applySeriesPlayableItemToUi(const MediaItem &playableItem, - bool scrollToEpisode) { - if (playableItem.id.isEmpty() || m_currentMediaItem.type != "Series") - return; - - const QString playableFingerprint = - DetailCacheUtils::fingerprint(playableItem); - const bool playableUnchanged = - !m_appliedSeriesPlayableFingerprint.isEmpty() && - playableFingerprint == m_appliedSeriesPlayableFingerprint; - m_currentPlayableItem = playableItem; - const QString episodeTag = formatEpisodeTag(playableItem); - - if (!playableUnchanged) { - const QString actionTag = - episodeTag.isEmpty() ? playableItem.name : episodeTag; - m_actionWidget->setupSeriesMode(playableItem, actionTag); - updateMetaRow(m_currentMediaItem, episodeTag); - - m_actionWidget->setSources(playableItem.mediaSources, 0); - m_appliedSourcesFingerprint = - computeSourcesFingerprint(playableItem.mediaSources); - - QList selectedSources; - if (!playableItem.mediaSources.isEmpty()) { - int sourceIndex = m_actionWidget->currentSourceIndex(); - if (sourceIndex >= playableItem.mediaSources.size()) - sourceIndex = 0; - - const MediaSourceInfo &selectedSource = - playableItem.mediaSources[sourceIndex]; - m_actionWidget->setStreams(selectedSource); - selectedSources.append(selectedSource); - } else { - m_actionWidget->setStreams(MediaSourceInfo{}); - } - - if (!selectedSources.isEmpty()) { - const QString bottomInfoFingerprint = - computeBottomInfoFingerprint(playableItem, selectedSources); - if (bottomInfoFingerprint != m_appliedBottomInfoFingerprint) { - m_bottomInfoWidget->setInfo(playableItem, selectedSources); - m_appliedBottomInfoFingerprint = bottomInfoFingerprint; - m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); - if (m_deferBottomInfoReveal) { - showReserveWidget(m_bottomInfoReserveWidget, - reserveHeightFor(m_bottomInfoWidget, 220)); - } else { - hideReserveWidget(m_bottomInfoReserveWidget); - } - } else { - m_bottomInfoWidget->setVisible(!m_deferBottomInfoReveal); - } - } - - m_appliedSeriesPlayableFingerprint = playableFingerprint; - } - - m_actionWidget->refreshExtPlayerButton(); - m_actionWidget->setPlayedState(playableItem.userData.played); - if (m_episodeWidget) { - m_episodeWidget->gallery()->setHighlightedItemId(playableItem.id); - if (scrollToEpisode) - m_episodeWidget->gallery()->scrollToItemId(playableItem.id); - } - - qDebug() << "[DetailView] Apply series playable item" - << "seriesId=" << m_currentItemId << "episodeId=" << playableItem.id - << "episodeTag=" << episodeTag - << "sourceCount=" << playableItem.mediaSources.size(); -} - -QCoro::Task DetailView::applySeriesPlayableItem(MediaItem playableItem, - bool scrollToEpisode) { - if (playableItem.id.isEmpty() || m_currentMediaItem.type != "Series") - co_return; - - QPointer safeThis(this); - const QString seriesId = m_currentItemId; - const QString playableItemId = playableItem.id; - - applySeriesPlayableItemToUi(playableItem, scrollToEpisode); - rememberSeriesSelection(playableItem, QStringLiteral("series-selection"), - true); - - const bool needsPlaybackInfo = - playableItem.mediaSources.isEmpty() || - playableItem.mediaSources.first().mediaStreams.isEmpty(); - if (!needsPlaybackInfo) - co_return; - - try { - PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(playableItemId); - if (!safeThis || safeThis->m_currentItemId != seriesId || - safeThis->m_currentMediaItem.type != "Series" || - safeThis->m_currentPlayableItem.id != playableItemId) - co_return; - if (!info.mediaSources.isEmpty()) { - playableItem.mediaSources = info.mediaSources; - safeThis->applySeriesPlayableItemToUi(playableItem, false); - safeThis->rememberSeriesSelection( - playableItem, QStringLiteral("series-selection-playback-info"), - true); - } - } catch (...) { - } -} - -QCoro::Task DetailView::onVersionChanged(int index) { - const bool usePlayableItem = - m_currentMediaItem.type == "Series" && !m_currentPlayableItem.id.isEmpty(); - MediaItem actionItem = usePlayableItem ? m_currentPlayableItem - : m_currentMediaItem; - if (index < 0 || index >= actionItem.mediaSources.size()) - co_return; - MediaSourceInfo source = actionItem.mediaSources[index]; - QPointer safeThis(this); - const QString expectedPageId = m_currentItemId; - const QString actionItemId = actionItem.id; - - if (source.mediaStreams.isEmpty()) { - try { - PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(actionItemId); - if (!safeThis || safeThis->m_currentItemId != expectedPageId) - co_return; - if (!info.mediaSources.isEmpty()) { - if (usePlayableItem) { - if (safeThis->m_currentPlayableItem.id != actionItemId) - co_return; - safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; - actionItem = safeThis->m_currentPlayableItem; - } else { - safeThis->m_currentMediaItem.mediaSources = info.mediaSources; - - - - safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; - actionItem = safeThis->m_currentMediaItem; - } - - int selectedIndex = index; - if (selectedIndex >= actionItem.mediaSources.size()) - selectedIndex = 0; - if (selectedIndex < actionItem.mediaSources.size()) { - source = actionItem.mediaSources[selectedIndex]; - } - } - } catch (...) { - co_return; - } - } - if (!safeThis || source.mediaStreams.isEmpty()) - co_return; - - safeThis->m_actionWidget->setStreams(source); - QList singleSource; - singleSource.append(source); - const MediaItem infoItem = - usePlayableItem ? safeThis->m_currentPlayableItem - : safeThis->m_currentMediaItem; - safeThis->m_bottomInfoWidget->setInfo(infoItem, singleSource); - hideReserveWidget(safeThis->m_bottomInfoReserveWidget); -} - -QCoro::Task DetailView::executePlay(MediaItem targetItem, - long long startTicks) { - if (targetItem.id.isEmpty()) - co_return; - try { - MediaItem actualItem = targetItem; - if (actualItem.mediaSources.isEmpty()) { - PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); - actualItem.mediaSources = info.mediaSources; - } - if (actualItem.mediaSources.isEmpty()) - co_return; - - int sourceIdx = 0; - int selectedAudioIdx = -1; - int selectedSubIdx = -1; - const bool useUiSelection = isCurrentActionItem(actualItem.id); - - if (useUiSelection) { - - sourceIdx = m_actionWidget->currentSourceIndex(); - selectedAudioIdx = m_actionWidget->currentAudioIndex(); - selectedSubIdx = m_actionWidget->currentSubtitleIndex(); - } else { - sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( - actualItem.mediaSources, - ConfigStore::instance() - ->get(ConfigKeys::PlayerPreferredVersion) - .trimmed()); - } - - if (sourceIdx >= actualItem.mediaSources.size()) - sourceIdx = 0; - MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; - - if (useUiSelection) { - - - - for (auto &stream : modifiedSource.mediaStreams) { - if (stream.type == "Audio" && selectedAudioIdx >= 0) - stream.isDefault = (stream.index == selectedAudioIdx); - else if (stream.type == "Subtitle" && selectedSubIdx >= 0) - stream.isDefault = (stream.index == selectedSubIdx); - } - } else { - PlayerPreferenceUtils::applyPreferredStreamRules( - modifiedSource, - ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, - "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, - "auto")); - } - - QString streamUrl = - m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); - - const QString playTitle = - MediaItemUtils::playbackTitle(actualItem, m_currentMediaItem.name); - - - PlaybackManager::instance()->startInternalPlayback( - actualItem.id, playTitle, streamUrl, startTicks, - QVariant::fromValue(modifiedSource)); - } catch (...) { - } -} - -QCoro::Task DetailView::executePlaySeason(MediaItem seasonItem) { - if (seasonItem.id.isEmpty() || m_currentItemId.isEmpty() || !m_core || - !m_core->mediaService()) - co_return; - - QPointer safeThis(this); - const QString seriesId = m_currentItemId; - const QString seasonId = seasonItem.id; - - try { - QList episodes = - co_await m_core->mediaService()->getEpisodes(seriesId, seasonId); - if (!safeThis || safeThis->m_currentItemId != seriesId) - co_return; - - MediaItem targetEpisode = resolveSeasonPlaybackEpisode(episodes); - if (targetEpisode.id.isEmpty()) - co_return; - - qDebug() << "[DetailView] Play season" - << "seriesId=" << seriesId << "seasonId=" << seasonId - << "episodeId=" << targetEpisode.id - << "episodeTag=" << formatEpisodeTag(targetEpisode) - << "episodeCount=" << episodes.size() - << "startTicks=" - << targetEpisode.userData.playbackPositionTicks; - - co_await safeThis->applySeriesPlayableItem(targetEpisode, false); - if (!safeThis || safeThis->m_currentItemId != seriesId) - co_return; - - const MediaItem playbackItem = - safeThis->m_currentPlayableItem.id == targetEpisode.id - ? safeThis->m_currentPlayableItem - : targetEpisode; - co_await safeThis->executePlay( - playbackItem, playbackItem.userData.playbackPositionTicks); - } catch (...) { - } -} - -QCoro::Task DetailView::executeExternalPlay(MediaItem targetItem, - QString playerPath) { - if (targetItem.id.isEmpty() || playerPath.isEmpty()) - co_return; - try { - MediaItem actualItem = targetItem; - if (actualItem.mediaSources.isEmpty()) { - PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); - actualItem.mediaSources = info.mediaSources; - } - if (actualItem.mediaSources.isEmpty()) - co_return; - - - int sourceIdx = 0; - const bool useUiSelection = isCurrentActionItem(actualItem.id); - if (useUiSelection) { - sourceIdx = m_actionWidget->currentSourceIndex(); - } - if (sourceIdx >= actualItem.mediaSources.size()) - sourceIdx = 0; - MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; - - if (useUiSelection) { - int selectedAudioIdx = m_actionWidget->currentAudioIndex(); - int selectedSubIdx = m_actionWidget->currentSubtitleIndex(); - for (auto &stream : modifiedSource.mediaStreams) { - if (stream.type == "Audio" && selectedAudioIdx >= 0) - stream.isDefault = (stream.index == selectedAudioIdx); - else if (stream.type == "Subtitle" && selectedSubIdx >= 0) - stream.isDefault = (stream.index == selectedSubIdx); - } - } else { - PlayerPreferenceUtils::applyPreferredStreamRules( - modifiedSource, - ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, - "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, - "auto")); - } - - QString streamUrl = - m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); - - const QString playTitle = - MediaItemUtils::playbackTitle(actualItem, m_currentMediaItem.name); - - long long startTicks = actualItem.userData.playbackPositionTicks; - PlaybackManager::instance()->startExternalPlayback( - playerPath, actualItem.id, playTitle, streamUrl, startTicks, - QVariant::fromValue(modifiedSource)); - } catch (...) { - } -} - -void DetailView::updateBackdrop() { - if (m_contentWidget) - static_cast(m_contentWidget) - ->setBackdrop(m_currentBackdropPix); -} - -void DetailView::updateTagLayoutHeight() { - if (!m_tagsWidget || !m_tagsLayout) - return; - - int targetWidth = m_tagsWidget->width(); - if (targetWidth <= 0 && m_textContainer) { - targetWidth = m_textContainer->contentsRect().width(); - } - - if (targetWidth <= 0) { - m_tagsWidget->updateGeometry(); - QTimer::singleShot(50, this, [this]() { updateTagLayoutHeight(); }); - return; - } - - m_tagsLayout->invalidate(); - const int targetHeight = m_tagsLayout->heightForWidth(targetWidth); - if (m_tagsWidget->minimumHeight() != targetHeight) { - m_tagsWidget->setMinimumHeight(targetHeight); - } - m_tagsWidget->updateGeometry(); - - if (m_textContainer) { - m_textContainer->updateGeometry(); - } -} - -void DetailView::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - if (m_overviewLabel && m_overviewLabel->width() > 10 && - qAbs(m_overviewLabel->width() - m_lastOverviewWidth) > 5) { - m_lastOverviewWidth = m_overviewLabel->width(); - updateOverviewElidedText(); - } - updateTagLayoutHeight(); - updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); -} - -bool DetailView::eventFilter(QObject *obj, QEvent *event) { - if (m_mainScrollArea && obj == m_mainScrollArea->viewport() && - event->type() == QEvent::Resize) { - QResizeEvent *re = static_cast(event); - int newWidth = re->size().width(); - if (m_contentWidget && newWidth > 100 && - m_contentWidget->maximumWidth() != newWidth) { - m_contentWidget->setMaximumWidth(newWidth); - QTimer::singleShot(0, this, [this]() { - updateTagLayoutHeight(); - updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); - }); - } - } - if (m_episodeWidget && m_episodeWidget->gallery() && - obj == m_episodeWidget->gallery()->listView()->viewport() && - event->type() == QEvent::Resize) { - QTimer::singleShot(0, this, [this]() { - updateEpisodeJumpControl(m_currentSeasonEpisodes.size()); - }); - } - if (event->type() == QEvent::Wheel) { - bool isHorizontalViewport = - obj->parent() && - obj->parent()->property("isHorizontalListView").toBool(); - bool isMainViewport = - (m_mainScrollArea && obj == m_mainScrollArea->viewport()); - if (isHorizontalViewport || isMainViewport) { - QWheelEvent *we = static_cast(event); - if (m_vScrollController) { - m_vScrollController->scrollByWheelEvent(we, Qt::Vertical); - } - return true; - } - } - return QWidget::eventFilter(obj, event); -} - -void DetailView::showEvent(QShowEvent *event) { - BaseView::showEvent(event); - - - - if (m_skipNextSilentRefresh) { - m_skipNextSilentRefresh = false; - return; - } - - - - - m_pendingDeferredFetchId.clear(); - - if (!m_currentItemId.isEmpty() && m_core && m_core->mediaService()) { - executeSilentRefresh(QPointer(this), m_core, m_currentItemId); - } -} - -void DetailView::clearLayout(QLayout *layout) { - if (!layout) - return; - QLayoutItem *child; - while ((child = layout->takeAt(0)) != nullptr) { - if (child->widget()) { - child->widget()->hide(); - child->widget()->setParent(nullptr); - child->widget()->deleteLater(); - } else if (child->layout()) { - clearLayout(child->layout()); - } - delete child; - } -} - -QString DetailView::formatRunTime(long long ticks) { - long long totalSeconds = ticks / 10000000; - long long hours = totalSeconds / 3600; - long long minutes = (totalSeconds % 3600) / 60; - if (hours > 0) - return QString(tr("%1 hr %2 min")).arg(hours).arg(minutes); - return QString(tr("%1 min")).arg(minutes); -} - -bool DetailView::shouldShowDisplayNumber(const MediaItem &item) const { - if (item.type.compare("Movie", Qt::CaseInsensitive) != 0) { - return false; - } - - static const QList adultMarkers = { - QRegularExpression(R"((^|[^0-9])18\+?([^0-9]|$))", - QRegularExpression::CaseInsensitiveOption), - QRegularExpression(R"(\b(?:R[-\s]?18\+?|NC[-\s]?17|JAV|AV)\b)", - QRegularExpression::CaseInsensitiveOption), - QRegularExpression(QStringLiteral("(成人|无码|有码|無碼|有碼)"))}; - - const auto containsAdultMarker = [](const QString &value) -> bool { - const QString trimmedValue = value.trimmed(); - if (trimmedValue.isEmpty()) { - return false; - } - - for (const QRegularExpression &pattern : adultMarkers) { - if (pattern.match(trimmedValue).hasMatch()) { - return true; - } - } - - return false; - }; - - if (containsAdultMarker(item.officialRating)) { - return true; - } - - for (const QString &genre : item.genres) { - if (containsAdultMarker(genre)) { - return true; - } - } - - for (const QString &tag : item.tags) { - if (containsAdultMarker(tag)) { - return true; - } - } - - return false; -} - -bool DetailView::isCurrentActionItem(const QString &itemId) const { - if (itemId.isEmpty()) - return false; - if (itemId == m_currentMediaItem.id) - return true; - return m_currentMediaItem.type == "Series" && - itemId == m_currentPlayableItem.id; -} - -QStringList DetailView::buildNumberCandidates(const MediaItem &item) const { - QStringList candidates; - const auto appendCandidate = [&candidates](const QString &value) { - const QString trimmedValue = value.trimmed(); - if (!trimmedValue.isEmpty() && !candidates.contains(trimmedValue)) { - candidates.append(trimmedValue); - } - }; - - for (const MediaSourceInfo &source : item.mediaSources) { - appendCandidate(source.path); - } - - appendCandidate(item.path); - appendCandidate(item.name); - appendCandidate(item.originalTitle); - appendCandidate(item.sortName); - - for (const QString &tagline : item.taglines) { - appendCandidate(tagline); - } - - for (const QString &tag : item.tags) { - appendCandidate(tag); - } - - return candidates; -} - -QString DetailView::extractDisplayNumber(const MediaItem &item) const { - static const NumberExtractor extractor(true); - const QStringList candidates = buildNumberCandidates(item); - const QString displayNumber = extractor.extractBestNumber(candidates); - - qDebug() << "[DetailView] adult number extraction itemId=" << item.id - << "officialRating=" << item.officialRating - << "candidateCount=" << candidates.size() - << "selected=" << displayNumber; - - return displayNumber; -} - -void DetailView::updateDisplayNumber(const QString &number) { - if (!m_numberButton || !m_metaRowWidget) { - return; - } - - const QString trimmedNumber = number.trimmed(); - const bool hasNumber = !trimmedNumber.isEmpty(); - m_numberButton->setVisible(hasNumber); - m_numberButton->setText(trimmedNumber); - m_numberButton->setToolTip(hasNumber ? tr("Click to copy number") - : QString()); - m_metaRowWidget->setVisible(!m_metaLabel->text().isEmpty() || hasNumber); -} - -void DetailView::copyDisplayedNumber() { - if (!m_numberButton) { - return; - } - - const QString displayNumber = m_numberButton->text().trimmed(); - if (displayNumber.isEmpty()) { - return; - } - - if (QClipboard *clipboard = QGuiApplication::clipboard()) { - clipboard->setText(displayNumber); - } - - qDebug() << "[DetailView] copied display number" << displayNumber - << "for itemId" << m_currentItemId; - ModernToast::showMessage(tr("Number copied: %1").arg(displayNumber), 2000); -} - -void DetailView::markManualSeriesSelection(const QString &reason) { - if (m_currentMediaItem.type != "Series") - return; - - if (!m_hasManualSeriesSelection) { - qDebug() << "[DetailView] Manual series selection locked server auto-sync" - << "seriesId=" << m_currentItemId << "reason=" << reason; - } - m_hasManualSeriesSelection = true; -} - -void DetailView::rememberSeriesSelection(const MediaItem &episode, - const QString &reason, - bool persist) { - if (m_currentMediaItem.type != "Series") - return; - - int selectedSeasonIndex = -1; - QString selectedSeasonId; - bool hasSelectedSeason = false; - if (episode.type == "Episode" && episode.parentIndexNumber >= 0) { - for (int i = 0; i < m_seriesSeasons.size(); ++i) { - if (m_seriesSeasons[i].indexNumber == episode.parentIndexNumber) { - selectedSeasonIndex = i; - selectedSeasonId = m_seriesSeasons[i].id; - hasSelectedSeason = true; - break; - } - } - } else { - selectedSeasonIndex = m_currentSeasonIndex; - if (selectedSeasonIndex >= 0 && - selectedSeasonIndex < m_seriesSeasons.size()) { - selectedSeasonId = m_seriesSeasons[selectedSeasonIndex].id; - hasSelectedSeason = true; - } - } - - if (hasSelectedSeason) { - if (persist) { - m_cachedSeasonIndex = selectedSeasonIndex; - m_cachedSeasonId = selectedSeasonId; - } else { - m_manualSelectedSeasonIndex = selectedSeasonIndex; - m_manualSelectedSeasonId = selectedSeasonId; - } - } - if (episode.type == "Episode" && !episode.id.isEmpty()) { - if (persist) { - m_cachedSeriesPlayableItem = episode; - m_hasCachedSeriesPlayableItem = true; - } else { - m_manualSelectedEpisodeId = episode.id; - } - } - - qDebug() << "[DetailView] Remember series selection" - << "seriesId=" << m_currentItemId - << "seasonIndex=" - << (persist ? m_cachedSeasonIndex : m_manualSelectedSeasonIndex) - << "seasonId=" - << (persist ? m_cachedSeasonId : m_manualSelectedSeasonId) - << "episodeId=" - << (persist ? m_cachedSeriesPlayableItem.id - : m_manualSelectedEpisodeId) - << "persist=" << persist << "reason=" << reason; - - if (persist) - persistDetailCacheSnapshot(reason); -} - -void DetailView::updateOverviewElidedText() { - if (m_cleanOverviewText.isEmpty() || !m_overviewLabel) - return; - - const int generation = ++m_overviewElideGeneration; - QPointer safeThis(this); - QTimer::singleShot(0, this, [safeThis, generation]() { - if (!safeThis || generation != safeThis->m_overviewElideGeneration) - return; - - int labelWidth = safeThis->m_overviewLabel->contentsRect().width(); - if (labelWidth <= 10 && safeThis->m_textContainer) - labelWidth = safeThis->m_textContainer->contentsRect().width(); - if (labelWidth <= 10) - return; - - QFontMetrics fm(safeThis->m_overviewLabel->font()); - int maxHeight = fm.lineSpacing() * 5 + 10; - QTextDocument doc; - doc.setDefaultFont(safeThis->m_overviewLabel->font()); - doc.setTextWidth(labelWidth); - - QString fullHtml = safeThis->m_cleanOverviewText.toHtmlEscaped(); - fullHtml.replace("\n", "
"); - doc.setHtml(fullHtml); - - if (doc.size().height() <= maxHeight) { - if (safeThis->m_overviewLabel->text() != fullHtml) - safeThis->m_overviewLabel->setText(fullHtml); - return; - } - - int low = 0, high = safeThis->m_cleanOverviewText.length(), best = 0; - QString moreLink = - safeThis->tr("... More"); - while (low <= high) { - int mid = low + (high - low) / 2; - QString testHtml = - safeThis->m_cleanOverviewText.left(mid).toHtmlEscaped(); - testHtml.replace("\n", "
"); - doc.setHtml(testHtml + moreLink); - if (doc.size().height() <= maxHeight) { - best = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - QString finalHtml = - safeThis->m_cleanOverviewText.left(best).toHtmlEscaped(); - finalHtml.replace("\n", "
"); - const QString overviewHtml = finalHtml + moreLink; - if (safeThis->m_overviewLabel->text() != overviewHtml) - safeThis->m_overviewLabel->setText(overviewHtml); - }); -} - -QCoro::Task DetailView::updateUi(MediaItem item, bool isSilentRefresh) { - m_currentMediaItem = item; - if (!isSilentRefresh && item.type == "Series") - m_deferBottomInfoReveal = true; - m_titleLabel->setText(item.name); - - const QString metaEpisodeTag = - item.type == "Series" && m_currentPlayableItem.type == "Episode" - ? formatEpisodeTag(m_currentPlayableItem) - : formatEpisodeTag(item); - updateMetaRow(item, metaEpisodeTag); - - const bool shouldShowNumber = shouldShowDisplayNumber(item); - const QString displayNumber = - shouldShowNumber ? extractDisplayNumber(item) : QString(); - updateDisplayNumber(displayNumber); - - qDebug() << "[DetailView] updateUi itemId=" << item.id - << "adultMovie=" << shouldShowNumber - << "displayNumber=" << displayNumber; - - QString text = item.overview; - text.replace(QRegularExpression("", - QRegularExpression::CaseInsensitiveOption), - "\n"); - text.replace("

", "\n\n"); - text.remove(QRegularExpression("<[^>]*>")); - QTextDocument doc; - doc.setHtml(text); - QString cleanText = doc.toPlainText(); - cleanText.replace(QChar::LineSeparator, '\n'); - cleanText.remove(QRegularExpression("<[^>]*>")); - m_cleanOverviewText = cleanText; - m_lastOverviewWidth = -1; - updateOverviewElidedText(); - - if (item.type == "Series") { - const bool hasCurrentEpisode = - m_currentPlayableItem.type == "Episode" && - !m_currentPlayableItem.id.isEmpty(); - if (!isSilentRefresh && !hasCurrentEpisode) - m_actionWidget->setSeriesLoadingMode(); - } else { - - - m_currentPlayableItem = item; - m_actionWidget->setupNormalMode(item); - } - - - - const QString newSourcesFingerprint = - computeSourcesFingerprint(item.mediaSources); - const bool sourcesUnchanged = - !m_appliedSourcesFingerprint.isEmpty() && - newSourcesFingerprint == m_appliedSourcesFingerprint; - const bool sourcesOwnedBySeriesEpisode = - item.type == "Series" && m_currentPlayableItem.type == "Episode" && - !m_currentPlayableItem.id.isEmpty(); - if (!sourcesOwnedBySeriesEpisode && !sourcesUnchanged) { - m_actionWidget->setSources(item.mediaSources, 0); - m_appliedSourcesFingerprint = newSourcesFingerprint; - if (item.mediaSources.isEmpty() || - item.mediaSources.first().mediaStreams.isEmpty()) { - m_actionWidget->setStreams(MediaSourceInfo{}); - } - } - m_actionWidget->refreshExtPlayerButton(); - - if (!item.taglines.isEmpty()) { - m_taglineLabel->setText(item.taglines.first()); - m_taglineLabel->show(); - } else { - m_taglineLabel->hide(); - } - - m_isFavorite = item.isFavorite(); - m_actionWidget->setFavoriteState(m_isFavorite); - m_actionWidget->setPlayedState(item.userData.played); - - - - buildTagButtons(item.genres); - - QCoro::connect(executeLoadImages(QPointer(this), m_core, item, - true), - this, []() {}); - - QPointer safeThis(this); - - - - auto applyBottomInfo = [this, &item](const QList &srcs) { - const QString fp = computeBottomInfoFingerprint(item, srcs); - if (fp == m_appliedBottomInfoFingerprint) - return; - m_bottomInfoWidget->setInfo(item, srcs); - m_appliedBottomInfoFingerprint = fp; - if (m_deferBottomInfoReveal) { - showReserveWidget(m_bottomInfoReserveWidget, - reserveHeightFor(m_bottomInfoWidget, 220)); - } - }; - - if (sourcesOwnedBySeriesEpisode) { - applySeriesPlayableItemToUi(m_currentPlayableItem); - } else if (!item.mediaSources.isEmpty() && - !item.mediaSources.first().mediaStreams.isEmpty()) { - int defaultIndex = m_actionWidget->currentSourceIndex(); - QList defaultSource; - if (defaultIndex < item.mediaSources.size()) { - const MediaSourceInfo &dSrc = item.mediaSources[defaultIndex]; - - - if (!sourcesUnchanged) - m_actionWidget->setStreams(dSrc); - defaultSource.append(dSrc); - } - applyBottomInfo(defaultSource); - } else { - - - applyBottomInfo({}); - if (item.mediaType == "Video" || item.mediaType == "Audio" || - item.type == "Movie" || item.type == "Episode") { - try { - PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(item.id); - if (!safeThis) - co_return; - if (item.id == safeThis->m_currentItemId && - !info.mediaSources.isEmpty()) { - safeThis->m_currentMediaItem.mediaSources = info.mediaSources; - - const QString refreshedNumber = - safeThis->shouldShowDisplayNumber(safeThis->m_currentMediaItem) - ? safeThis->extractDisplayNumber(safeThis->m_currentMediaItem) - : QString(); - safeThis->updateDisplayNumber(refreshedNumber); - - - - if (item.type != "Series") { - safeThis->m_currentPlayableItem.mediaSources = info.mediaSources; - } - - int idx = safeThis->m_actionWidget->currentSourceIndex(); - if (idx >= info.mediaSources.size()) - idx = 0; - safeThis->m_actionWidget->setSources(info.mediaSources, idx); - safeThis->m_appliedSourcesFingerprint = - computeSourcesFingerprint(info.mediaSources); - idx = safeThis->m_actionWidget->currentSourceIndex(); - if (idx >= safeThis->m_currentMediaItem.mediaSources.size()) - idx = 0; - if (idx < safeThis->m_currentMediaItem.mediaSources.size()) { - const MediaSourceInfo &singleSource = - safeThis->m_currentMediaItem.mediaSources[idx]; - safeThis->m_actionWidget->setStreams(singleSource); - QList sList; - sList.append(singleSource); - - const QString fp = computeBottomInfoFingerprint( - safeThis->m_currentMediaItem, sList); - if (fp != safeThis->m_appliedBottomInfoFingerprint) { - safeThis->m_bottomInfoWidget->setInfo( - safeThis->m_currentMediaItem, sList); - safeThis->m_appliedBottomInfoFingerprint = fp; - if (safeThis->m_deferBottomInfoReveal) { - showReserveWidget( - safeThis->m_bottomInfoReserveWidget, - reserveHeightFor(safeThis->m_bottomInfoWidget, 220)); - } - } - } - } - } catch (...) { - } - } - } - - if (!safeThis) - co_return; - safeThis->m_bottomInfoWidget->setVisible( - !safeThis->m_deferBottomInfoReveal); - if (safeThis->m_deferBottomInfoReveal) { - showReserveWidget(safeThis->m_bottomInfoReserveWidget, - reserveHeightFor(safeThis->m_bottomInfoWidget, 220)); - } else { - hideReserveWidget(safeThis->m_bottomInfoReserveWidget); - } - if (!isSilentRefresh && safeThis) - QMetaObject::invokeMethod(safeThis.data(), "scrollToTop", - Qt::QueuedConnection); -} - -void DetailView::onOverviewMoreClicked(const QString &link) { - if (link == "more") { - OverviewDialog dialog(this); - dialog.setMediaItem(m_currentMediaItem, m_currentPosterPix); - dialog.exec(); - } -} - -QCoro::Task -DetailView::executeSilentRefresh(QPointer safeThis, QEmbyCore *core, - QString itemId) { - QString cacheServerId; - QString cacheUserId; - if (core && core->serverManager()) { - const ServerProfile profile = core->serverManager()->activeProfile(); - cacheServerId = profile.id; - cacheUserId = profile.userId; - } - - try { - MediaItem item = co_await core->mediaService()->getItemDetail(itemId); - if (!cacheServerId.isEmpty() && !cacheUserId.isEmpty()) { - const QString fp = co_await QtConcurrent::run( - [item]() mutable { return DetailCacheUtils::fingerprint(item); }); - saveDetailCacheAsync(cacheServerId, cacheUserId, item, fp); - if (safeThis && item.id == safeThis->m_currentItemId) - safeThis->m_cachedDetailFingerprint = fp; - } - if (safeThis && item.id == safeThis->m_currentItemId) { - co_await safeThis->updateUi(item, true); - - - if (item.type == "Series") { - co_await safeThis->fetchSeriesNextUp( - itemId, !safeThis->m_hasManualSeriesSelection); - if (!safeThis || safeThis->m_currentItemId != itemId) - co_return; - - auto seasons = co_await core->mediaService()->getSeasons(itemId); - if (!safeThis || safeThis->m_currentItemId != itemId) - co_return; - - safeThis->m_seriesSeasons = seasons; - safeThis->m_cachedSeriesSeasons = seasons; - safeThis->m_hasCachedSeriesSeasons = true; - - - - bool selectedSeasonResolved = false; - if (safeThis->m_hasManualSeriesSelection && - !safeThis->m_manualSelectedSeasonId.isEmpty()) { - for (int i = 0; i < seasons.size(); ++i) { - if (seasons[i].id == safeThis->m_manualSelectedSeasonId) { - safeThis->m_currentSeasonIndex = i; - selectedSeasonResolved = true; - break; - } - } - } - if (!selectedSeasonResolved && - safeThis->m_hasManualSeriesSelection && - safeThis->m_manualSelectedSeasonIndex >= 0 && - safeThis->m_manualSelectedSeasonIndex < seasons.size()) { - safeThis->m_currentSeasonIndex = - safeThis->m_manualSelectedSeasonIndex; - selectedSeasonResolved = true; - } - if (!selectedSeasonResolved && - safeThis->m_hasManualSeriesSelection && - safeThis->m_currentSeasonIndex >= 0 && - safeThis->m_currentSeasonIndex < seasons.size()) { - selectedSeasonResolved = true; - } - int playableParentIdx = - safeThis->m_currentPlayableItem.parentIndexNumber; - if (!selectedSeasonResolved && - !safeThis->m_hasManualSeriesSelection && playableParentIdx >= 0) { - for (int i = 0; i < seasons.size(); ++i) { - if (seasons[i].indexNumber == playableParentIdx) { - safeThis->m_currentSeasonIndex = i; - break; - } - } - } - - if (!seasons.isEmpty() && safeThis->m_episodeWidget) { - const int idx = - qBound(0, safeThis->m_currentSeasonIndex, seasons.size() - 1); - safeThis->updateSeasonSwitcher(idx); - - const QString playableItemId = - safeThis->m_hasManualSeriesSelection && - !safeThis->m_manualSelectedEpisodeId.isEmpty() - ? safeThis->m_manualSelectedEpisodeId - : safeThis->m_currentPlayableItem.id; - co_await safeThis->loadEpisodesForSeason( - idx, playableItemId, !playableItemId.isEmpty(), - safeThis->m_hasManualSeriesSelection); - } else if (safeThis->m_episodeWidget) { - applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, - safeThis->m_episodeWidget, {}); - } - if (!safeThis || safeThis->m_currentItemId != itemId) - co_return; - - if (safeThis->m_seasonWidget) { - safeThis->m_seasonWidget->setTitle(tr("Seasons")); - safeThis->m_seasonWidget->setCardStyle(MediaCardDelegate::Poster); - safeThis->m_seasonWidget->setGalleryHeight(300); - applyReservedSectionItems(safeThis->m_seasonSectionReserveWidget, - safeThis->m_seasonWidget, seasons); - } - } - - safeThis->applyCastSection(item); - if (safeThis->m_deferBottomInfoReveal) { - safeThis->m_deferBottomInfoReveal = false; - hideReserveWidget(safeThis->m_bottomInfoReserveWidget); - if (safeThis->m_bottomInfoWidget) - safeThis->m_bottomInfoWidget->show(); - } - safeThis->persistDetailCacheSnapshot(QStringLiteral("silent-refresh")); - } - } catch (...) { - } -} - -QCoro::Task DetailView::executeLoadImages(QPointer safeThis, - QEmbyCore *core, - MediaItem item, - bool allowPrimaryBackdropFallback) { - bool adaptive = - ConfigStore::instance()->get(ConfigKeys::AdaptiveImages, true); - QString cacheServerId; - QString cacheUserId; - if (safeThis) { - cacheServerId = safeThis->m_detailCacheServerId; - cacheUserId = safeThis->m_detailCacheUserId; - } else if (core && core->serverManager()) { - const ServerProfile profile = core->serverManager()->activeProfile(); - cacheServerId = profile.id; - cacheUserId = profile.userId; - } - - - - QString posterType = QStringLiteral("Primary"); - QString posterTag = item.images.primaryTag; - QString posterId = item.id; - if (posterTag.isEmpty() && adaptive) { - auto best = item.images.bestPoster(); - posterTag = best.first; - posterType = best.second; - if (item.images.isParentTag(posterTag)) { - posterId = item.images.parentImageItemId.isEmpty() - ? item.seriesId - : item.images.parentImageItemId; - } - } - const int posterMaxWidth = - posterType == QStringLiteral("Primary") ? 400 : 800; - - QString backdropType = QStringLiteral("Backdrop"); - QString backdropTag = item.images.backdropTag; - QString backdropId = item.id; - if (backdropTag.isEmpty() && adaptive) { - if (allowPrimaryBackdropFallback) { - auto best = item.images.bestBackdrop(); - backdropTag = best.first; - backdropType = best.second; - } else { - if (!item.images.thumbTag.isEmpty()) { - backdropTag = item.images.thumbTag; - backdropType = QStringLiteral("Thumb"); - } else if (!item.images.parentBackdropTag.isEmpty()) { - backdropTag = item.images.parentBackdropTag; - backdropType = QStringLiteral("Backdrop"); - } else if (!item.images.parentThumbTag.isEmpty()) { - backdropTag = item.images.parentThumbTag; - backdropType = QStringLiteral("Thumb"); - } - } - if (item.images.isParentTag(backdropTag)) { - backdropId = item.images.parentImageItemId.isEmpty() - ? item.seriesId - : item.images.parentImageItemId; - } - } - - const QString logoId = item.id; - const QString logoType = QStringLiteral("Logo"); - const QString logoTag = item.images.logoTag; - constexpr int logoMaxWidth = 400; - - const CachedDetailImages cachedImages = co_await QtConcurrent::run( - [cacheServerId, cacheUserId, ownerItemId = item.id, posterId, - posterType, posterTag, posterMaxWidth, backdropId, backdropType, - backdropTag, logoId, logoType, logoTag, logoMaxWidth]() mutable { - CachedDetailImages result; - if (!posterTag.isEmpty()) { - result.poster = DetailCacheUtils::loadImage( - cacheServerId, cacheUserId, ownerItemId, - QStringLiteral("poster"), posterId, posterType, posterTag, - posterMaxWidth); - } - if (!backdropTag.isEmpty()) { - result.backdrop = DetailCacheUtils::loadImage( - cacheServerId, cacheUserId, ownerItemId, - QStringLiteral("backdrop"), backdropId, backdropType, - backdropTag, 1920); - } - if (!logoTag.isEmpty()) { - result.logo = DetailCacheUtils::loadImage( - cacheServerId, cacheUserId, ownerItemId, QStringLiteral("logo"), - logoId, logoType, logoTag, logoMaxWidth); - } - return result; - }); - if (!safeThis || safeThis->m_currentItemId != item.id) - co_return; - - if (cachedImages.poster.has_value()) { - safeThis->m_currentPosterPix = - QPixmap::fromImage(cachedImages.poster.value()); - safeThis->m_posterLabel->setPixmap(safeThis->m_currentPosterPix); - qDebug() << "[DetailView] Detail poster image cache hit" - << "itemId=" << item.id << "imageId=" << posterId - << "type=" << posterType; - } - - if (cachedImages.backdrop.has_value()) { - safeThis->m_currentBackdropPix = - QPixmap::fromImage(cachedImages.backdrop.value()); - safeThis->updateBackdrop(); - qDebug() << "[DetailView] Detail backdrop image cache hit" - << "itemId=" << item.id << "imageId=" << backdropId - << "type=" << backdropType; - } - - if (cachedImages.logo.has_value()) { - safeThis->m_logoLabel->setPixmap( - QPixmap::fromImage(cachedImages.logo.value())); - FadeHelper::fadeIn(safeThis->m_logoLabel, - AnimationTokens::DurationMs::kNormal); - qDebug() << "[DetailView] Detail logo image cache hit" - << "itemId=" << item.id; - } - - - if (!posterTag.isEmpty()) { - try { - QPixmap pix = co_await core->mediaService()->fetchImage( - posterId, posterType, posterTag, posterMaxWidth); - if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { - safeThis->m_currentPosterPix = pix; - - - - - const QSize posterSize(250, 375); - const QString currentItemId = item.id; - QImage src = pix.toImage(); - - auto *watcher = new QFutureWatcher(safeThis.data()); - QObject::connect( - watcher, &QFutureWatcher::finished, safeThis.data(), - [safeThis, watcher, currentItemId, cacheServerId, cacheUserId, - posterId, posterType, posterTag, posterMaxWidth]() { - if (safeThis && safeThis->m_currentItemId == currentItemId) { - const QImage result = watcher->result(); - if (!result.isNull()) { - safeThis->m_posterLabel->setPixmap( - QPixmap::fromImage(result)); - saveDetailImageAsync(cacheServerId, cacheUserId, - currentItemId, - QStringLiteral("poster"), posterId, - posterType, posterTag, posterMaxWidth, - result); - } - } - watcher->deleteLater(); - }); - watcher->setFuture(QtConcurrent::run([src, posterSize]() -> QImage { - QImage scaled = - src.scaled(posterSize, Qt::KeepAspectRatioByExpanding, - Qt::SmoothTransformation); - const int cropX = (scaled.width() - posterSize.width()) / 2; - const int cropY = (scaled.height() - posterSize.height()) / 2; - QImage cropped = - scaled.copy(cropX, cropY, posterSize.width(), posterSize.height()); - - - QImage rounded(posterSize, QImage::Format_ARGB32_Premultiplied); - rounded.fill(Qt::transparent); - QPainter p(&rounded); - p.setRenderHint(QPainter::Antialiasing); - QPainterPath path; - path.addRoundedRect( - QRectF(0, 0, posterSize.width(), posterSize.height()), 12, 12); - p.setClipPath(path); - p.drawImage(0, 0, cropped); - p.end(); - return rounded; - })); - } - } catch (...) { - } - } - - - if (!backdropTag.isEmpty()) { - try { - QPixmap pix = co_await core->mediaService()->fetchImage( - backdropId, backdropType, backdropTag, 1920); - if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { - safeThis->m_currentBackdropPix = pix; - safeThis->updateBackdrop(); - saveDetailImageAsync(cacheServerId, cacheUserId, item.id, - QStringLiteral("backdrop"), backdropId, - backdropType, backdropTag, 1920, pix.toImage()); - } - } catch (...) { - } - } - - - - if (!logoTag.isEmpty()) { - try { - QPixmap pix = - co_await core->mediaService()->fetchImage(logoId, logoType, logoTag, - logoMaxWidth); - if (safeThis && safeThis->m_currentItemId == item.id && !pix.isNull()) { - const QString currentItemId = item.id; - QImage src = pix.toImage(); - - auto *watcher = new QFutureWatcher(safeThis.data()); - QObject::connect( - watcher, &QFutureWatcher::finished, safeThis.data(), - [safeThis, watcher, currentItemId, cacheServerId, cacheUserId, - logoId, logoType, logoTag, logoMaxWidth]() { - if (safeThis && - safeThis->m_currentItemId == currentItemId) { - const QImage result = watcher->result(); - if (!result.isNull()) { - safeThis->m_logoLabel->setPixmap( - QPixmap::fromImage(result)); - FadeHelper::fadeIn(safeThis->m_logoLabel, - AnimationTokens::DurationMs::kNormal); - saveDetailImageAsync( - cacheServerId, cacheUserId, currentItemId, - QStringLiteral("logo"), logoId, logoType, logoTag, - logoMaxWidth, result); - } - } - watcher->deleteLater(); - }); - watcher->setFuture(QtConcurrent::run([src]() -> QImage { - return src.scaled(250, 100, Qt::KeepAspectRatio, - Qt::SmoothTransformation); - })); - } - } catch (...) { - } - } +void DetailView::scrollToTop() { + if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) + QMetaObject::invokeMethod(root, "scrollToTop"); } void DetailView::onMediaItemUpdated(const MediaItem &item) { - - if (m_currentItemId == item.id && m_core && !m_skipSilentRefresh) { - QCoro::connect( - executeSilentRefresh(QPointer(this), m_core, item.id), this, - []() {}); - } - - - if (item.type == "Season" && !m_currentSeasonEpisodes.isEmpty()) { - bool belongsToSeries = false; - for (const MediaItem &season : m_seriesSeasons) { - if (season.id == item.id) { - belongsToSeries = true; - break; - } - } - if (belongsToSeries) { - for (MediaItem &ep : m_currentSeasonEpisodes) { - if (ep.parentIndexNumber == item.indexNumber) - ep.userData.played = item.userData.played; - } - m_episodeWidget->setItems(m_currentSeasonEpisodes); - } - } - - - if (item.type == "Episode" && !m_currentSeasonEpisodes.isEmpty() && - !m_seriesSeasons.isEmpty()) { - - for (MediaItem &ep : m_currentSeasonEpisodes) { - if (ep.id == item.id) { - ep.userData.played = item.userData.played; - break; - } - } - - const int seasonIndex = item.parentIndexNumber; - if (seasonIndex >= 0) { - for (MediaItem &season : m_seriesSeasons) { - if (season.indexNumber == seasonIndex) { - int total = 0, played = 0; - for (const MediaItem &ep : m_currentSeasonEpisodes) { - if (ep.parentIndexNumber == seasonIndex) { - total++; - if (ep.userData.played) - played++; - } - } - const bool allPlayed = (total > 0 && played == total); - if (season.userData.played != allPlayed) { - season.userData.played = allPlayed; - m_seasonWidget->updateItem(season); - } - break; - } - } - } - } - - - if (m_currentPlayableItem.id == item.id) { - m_currentPlayableItem.userData.played = item.userData.played; - m_actionWidget->setPlayedState(item.userData.played); - } - if (m_currentMediaItem.id == item.id) { - m_currentMediaItem.userData.played = item.userData.played; - m_actionWidget->setPlayedState(item.userData.played); - } - - if (m_seasonWidget) - m_seasonWidget->updateItem(item); - if (m_episodeWidget) - m_episodeWidget->updateItem(item); - if (m_castWidget) - m_castWidget->updateItem(item); - if (m_additionalPartsWidget) - m_additionalPartsWidget->updateItem(item); - if (m_collectionWidget) - m_collectionWidget->updateItem(item); - if (m_similarWidget) - m_similarWidget->updateItem(item); + m_viewModel->updateItem(item); } -void DetailView::beginOptimisticPlayedUpdate() { m_skipSilentRefresh = true; } - -void DetailView::endOptimisticPlayedUpdate() { m_skipSilentRefresh = false; } - -void DetailView::updateSeasonSwitcher(int currentIndex) { - if (!m_seasonSwitcher) - return; - - QSignalBlocker blocker(m_seasonSwitcher); - m_seasonSwitcher->clear(); - - for (const MediaItem &season : m_seriesSeasons) - m_seasonSwitcher->addItem(season.name, ""); - - if (!m_seriesSeasons.isEmpty()) { - const int idx = qBound(0, currentIndex, m_seriesSeasons.size() - 1); - m_seasonSwitcher->setCurrentIndex(idx); - } - - m_seasonSwitcher->setVisible(m_seriesSeasons.size() > 1); - m_seasonSwitcher->adjustSize(); - if (m_episodeJumpEdit) { - const int switcherHeight = - qMax(m_seasonSwitcher->height(), m_seasonSwitcher->sizeHint().height()); - m_episodeJumpEdit->setFixedHeight(switcherHeight); - } - updateEpisodeHeaderControlsVisibility(); +void DetailView::beginOptimisticPlayedUpdate() { + m_viewModel->setActionBusy(true); } -void DetailView::updateEpisodeHeaderControlsVisibility() { - if (!m_episodeHeaderControls) - return; - - const bool showSeasonSwitcher = - m_seasonSwitcher && !m_seasonSwitcher->isHidden(); - const bool showEpisodeJump = - m_episodeJumpEdit && !m_episodeJumpEdit->isHidden(); - m_episodeHeaderControls->setVisible(showSeasonSwitcher || showEpisodeJump); - m_episodeHeaderControls->adjustSize(); - m_episodeHeaderControls->updateGeometry(); +void DetailView::endOptimisticPlayedUpdate() { + m_viewModel->setActionBusy(false); } -void DetailView::updateEpisodeJumpControl(int episodeCount) { - if (!m_episodeJumpEdit) { - return; - } - - const int visibleCardCapacity = episodeGalleryVisibleCardCapacity(); - const bool showJump = - episodeCount > 0 && visibleCardCapacity > 0 && - episodeCount > visibleCardCapacity; - if (showJump) { - int explicitMaxEpisodeNumber = 0; - for (const MediaItem &episode : m_currentSeasonEpisodes) - explicitMaxEpisodeNumber = qMax(explicitMaxEpisodeNumber, - episode.indexNumber); - - const int maxEpisodeNumber = - explicitMaxEpisodeNumber > 0 ? explicitMaxEpisodeNumber : episodeCount; - const QString rangeText = tr("1-%1").arg(maxEpisodeNumber); - m_episodeJumpEdit->setPlaceholderText(rangeText); - m_episodeJumpEdit->setToolTip( - tr("Jump to episode number (1-%1)").arg(maxEpisodeNumber)); - if (m_episodeJumpValidator) - m_episodeJumpValidator->setTop(maxEpisodeNumber); - m_episodeJumpEdit->setMaxLength( - qMax(1, QString::number(maxEpisodeNumber).length())); - - const int textWidth = - m_episodeJumpEdit->fontMetrics().horizontalAdvance(rangeText); - m_episodeJumpEdit->setFixedWidth(qBound(64, textWidth + 22, 92)); - } else { - m_episodeJumpEdit->clear(); - m_episodeJumpEdit->setPlaceholderText(tr("Ep #")); - m_episodeJumpEdit->setToolTip(tr("Jump to episode number")); - if (m_episodeJumpValidator) - m_episodeJumpValidator->setTop(9999); - m_episodeJumpEdit->setMaxLength(4); - m_episodeJumpEdit->setFixedWidth(64); - } - - m_episodeJumpEdit->setVisible(showJump); - updateEpisodeHeaderControlsVisibility(); -} - -int DetailView::episodeGalleryVisibleCardCapacity() const { - if (!m_episodeWidget || !m_episodeWidget->gallery()) - return 0; - - QListView *listView = m_episodeWidget->gallery()->listView(); - if (!listView || !listView->viewport()) - return 0; - - int cardWidth = m_episodeTileWidth; - if (cardWidth <= 0 && listView->model() && listView->model()->rowCount() > 0) { - const QSize itemSize = - listView->sizeHintForIndex(listView->model()->index(0, 0)); - cardWidth = itemSize.width(); - } - if (cardWidth <= 0) - return 0; - - const int viewportWidth = listView->viewport()->width(); - if (viewportWidth <= 0) - return 0; - - return qMax(1, viewportWidth / cardWidth); -} - -MediaItem DetailView::episodeForJumpNumber(int episodeNumber) const { - if (episodeNumber <= 0) - return MediaItem{}; - - bool hasExplicitEpisodeNumbers = false; - for (const MediaItem &episode : m_currentSeasonEpisodes) { - if (episode.indexNumber > 0) - hasExplicitEpisodeNumbers = true; - if (episode.indexNumber == episodeNumber) - return episode; - } - - if (hasExplicitEpisodeNumbers) - return MediaItem{}; - - const int row = episodeNumber - 1; - if (row >= 0 && row < m_currentSeasonEpisodes.size()) - return m_currentSeasonEpisodes.at(row); - - return MediaItem{}; -} - -void DetailView::submitEpisodeJump() { - if (!m_episodeJumpEdit || m_episodeJumpEdit->isHidden() || - m_currentSeasonEpisodes.isEmpty()) { - return; - } - - const QString text = m_episodeJumpEdit->text().trimmed(); - if (text.isEmpty()) - return; - - bool ok = false; - const int episodeNumber = text.toInt(&ok); - if (!ok) - return; - - const MediaItem targetEpisode = episodeForJumpNumber(episodeNumber); - if (targetEpisode.id.isEmpty()) { - qDebug() << "[DetailView] Episode jump target not found" - << "seriesId=" << m_currentItemId - << "seasonIndex=" << m_currentSeasonIndex - << "episodeNumber=" << episodeNumber - << "episodeCount=" << m_currentSeasonEpisodes.size(); - ModernToast::showMessage(tr("Episode %1 not found").arg(episodeNumber), - 1600); - m_episodeJumpEdit->selectAll(); - return; - } - - qDebug() << "[DetailView] Episode jump" - << "seriesId=" << m_currentItemId - << "seasonIndex=" << m_currentSeasonIndex - << "episodeNumber=" << episodeNumber - << "episodeId=" << targetEpisode.id; - - m_episodeJumpEdit->clear(); - markManualSeriesSelection(QStringLiteral("episode-jump")); - QCoro::connect(applySeriesPlayableItem(targetEpisode, true), this, []() {}); -} - -QCoro::Task DetailView::loadEpisodesForSeason(int idx, - QString highlightEpisodeId, - bool scrollToHighlight, - bool manualSelection) { - if (idx < 0 || idx >= m_seriesSeasons.size() || !m_episodeWidget) +QCoro::Task DetailView::playSelected(MediaItem item, int sourceIndex, + bool resume) { + if (!m_core || !m_core->mediaService() || item.id.isEmpty()) co_return; - m_currentSeasonIndex = idx; - const QString seasonId = m_seriesSeasons[idx].id; - if (seasonId.isEmpty()) - co_return; - if (manualSelection) - markManualSeriesSelection(QStringLiteral("season-selection")); - rememberSeriesSelection(MediaItem{}, QStringLiteral("season-selection"), - !manualSelection); - - bool showedCachedEpisodes = false; - QList shownCachedEpisodes; - if (m_hasCachedSeasonEpisodes && m_cachedSeasonIndex == idx && - m_cachedSeasonId == seasonId && - episodesBelongToSeason(m_cachedSeasonEpisodes, m_seriesSeasons[idx])) { - shownCachedEpisodes = m_cachedSeasonEpisodes; - if (!m_appliedCachedSeasonEpisodesToUi) { - m_currentSeasonEpisodes = shownCachedEpisodes; - applyReservedSectionItems(m_episodeSectionReserveWidget, m_episodeWidget, - shownCachedEpisodes); - updateEpisodeJumpControl(shownCachedEpisodes.size()); - if (!highlightEpisodeId.isEmpty()) { - m_episodeWidget->gallery()->setHighlightedItemId(highlightEpisodeId); - if (scrollToHighlight) { - m_episodeWidget->gallery()->scrollToItemId(highlightEpisodeId); - } - } - m_appliedCachedSeasonEpisodesToUi = true; - } - showedCachedEpisodes = true; - qDebug() << "[DetailView][cache-ui] Applied cached season episodes" - << "seriesId=" << m_currentItemId << "seasonId=" << seasonId - << "seasonIndex=" << idx - << "episodeCount=" << shownCachedEpisodes.size(); - } else { - m_currentSeasonEpisodes.clear(); - updateEpisodeJumpControl(0); - } - - if (!m_core || !m_core->mediaService()) - co_return; - - QPointer safeThis(this); - const QString seriesId = m_currentItemId; - QEmbyCore *core = m_core; - + QPointer guard(this); try { - qDebug() << "[DetailView][network] Fetch season episodes" - << "seriesId=" << seriesId << "seasonId=" << seasonId - << "seasonIndex=" << idx; - QList episodes = - co_await core->mediaService()->getEpisodes(seriesId, seasonId); - - if (!safeThis || safeThis->m_currentItemId != seriesId || - safeThis->m_currentSeasonIndex != idx) - co_return; - - bool episodesUnchanged = false; - if (showedCachedEpisodes) { - episodesUnchanged = - co_await mediaItemListsEqualAsync(shownCachedEpisodes, episodes); - if (!safeThis || safeThis->m_currentItemId != seriesId || - safeThis->m_currentSeasonIndex != idx) + if (item.mediaSources.isEmpty()) { + const PlaybackInfo info = + co_await m_core->mediaService()->getPlaybackInfo(item.id); + if (!guard) co_return; + item.mediaSources = info.mediaSources; } - safeThis->m_currentSeasonEpisodes = episodes; - if (!safeThis->m_hasManualSeriesSelection) { - safeThis->m_cachedSeasonEpisodes = episodes; - safeThis->m_cachedSeasonIndex = idx; - safeThis->m_cachedSeasonId = seasonId; - safeThis->m_hasCachedSeasonEpisodes = true; + QVariant launchData; + QString sourceId = item.id; + QString streamUrl; + if (!item.mediaSources.isEmpty()) { + sourceIndex = qBound(0, sourceIndex, item.mediaSources.size() - 1); + MediaSourceInfo selected = item.mediaSources.at(sourceIndex); + PlayerPreferenceUtils::applyPreferredStreamRules( + selected, + ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, + "auto"), + ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, + "auto")); + sourceId = selected.id; + PlayerLaunchContext context; + context.mediaItem = item; + context.selectedSource = selected; + launchData = QVariant::fromValue(context); + streamUrl = m_core->mediaService()->getStreamUrl(item.id, selected); } else { - qDebug() << "[DetailView] Skip persisting manually selected season " - "episodes" - << "seriesId=" << seriesId << "seasonId=" << seasonId - << "seasonIndex=" << idx; + streamUrl = m_core->mediaService()->getStreamUrl(item.id, sourceId); } - if (!manualSelection && !highlightEpisodeId.isEmpty() && - safeThis->m_currentPlayableItem.id != highlightEpisodeId) { - for (const MediaItem &episode : episodes) { - if (episode.id == highlightEpisodeId) { - safeThis->applySeriesPlayableItemToUi(episode); - break; - } - } - } - if (!episodesUnchanged) { - applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, - safeThis->m_episodeWidget, episodes); - } - safeThis->updateEpisodeJumpControl(episodes.size()); - qDebug() << "[DetailView][network] Loaded season episodes" - << "seriesId=" << seriesId << "seasonId=" << seasonId - << "seasonIndex=" << idx << "episodeCount=" << episodes.size() - << "fromCache=" << showedCachedEpisodes - << "unchanged=" << episodesUnchanged - << "visibleCardCapacity=" - << safeThis->episodeGalleryVisibleCardCapacity() - << "jumpVisible=" - << (safeThis->m_episodeJumpEdit && - !safeThis->m_episodeJumpEdit->isHidden()); - - if (!highlightEpisodeId.isEmpty()) { - safeThis->m_episodeWidget->gallery()->setHighlightedItemId( - highlightEpisodeId); - if (scrollToHighlight) { - safeThis->m_episodeWidget->gallery()->scrollToItemId( - highlightEpisodeId); - } - } - safeThis->persistDetailCacheSnapshot(QStringLiteral("episodes")); + if (!guard) + co_return; + const long long startTicks = + resume ? item.userData.playbackPositionTicks : 0; + emit navigateToPlayer(item.id, MediaItemUtils::playbackTitle(item), + streamUrl, startTicks, launchData); } catch (...) { - if (safeThis && safeThis->m_currentItemId == seriesId && - safeThis->m_currentSeasonIndex == idx) { - if (!showedCachedEpisodes) { - safeThis->m_currentSeasonEpisodes.clear(); - safeThis->updateEpisodeJumpControl(0); - applyReservedSectionItems(safeThis->m_episodeSectionReserveWidget, - safeThis->m_episodeWidget, {}); - } - qDebug() << "[DetailView] Failed to load season episodes" - << "seriesId=" << seriesId << "seasonId=" << seasonId - << "seasonIndex=" << idx - << "keptCache=" << showedCachedEpisodes; - } + if (guard) + m_viewModel->setTransientError(tr("Unable to prepare playback")); } } - - -QCoro::Task DetailView::switchToSeason(int idx, bool manualSelection) { - co_await loadEpisodesForSeason(idx, QString(), false, manualSelection); -} diff --git a/src/qEmbyApp/views/media/detailview.h b/src/qEmbyApp/views/media/detailview.h index 829e0c9..0045f4c 100644 --- a/src/qEmbyApp/views/media/detailview.h +++ b/src/qEmbyApp/views/media/detailview.h @@ -2,265 +2,36 @@ #define DETAILVIEW_H #include "../baseview.h" -#include "models/media/mediaitem.h" + #include -class QScrollArea; -class QVBoxLayout; -class QGridLayout; -class QLabel; -class QPushButton; -class QLineEdit; -class QIntValidator; -class FlowLayout; -class ModernMenuButton; -class SmoothScrollController; +class DetailViewModel; +class QuickPageHost; -class DetailActionWidget; -class DetailBottomInfoWidget; -class MediaSectionWidget; -class QGraphicsDropShadowEffect; - -class DetailView : public BaseView { +// QWidget compatibility shell for the QML detail page. HomeView constructs and +// connects this class exactly as before; presentation state lives in +// DetailViewModel. +class DetailView final : public BaseView { Q_OBJECT public: explicit DetailView(QEmbyCore *core, QWidget *parent = nullptr); - - - - - QCoro::Task loadItem(const QString &itemId, const MediaItem &seedItem = {}); -public Q_SLOTS: + QCoro::Task loadItem(const QString &itemId, + const MediaItem &seedItem = {}); + void prepareForStackLeave() override; + +public slots: + void scrollToTop() override; void onMediaItemUpdated(const MediaItem &item) override; - void beginOptimisticPlayedUpdate() override; void endOptimisticPlayedUpdate() override; -protected: - void resizeEvent(QResizeEvent *event) override; - bool eventFilter(QObject *obj, QEvent *event) override; - void showEvent(QShowEvent *event) override; - -private slots: - void scrollToTop() override; - QCoro::Task onVersionChanged(int index); - void onOverviewMoreClicked(const QString &link); - private: - void setupUi(); - void updateBackdrop(); - void updateOverviewElidedText(); - void updateTagLayoutHeight(); - void updateMetaRow(const MediaItem &item, - const QString &leadingMeta = QString()); - QString formatRunTime(long long ticks); - void clearLayout(QLayout *layout); - bool shouldShowDisplayNumber(const MediaItem &item) const; - bool isCurrentActionItem(const QString &itemId) const; - QStringList buildNumberCandidates(const MediaItem &item) const; - QString extractDisplayNumber(const MediaItem &item) const; - void updateDisplayNumber(const QString &number); - void copyDisplayedNumber(); - void markManualSeriesSelection(const QString &reason); - void rememberSeriesSelection(const MediaItem &episode, - const QString &reason = QString(), - bool persist = false); - void applySeriesPlayableItemToUi(const MediaItem &playableItem, - bool scrollToEpisode = false); - QCoro::Task applySeriesPlayableItem(MediaItem playableItem, - bool scrollToEpisode = false); + QCoro::Task playSelected(MediaItem item, int sourceIndex, bool resume); - QCoro::Task updateUi(MediaItem item, bool isSilentRefresh = false); - - - - - void applySeedToUi(const MediaItem &seed); - - - - void buildTagButtons(const QStringList &genres); - void applyCastSection(const MediaItem &item); - void applyCollectionGalleryLayout(const QList &collections); - QCoro::Task executePlay(MediaItem targetItem, long long startTicks); - QCoro::Task executePlaySeason(MediaItem seasonItem); - QCoro::Task executeExternalPlay(MediaItem targetItem, QString playerPath); - QCoro::Task fetchSeriesNextUp(const QString &targetId, - bool applyToUi = true); - void updateSeasonSwitcher(int currentIndex); - void updateEpisodeHeaderControlsVisibility(); - void updateEpisodeJumpControl(int episodeCount); - int episodeGalleryVisibleCardCapacity() const; - MediaItem episodeForJumpNumber(int episodeNumber) const; - void submitEpisodeJump(); - QCoro::Task loadEpisodesForSeason(int idx, - QString highlightEpisodeId = QString(), - bool scrollToHighlight = false, - bool manualSelection = false); - - - static QCoro::Task - executeFetchSecondaries(QPointer safeThis, QEmbyCore *core, - QString targetId, QString itemType); - static QCoro::Task executeSilentRefresh(QPointer safeThis, - QEmbyCore *core, - QString itemId); - static QCoro::Task executeLoadImages(QPointer safeThis, - QEmbyCore *core, MediaItem item, - bool allowPrimaryBackdropFallback); - - - - QCoro::Task loadDetailCacheAndStartPrefetch(QString itemId, - MediaItem seedItem, - QString cacheServerId, - QString cacheUserId); - - QCoro::Task prefetchItemDetail(QString itemId, QString cacheServerId, - QString cacheUserId, - QString cachedFingerprint); - - - - void maybeFlushDeferredUpdate(const QString &itemId); - - QCoro::Task executeDeferredUpdate(MediaItem item, - bool isSilentRefresh = false); - void setDeferredPresentationBatchActive(bool active); - void finishDeferredPresentationBatch(const QString &targetId, - const QString &detectedType); - void startSecondaryFetches(const QString &targetId, - const QString &detectedType); - void applyCachedSecondaryData(const QString &targetId, - const QString &detectedType, - bool revealBottomInfo = true); - void persistDetailCacheSnapshot(const QString &reason = QString()); - - QCoro::Task switchToSeason(int idx, - bool manualSelection = false); - - - QString m_currentItemId; - MediaItem m_currentMediaItem; - MediaItem m_currentPlayableItem; - bool m_isFavorite = false; - QString m_cleanOverviewText; - int m_lastOverviewWidth = -1; - int m_overviewElideGeneration = 0; - - - - - bool m_skipNextSilentRefresh = false; - - bool m_skipSilentRefresh = false; - - - QString m_pendingDeferredFetchId; - - MediaItem m_pendingFetchedItem; - bool m_pendingFetchReady = false; - bool m_pendingAnimationGuardDone = false; - bool m_pendingFetchedItemFromCache = false; - bool m_deferredPresentationBatchActive = false; - bool m_deferBottomInfoReveal = false; - QString m_detailCacheServerId; - QString m_detailCacheUserId; - QString m_cachedDetailFingerprint; - QString m_cachedSectionsFingerprint; - MediaItem m_cachedSeriesPlayableItem; - QList m_cachedSeriesSeasons; - QList m_cachedSeasonEpisodes; - QList m_cachedSimilarItems; - QList m_cachedCollections; - QList m_cachedAdditionalParts; - QString m_cachedSeasonId; - QString m_manualSelectedSeasonId; - QString m_manualSelectedEpisodeId; - int m_cachedSeasonIndex = -1; - int m_manualSelectedSeasonIndex = -1; - bool m_hasManualSeriesSelection = false; - bool m_hasCachedSeriesPlayableItem = false; - bool m_hasCachedSeriesSeasons = false; - bool m_hasCachedSeasonEpisodes = false; - bool m_hasCachedSimilarItems = false; - bool m_hasCachedCollections = false; - bool m_hasCachedAdditionalParts = false; - bool m_hasCachedCastData = false; - bool m_appliedCachedCastToUi = false; - bool m_appliedCachedSeriesSeasonsToUi = false; - bool m_appliedCachedSeasonEpisodesToUi = false; - bool m_appliedCachedSimilarItemsToUi = false; - bool m_appliedCachedCollectionsToUi = false; - bool m_appliedCachedAdditionalPartsToUi = false; - QString m_appliedCastFingerprint; - QString m_appliedSeriesPlayableFingerprint; - - - - - QString m_appliedSourcesFingerprint; - - - - - QString m_appliedBottomInfoFingerprint; - - - QScrollArea *m_mainScrollArea; - QWidget *m_contentWidget; - QGridLayout *m_infoLayout; - - QLabel *m_logoLabel; - QLabel *m_posterLabel; - QGraphicsDropShadowEffect *m_posterShadow = nullptr; - QPixmap m_currentPosterPix; - QPixmap m_currentBackdropPix; - - QWidget *m_textContainer; - QLabel *m_titleLabel; - QWidget *m_metaRowWidget; - QLabel *m_ratingStarLabel; - QLabel *m_metaLabel; - QPushButton *m_numberButton; - QLabel *m_taglineLabel; - QLabel *m_overviewLabel; - - QWidget *m_tagsWidget; - FlowLayout *m_tagsLayout; - - DetailActionWidget *m_actionWidget; - DetailBottomInfoWidget *m_bottomInfoWidget; - - - QWidget *m_seasonSectionReserveWidget = nullptr; - MediaSectionWidget *m_seasonWidget; - QWidget *m_episodeSectionReserveWidget = nullptr; - MediaSectionWidget *m_episodeWidget; - QWidget *m_castSectionReserveWidget = nullptr; - MediaSectionWidget *m_castWidget; - QWidget *m_additionalPartsSectionReserveWidget = nullptr; - MediaSectionWidget *m_additionalPartsWidget; - QWidget *m_collectionSectionReserveWidget = nullptr; - MediaSectionWidget *m_collectionWidget; - QWidget *m_similarSectionReserveWidget = nullptr; - MediaSectionWidget *m_similarWidget; - QWidget *m_bottomInfoReserveWidget = nullptr; - - - QWidget *m_episodeHeaderControls = nullptr; - ModernMenuButton *m_seasonSwitcher = - nullptr; - QLineEdit *m_episodeJumpEdit = nullptr; - QIntValidator *m_episodeJumpValidator = nullptr; - int m_episodeTileWidth = 0; - QList m_seriesSeasons; - QList m_currentSeasonEpisodes; - int m_currentSeasonIndex = 0; - - SmoothScrollController *m_vScrollController = nullptr; + QuickPageHost *m_quickHost = nullptr; + DetailViewModel *m_viewModel = nullptr; }; -#endif +#endif // DETAILVIEW_H diff --git a/src/qEmbyApp/views/media/detailviewmodel.cpp b/src/qEmbyApp/views/media/detailviewmodel.cpp new file mode 100644 index 0000000..0083598 --- /dev/null +++ b/src/qEmbyApp/views/media/detailviewmodel.cpp @@ -0,0 +1,493 @@ +#include "detailviewmodel.h" + +#include +#include + +#include +#include +#include + +#include + +namespace { + +QString imageSource(const MediaItem &item, bool thumb = false) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(best.first) && + !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + if (imageId.isEmpty()) + return {}; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, best.first); +} + +QString backdropSource(const MediaItem &item) { + const auto best = item.images.bestBackdrop(); + if (item.id.isEmpty() || best.first.isEmpty()) + return {}; + QString imageId = item.id; + if (item.images.isParentTag(best.first) && + !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + const QString type = + best.second.isEmpty() ? QStringLiteral("Backdrop") : best.second; + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, best.first); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) + parts.append(item.seriesName); + if (item.type == QLatin1String("Episode")) { + if (item.parentIndexNumber >= 0) + parts.append(QStringLiteral("S%1").arg(item.parentIndexNumber, 2, 10, + QLatin1Char('0'))); + if (item.indexNumber >= 0) + parts.append( + QStringLiteral("E%1").arg(item.indexNumber, 2, 10, QLatin1Char('0'))); + } else if (item.productionYear > 0) { + parts.append(QString::number(item.productionYear)); + } + if (parts.isEmpty()) + parts.append(item.type); + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series") || + item.mediaType == QLatin1String("Video"); +} + +QString formatRuntime(long long ticks) { + const qint64 minutes = ticks / 600000000LL; + if (minutes <= 0) + return {}; + if (minutes < 60) + return QObject::tr("%1 min").arg(minutes); + return QObject::tr("%1 h %2 min").arg(minutes / 60).arg(minutes % 60); +} + +MediaItem personItem(const MediaPersonInfo &person) { + MediaItem item; + item.id = person.id; + item.name = person.name; + item.type = QStringLiteral("Person"); + item.overview = person.role; + item.images.primaryTag = person.primaryImageTag; + return item; +} + +} // namespace + +DetailMediaModel::DetailMediaModel(QObject *parent) + : QAbstractListModel(parent) {} + +int DetailMediaModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant DetailMediaModel::data(const QModelIndex &index, int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + const MediaUserDataInfo &userData = + item->hasResumeContext ? item->resumeUserData : item->userData; + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return item->type == QLatin1String("Person") && !item->overview.isEmpty() + ? item->overview + : subtitleFor(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return isPlayable(*item); + case PlayedRole: + return userData.played; + case ProgressRole: + return userData.playedPercentage; + default: + return {}; + } +} + +QHash DetailMediaModel::roleNames() const { + return {{ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + {PlayedRole, "played"}, + {ProgressRole, "progress"}}; +} + +const MediaItem *DetailMediaModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +void DetailMediaModel::setItems(QList items) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); +} + +void DetailMediaModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + m_items[row] = item; + emit dataChanged(index(row), index(row)); + return; + } +} + +DetailViewModel::DetailViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_seasons(this), m_episodes(this), + m_cast(this), m_collections(this), m_additional(this), m_similar(this) {} + +QString DetailViewModel::tagline() const { + return m_item.taglines.isEmpty() ? QString() : m_item.taglines.first(); +} + +QString DetailViewModel::metaText() const { + QStringList parts; + if (m_item.productionYear > 0) + parts.append(QString::number(m_item.productionYear)); + if (!m_item.officialRating.isEmpty()) + parts.append(m_item.officialRating); + const QString runtime = + formatRuntime(actionItem().runTimeTicks > 0 ? actionItem().runTimeTicks + : m_item.runTimeTicks); + if (!runtime.isEmpty()) + parts.append(runtime); + if (m_item.communityRating > 0) + parts.append(QStringLiteral("★ %1").arg(m_item.communityRating, 0, 'f', 1)); + return parts.join(QStringLiteral(" · ")); +} + +QString DetailViewModel::posterSource() const { return imageSource(m_item); } + +QString DetailViewModel::backdropSource() const { + return ::backdropSource(m_item); +} + +QStringList DetailViewModel::sourceNames() const { + QStringList names; + const auto &sources = actionItem().mediaSources; + for (int i = 0; i < sources.size(); ++i) { + const MediaSourceInfo &source = sources.at(i); + QString name = source.name.trimmed(); + if (name.isEmpty()) + name = source.container.toUpper(); + if (name.isEmpty()) + name = tr("Version %1").arg(i + 1); + names.append(name); + } + return names; +} + +bool DetailViewModel::playable() const { + if (m_item.type == QLatin1String("Series")) + return !m_playableItem.id.isEmpty(); + return isPlayable(actionItem()); +} + +const MediaItem &DetailViewModel::actionItem() const { + return m_playableItem.id.isEmpty() ? m_item : m_playableItem; +} + +void DetailViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_generation; + if (m_loading) + m_reloadOnActivate = true; + m_loading = false; + emit stateChanged(); + } else if (m_reloadOnActivate) { + m_reloadOnActivate = false; + launchReload(); + } +} + +void DetailViewModel::setActionBusy(bool busy) { + if (m_actionBusy == busy) + return; + m_actionBusy = busy; + emit stateChanged(); +} + +void DetailViewModel::setTransientError(QString message) { + m_errorText = std::move(message); + emit stateChanged(); +} + +void DetailViewModel::applySeed(const MediaItem &seed) { + if (seed.id.isEmpty()) + return; + m_item = seed; + m_itemId = seed.id; + m_selectedSource = 0; + emit stateChanged(); +} + +QCoro::Task DetailViewModel::loadItem(QString itemId, MediaItem seed) { + ++m_generation; + const quint64 generation = m_generation; + m_itemId = std::move(itemId); + m_errorText.clear(); + m_playableItem = {}; + m_seasons.setItems({}); + m_episodes.setItems({}); + m_cast.setItems({}); + m_collections.setItems({}); + m_additional.setItems({}); + m_similar.setItems({}); + if (!seed.id.isEmpty() && seed.id == m_itemId) + applySeed(seed); + m_loading = true; + emit stateChanged(); + + if (!m_core || !m_core->mediaService()) { + m_loading = false; + m_errorText = tr("Media service is unavailable"); + emit stateChanged(); + co_return; + } + + QPointer guard(this); + auto *service = m_core->mediaService(); + const QString requestedItemId = m_itemId; + try { + MediaItem item = co_await service->getItemDetail(requestedItemId); + if (!guard || !guard->m_active || generation != guard->m_generation || + requestedItemId != guard->m_itemId || !guard->m_core) + co_return; + m_item = std::move(item); + m_selectedSource = 0; + + QList people; + people.reserve(m_item.people.size()); + for (const MediaPersonInfo &person : std::as_const(m_item.people)) + people.append(personItem(person)); + m_cast.setItems(std::move(people)); + m_loading = false; + emit stateChanged(); + co_await loadSecondaries(generation, requestedItemId, m_item.type); + } catch (...) { + if (guard && guard->m_active && generation == guard->m_generation && + requestedItemId == guard->m_itemId) { + m_loading = false; + m_errorText = tr("Unable to load this item"); + emit stateChanged(); + } + } +} + +QCoro::Task DetailViewModel::loadSecondaries(quint64 generation, + QString itemId, + QString itemType) { + QPointer guard(this); + auto current = [&]() { + return guard && guard->m_active && generation == guard->m_generation && + itemId == guard->m_itemId; + }; + if (itemType == QLatin1String("Series")) { + try { + const QList nextUp = + co_await m_core->mediaService()->getNextUp(itemId); + if (!current()) + co_return; + if (!nextUp.isEmpty()) + m_playableItem = nextUp.first(); + } catch (...) { + // Next-up is optional and restricted on some servers. + } + + try { + const QList seasons = + co_await m_core->mediaService()->getSeasons(itemId); + if (!current()) + co_return; + m_seasons.setItems(seasons); + if (!seasons.isEmpty()) { + QString seasonId = seasons.first().id; + if (!m_playableItem.id.isEmpty()) { + for (const MediaItem &season : seasons) { + if (season.indexNumber == m_playableItem.parentIndexNumber) { + seasonId = season.id; + break; + } + } + } + try { + const QList episodes = + co_await m_core->mediaService()->getEpisodes(itemId, seasonId); + if (!current()) + co_return; + m_episodes.setItems(episodes); + if (m_playableItem.id.isEmpty() && !episodes.isEmpty()) { + auto candidate = episodes.constBegin(); + for (auto it = episodes.constBegin(); it != episodes.constEnd(); + ++it) { + if (!it->userData.played) { + candidate = it; + break; + } + } + m_playableItem = *candidate; + } + } catch (...) { + // The season list remains useful even if episodes cannot be loaded. + } + } + emit stateChanged(); + } catch (...) { + // The base detail and any next-up item remain usable. + } + } + + try { + const QList additional = + co_await m_core->mediaService()->getAdditionalParts(itemId); + if (!current()) + co_return; + m_additional.setItems(additional); + emit stateChanged(); + } catch (...) { + } + + try { + const QList collections = + co_await m_core->mediaService()->getItemCollections(itemId); + if (!current()) + co_return; + m_collections.setItems(collections); + emit stateChanged(); + } catch (...) { + } + + try { + const QList similar = + itemType == QLatin1String("Person") + ? co_await m_core->mediaService()->getItemsByPerson(itemId) + : co_await m_core->mediaService()->getSimilarItems(itemId, 15); + if (!current()) + co_return; + m_similar.setItems(similar); + emit stateChanged(); + } catch (...) { + // Detail is useful without optional sections. Do not replace it with an + // error state when one server endpoint is unsupported. + } +} + +void DetailViewModel::updateItem(const MediaItem &item) { + if (item.id == m_item.id) + m_item = item; + if (item.id == m_playableItem.id) + m_playableItem = item; + m_seasons.updateItem(item); + m_episodes.updateItem(item); + m_cast.updateItem(item); + m_collections.updateItem(item); + m_additional.updateItem(item); + m_similar.updateItem(item); + emit stateChanged(); +} + +void DetailViewModel::setSelectedSource(int index) { + if (index < 0 || index >= actionItem().mediaSources.size() || + m_selectedSource == index) + return; + m_selectedSource = index; + emit stateChanged(); +} + +void DetailViewModel::launchReload() { + const QString id = m_itemId; + QMetaObject::invokeMethod( + this, [this, id]() { loadItem(id); }, Qt::QueuedConnection); +} + +void DetailViewModel::retry() { launchReload(); } + +void DetailViewModel::requestPlay(bool resume) { + if (playable() && !m_actionBusy) + emit playRequested(actionItem(), m_selectedSource, resume); +} + +void DetailViewModel::requestFavorite() { + if (!m_item.id.isEmpty() && !m_actionBusy) + emit favoriteRequested(m_item); +} + +void DetailViewModel::requestPlayed() { + if (!actionItem().id.isEmpty() && !m_actionBusy) + emit playedRequested(actionItem(), !played()); +} + +void DetailViewModel::requestMore() { + if (!m_item.id.isEmpty()) + emit moreRequested(m_item); +} + +void DetailViewModel::activateGenre(int index) { + if (index >= 0 && index < m_item.genres.size()) + emit genreActivated(m_item.genres.at(index)); +} + +DetailMediaModel *DetailViewModel::sectionModel(const QString §ion) { + if (section == QLatin1String("seasons")) + return &m_seasons; + if (section == QLatin1String("episodes")) + return &m_episodes; + if (section == QLatin1String("cast")) + return &m_cast; + if (section == QLatin1String("collections")) + return &m_collections; + if (section == QLatin1String("additional")) + return &m_additional; + return &m_similar; +} + +void DetailViewModel::activateSectionItem(QString section, int row) { + if (const MediaItem *item = sectionModel(section)->itemAt(row)) + emit itemActivated(*item); +} + +void DetailViewModel::playSectionItem(QString section, int row) { + if (const MediaItem *item = sectionModel(section)->itemAt(row)) + emit playRequested(*item, 0, true); +} + +void DetailViewModel::favoriteSectionItem(QString section, int row) { + if (const MediaItem *item = sectionModel(section)->itemAt(row)) + emit favoriteRequested(*item); +} + +void DetailViewModel::moreSectionItem(QString section, int row) { + if (const MediaItem *item = sectionModel(section)->itemAt(row)) + emit moreRequested(*item); +} diff --git a/src/qEmbyApp/views/media/detailviewmodel.h b/src/qEmbyApp/views/media/detailviewmodel.h new file mode 100644 index 0000000..d041d09 --- /dev/null +++ b/src/qEmbyApp/views/media/detailviewmodel.h @@ -0,0 +1,165 @@ +#ifndef DETAILVIEWMODEL_H +#define DETAILVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class DetailMediaModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + PlayedRole, + ProgressRole, + }; + + explicit DetailMediaModel(QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + const MediaItem *itemAt(int row) const; + void setItems(QList items); + void updateItem(const MediaItem &item); + +private: + QList m_items; +}; + +class DetailViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY stateChanged) + Q_PROPERTY(QString tagline READ tagline NOTIFY stateChanged) + Q_PROPERTY(QString overview READ overview NOTIFY stateChanged) + Q_PROPERTY(QString metaText READ metaText NOTIFY stateChanged) + Q_PROPERTY(QString posterSource READ posterSource NOTIFY stateChanged) + Q_PROPERTY(QString backdropSource READ backdropSource NOTIFY stateChanged) + Q_PROPERTY(QStringList genres READ genres NOTIFY stateChanged) + Q_PROPERTY(QStringList sourceNames READ sourceNames NOTIFY stateChanged) + Q_PROPERTY(int selectedSource READ selectedSource WRITE setSelectedSource + NOTIFY stateChanged) + Q_PROPERTY(bool favorite READ favorite NOTIFY stateChanged) + Q_PROPERTY(bool played READ played NOTIFY stateChanged) + Q_PROPERTY(bool playable READ playable NOTIFY stateChanged) + Q_PROPERTY(bool resumable READ resumable NOTIFY stateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool actionBusy READ actionBusy NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(bool hasSeasons READ hasSeasons NOTIFY stateChanged) + Q_PROPERTY(bool hasEpisodes READ hasEpisodes NOTIFY stateChanged) + Q_PROPERTY(bool hasCast READ hasCast NOTIFY stateChanged) + Q_PROPERTY(bool hasCollections READ hasCollections NOTIFY stateChanged) + Q_PROPERTY(bool hasAdditional READ hasAdditional NOTIFY stateChanged) + Q_PROPERTY(bool hasSimilar READ hasSimilar NOTIFY stateChanged) + Q_PROPERTY(QAbstractItemModel *seasonsModel READ seasonsModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *episodesModel READ episodesModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *castModel READ castModel CONSTANT) + Q_PROPERTY( + QAbstractItemModel *collectionsModel READ collectionsModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *additionalModel READ additionalModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *similarModel READ similarModel CONSTANT) + +public: + explicit DetailViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QString title() const { return m_item.name; } + QString tagline() const; + QString overview() const { return m_item.overview; } + QString metaText() const; + QString posterSource() const; + QString backdropSource() const; + QStringList genres() const { return m_item.genres; } + QStringList sourceNames() const; + int selectedSource() const { return m_selectedSource; } + bool favorite() const { return m_item.isFavorite(); } + bool played() const { return actionItem().userData.played; } + bool playable() const; + bool resumable() const { + return actionItem().userData.playbackPositionTicks > 0; + } + bool loading() const { return m_loading; } + bool actionBusy() const { return m_actionBusy; } + QString errorText() const { return m_errorText; } + bool hasSeasons() const { return m_seasons.rowCount() > 0; } + bool hasEpisodes() const { return m_episodes.rowCount() > 0; } + bool hasCast() const { return m_cast.rowCount() > 0; } + bool hasCollections() const { return m_collections.rowCount() > 0; } + bool hasAdditional() const { return m_additional.rowCount() > 0; } + bool hasSimilar() const { return m_similar.rowCount() > 0; } + const MediaItem &item() const { return m_item; } + + QAbstractItemModel *seasonsModel() { return &m_seasons; } + QAbstractItemModel *episodesModel() { return &m_episodes; } + QAbstractItemModel *castModel() { return &m_cast; } + QAbstractItemModel *collectionsModel() { return &m_collections; } + QAbstractItemModel *additionalModel() { return &m_additional; } + QAbstractItemModel *similarModel() { return &m_similar; } + + void setActive(bool active); + void setActionBusy(bool busy); + void setTransientError(QString message); + void updateItem(const MediaItem &item); + QCoro::Task loadItem(QString itemId, MediaItem seed = {}); + + Q_INVOKABLE void setSelectedSource(int index); + Q_INVOKABLE void retry(); + Q_INVOKABLE void requestPlay(bool resume); + Q_INVOKABLE void requestFavorite(); + Q_INVOKABLE void requestPlayed(); + Q_INVOKABLE void requestMore(); + Q_INVOKABLE void activateGenre(int index); + Q_INVOKABLE void activateSectionItem(QString section, int row); + Q_INVOKABLE void playSectionItem(QString section, int row); + Q_INVOKABLE void favoriteSectionItem(QString section, int row); + Q_INVOKABLE void moreSectionItem(QString section, int row); + +signals: + void stateChanged(); + void itemActivated(const MediaItem &item); + void genreActivated(const QString &genre); + void playRequested(const MediaItem &item, int sourceIndex, bool resume); + void favoriteRequested(const MediaItem &item); + void playedRequested(const MediaItem &item, bool played); + void moreRequested(const MediaItem &item); + +private: + const MediaItem &actionItem() const; + DetailMediaModel *sectionModel(const QString §ion); + void launchReload(); + QCoro::Task loadSecondaries(quint64 generation, QString itemId, + QString itemType); + void applySeed(const MediaItem &seed); + + QPointer m_core; + MediaItem m_item; + MediaItem m_playableItem; + DetailMediaModel m_seasons; + DetailMediaModel m_episodes; + DetailMediaModel m_cast; + DetailMediaModel m_collections; + DetailMediaModel m_additional; + DetailMediaModel m_similar; + QString m_itemId; + QString m_errorText; + bool m_loading = false; + bool m_actionBusy = false; + bool m_active = true; + bool m_reloadOnActivate = false; + int m_selectedSource = 0; + quint64 m_generation = 0; +}; + +#endif // DETAILVIEWMODEL_H diff --git a/src/qEmbyApp/views/media/libraryview.cpp b/src/qEmbyApp/views/media/libraryview.cpp index 26003f1..d199b4e 100644 --- a/src/qEmbyApp/views/media/libraryview.cpp +++ b/src/qEmbyApp/views/media/libraryview.cpp @@ -1,1102 +1,91 @@ #include "libraryview.h" -#include "../../components/elidedlabel.h" -#include "../../components/mediagridwidget.h" -#include "../../components/modernsortbutton.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "libraryviewmodel.h" + +#include +#include +#include +#include #include -#include -#include -#include -#include -#include -#include LibraryView::LibraryView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent), m_currentMode(LibraryMode) -{ - setProperty("showGlobalSearch", true); - setAttribute(Qt::WA_StyledBackground, true); - setObjectName("library-view"); + : BaseView(core, parent) { + setObjectName(QStringLiteral("library-view")); + setProperty("showGlobalSearch", true); + setAttribute(Qt::WA_StyledBackground, true); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); - - QScrollArea *headerScrollArea = new QScrollArea(this); - headerScrollArea->setFrameShape(QFrame::NoFrame); - headerScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setWidgetResizable(true); + m_quickHost = new QuickPageHost(this); + m_viewModel = new LibraryViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("libraryPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/LibraryPage.qml"))); + layout->addWidget(m_quickHost); - headerScrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } " - "QWidget#library-header-container { background: transparent; }"); + connect(m_quickHost, &QuickPageHost::pageShown, this, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, this, + [this]() { m_viewModel->setActive(false); }); - headerScrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - headerScrollArea->setFixedHeight(65); - - QWidget *headerContainer = new QWidget(headerScrollArea); - headerContainer->setObjectName("library-header-container"); - - auto *headerLayout = new QHBoxLayout(headerContainer); - headerLayout->setContentsMargins(20, 15, 20, 10); - headerLayout->setSpacing(20); - headerLayout->setAlignment(Qt::AlignVCenter); - - setupTopBar(headerLayout); - headerScrollArea->setWidget(headerContainer); - - mainLayout->addWidget(headerScrollArea); - - - - m_mediaGrid = new MediaGridWidget(m_core, this); - mainLayout->addWidget(m_mediaGrid); - - - - connect(m_mediaGrid, &MediaGridWidget::itemClicked, this, - [this](const MediaItem &item) - { - - if (item.type == "BoxSet" || item.type == "Playlist" || item.type == "Folder") - { - Q_EMIT navigateToFolder(item.id, item.name); - } - else if (item.type == "Person") - { - - Q_EMIT navigateToPerson(item.id, item.name); - } - else - { - Q_EMIT navigateToDetail(item.id, item.name, item); - } - }); - - - - - connect(m_mediaGrid, &MediaGridWidget::playRequested, this, &BaseView::handlePlayRequested); - connect(m_mediaGrid, &MediaGridWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); - connect(m_mediaGrid, &MediaGridWidget::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); - connect(m_mediaGrid, &MediaGridWidget::loadMoreRequested, this, &LibraryView::onLoadMoreRequested); - -} - -void LibraryView::setupTopBar(QHBoxLayout *headerLayout) -{ - - - - auto *titleLayout = new QHBoxLayout(); - titleLayout->setContentsMargins(0, 0, 0, 0); - titleLayout->setSpacing(8); - - - m_titleLabel = new ElidedLabel(this); - m_titleLabel->setObjectName("library-title"); - m_titleLabel->setStyleSheet("padding: 0px;"); - - m_titleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - - - m_favBtn = new QPushButton(this); - m_favBtn->setObjectName("detail-fav-btn"); - m_favBtn->setIcon(QIcon(":/svg/light/heart-outline.svg")); - m_favBtn->setIconSize(QSize(20, 20)); - m_favBtn->setFixedSize(36, 36); - m_favBtn->setCursor(Qt::PointingHandCursor); - m_favBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - m_favBtn->setFocusPolicy(Qt::NoFocus); - m_favBtn->hide(); - - - connect(m_favBtn, &QPushButton::clicked, this, - [this]() - { - if (!m_currentMediaItem.id.isEmpty()) - { - handleFavoriteRequested(m_currentMediaItem); - } - }); - - titleLayout->addWidget(m_titleLabel); - titleLayout->addWidget(m_favBtn); - headerLayout->addLayout(titleLayout); - - - m_tabBarWidget = new QWidget(this); - auto *tabLayout = new QHBoxLayout(m_tabBarWidget); - tabLayout->setContentsMargins(0, 0, 0, 0); - tabLayout->setSpacing(8); - - m_tabGroup = new QButtonGroup(this); - m_tabGroup->setExclusive(true); - - - QStringList tabs = {tr("All"), tr("Recent"), tr("Playlists"), tr("Collections"), tr("Favorites"), tr("Folders")}; - for (int i = 0; i < tabs.size(); ++i) - { - auto *btn = new QPushButton(tabs[i], m_tabBarWidget); - btn->setObjectName("library-tab-btn"); - btn->setCheckable(true); - m_tabGroup->addButton(btn, i); - tabLayout->addWidget(btn); - } - m_tabGroup->button(0)->setChecked(true); - connect(m_tabGroup, &QButtonGroup::idClicked, this, &LibraryView::onFilterChanged); - - headerLayout->addWidget(m_tabBarWidget); - headerLayout->addStretch(); - - - QWidget *filterBarWidget = new QWidget(this); - auto *filterLayout = new QHBoxLayout(filterBarWidget); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(10); - - - m_sortButton = new ModernSortButton(filterBarWidget); - m_sortButton->setSortItems({tr("Date Added"), tr("Date Played"), tr("Title"), tr("Runtime"), tr("Premiere Date")}); - connect(m_sortButton, &ModernSortButton::sortChanged, this, &LibraryView::onFilterChanged); - - - m_viewSwitchBtn = new QPushButton(filterBarWidget); - m_viewSwitchBtn->setObjectName("icon-action-btn"); - m_viewSwitchBtn->setCheckable(true); - m_viewSwitchBtn->setFixedSize(32, 32); - m_viewSwitchBtn->setCursor(Qt::PointingHandCursor); - m_viewSwitchBtn->setToolTip(tr("Toggle View Mode")); - - - connect(m_viewSwitchBtn, &QPushButton::clicked, this, - [this](bool checked) - { - m_mediaGrid->setCardStyle(checked ? MediaCardDelegate::LibraryTile : MediaCardDelegate::Poster); - saveViewPreference(); - onFilterChanged(); - }); - - m_statsLabel = new QLabel(tr("0 Items"), filterBarWidget); - m_statsLabel->setObjectName("library-stats-label"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - filterLayout->addWidget(m_sortButton); - filterLayout->addWidget(m_viewSwitchBtn); - filterLayout->addWidget(m_statsLabel); - - headerLayout->addWidget(filterBarWidget); -} - - -void LibraryView::updateFavBtnState() -{ - m_favBtn->setProperty("isFavorite", m_isFavorite); - m_favBtn->setIcon(QIcon(m_isFavorite ? ":/svg/light/heart-fill.svg" : ":/svg/light/heart-outline.svg")); - m_favBtn->style()->unpolish(m_favBtn); - m_favBtn->style()->polish(m_favBtn); -} - - -QCoro::Task LibraryView::loadLibrary(const QString &libraryId, const QString &libraryName) -{ - - QPointer guard(this); - - QElapsedTimer perfTimer; - perfTimer.start(); - qDebug() << "[LibraryView] loadLibrary START" << "| id=" << libraryId << "| name=" << libraryName; - - m_currentMode = LibraryMode; - m_currentLibraryId = libraryId; - m_currentMediaItem = MediaItem(); - - m_titleLabel->setFullText(libraryName); - m_titleLabel->ensurePolished(); - QFontMetrics fm(m_titleLabel->font()); - m_titleLabel->setMaximumWidth(fm.horizontalAdvance(libraryName) + 15); - - - - - m_favBtn->hide(); - m_tabBarWidget->hide(); - m_sortButton->show(); - - - m_tabGroup->button(0)->setText(tr("All")); - - m_tabGroup->blockSignals(true); - m_tabGroup->button(0)->setChecked(true); - m_tabGroup->blockSignals(false); - - m_sortButton->blockSignals(true); - m_sortButton->setSortItems({tr("Date Added"), tr("Date Played"), tr("Title"), tr("Runtime"), tr("Premiere Date")}); - m_sortButton->blockSignals(false); - - - restoreSortPreference(); - - restoreViewPreference(); - - - - const bool shimmerEnabled = - ConfigStore::instance()->get(ConfigKeys::ShimmerAnimation, false); - if (shimmerEnabled && m_mediaGrid) { - m_mediaGrid->setLoading(true); - } - - if (!libraryId.isEmpty()) - { - try - { - - MediaItem detail = co_await m_core->mediaService()->getItemDetail(libraryId); - qDebug() << "[LibraryView] getItemDetail completed" << "| elapsed=" << perfTimer.elapsed() << "ms"; - if (!guard) - co_return; - - - if (detail.id == m_currentLibraryId) - { - m_currentMediaItem = detail; - - - - - if (detail.collectionType == "movies") - { - m_tabGroup->button(0)->setText(tr("Movies")); - } - else if (detail.collectionType == "tvshows") - { - m_tabGroup->button(0)->setText(tr("Shows")); - } - else if (detail.collectionType == "music") - { - m_tabGroup->button(0)->setText(tr("Music")); - } - else if (detail.collectionType == "homevideos" || detail.collectionType == "photos") - { - m_tabGroup->button(0)->setText(tr("Videos")); - } - else - { - m_tabGroup->button(0)->setText(tr("All")); - } - - - - - bool isItemFolder = (detail.type == "BoxSet" || detail.type == "Playlist" || detail.type == "Folder"); - bool isCollectionLib = (detail.collectionType == "playlists" || detail.collectionType == "boxsets"); - if (isItemFolder) - { - m_isFavorite = detail.isFavorite(); - updateFavBtnState(); - m_favBtn->show(); - m_tabBarWidget->hide(); - } - else if (isCollectionLib) - { - m_favBtn->hide(); - m_tabBarWidget->hide(); - } - else - { - m_favBtn->hide(); - if (m_currentMode == LibraryMode) - { - m_tabBarWidget->show(); - } - } + connect(m_viewModel, &LibraryViewModel::itemActivated, this, + [this](const MediaItem &item) { + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) { + emit navigateToFolder(item.id, item.name); + } else if (item.type == QLatin1String("Person")) { + emit navigateToPerson(item.id, item.name); + } else { + emit navigateToDetail(item.id, item.name, item); } - } - catch (const std::exception &e) - { - if (!guard) - co_return; - qDebug() << "Failed to detect library type details: " << e.what(); - } - } - - - qDebug() << "[LibraryView] entering onFilterChanged" << "| elapsed=" << perfTimer.elapsed() << "ms"; - co_await onFilterChanged(); - qDebug() << "[LibraryView] loadLibrary END" << "| totalElapsed=" << perfTimer.elapsed() << "ms"; + }); + connect(m_viewModel, &LibraryViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_viewModel, &LibraryViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &LibraryViewModel::currentFavoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &LibraryViewModel::moreRequested, this, + [this](const MediaItem &item) { + handleMoreMenuRequested(item, QCursor::pos()); + }); } - -QCoro::Task LibraryView::loadPerson(const QString &personId, const QString &personName) -{ - - QPointer guard(this); - - m_currentMode = PersonMode; - m_currentPersonId = personId; - m_currentMediaItem = MediaItem(); - - m_titleLabel->setFullText(personName); - m_titleLabel->ensurePolished(); - QFontMetrics fm(m_titleLabel->font()); - m_titleLabel->setMaximumWidth(fm.horizontalAdvance(personName) + 15); - - - m_favBtn->hide(); - m_tabBarWidget->hide(); - m_sortButton->show(); - - m_sortButton->blockSignals(true); - - - m_sortButton->blockSignals(false); - - - restoreSortPreference(); - - restoreViewPreference(); - - - const bool shimmerEnabled = - ConfigStore::instance()->get(ConfigKeys::ShimmerAnimation, false); - if (shimmerEnabled && m_mediaGrid) { - m_mediaGrid->setLoading(true); - } - - - if (!personId.isEmpty()) - { - try - { - - MediaItem detail = co_await m_core->mediaService()->getItemDetail(personId); - if (!guard) - co_return; - - if (detail.id == m_currentPersonId) - { - m_currentMediaItem = detail; - m_isFavorite = detail.isFavorite(); - updateFavBtnState(); - m_favBtn->show(); - } - } - catch (const std::exception &e) - { - if (!guard) - co_return; - qDebug() << "Failed to fetch person details: " << e.what(); - } - } - - co_await onFilterChanged(); +QCoro::Task LibraryView::loadLibrary(const QString &libraryId, + const QString &libraryName) { + co_await m_viewModel->loadLibrary(libraryId, libraryName); } - -QCoro::Task LibraryView::loadFiltered(const QString &filterType, const QString &filterValue) -{ - QPointer guard(this); - - m_currentMode = FilteredMode; - m_filterType = filterType; - m_filterValue = filterValue; - m_currentLibraryId.clear(); - m_currentPersonId.clear(); - m_currentMediaItem = MediaItem(); - - m_titleLabel->setFullText(filterValue); - m_titleLabel->ensurePolished(); - QFontMetrics fm(m_titleLabel->font()); - m_titleLabel->setMaximumWidth(fm.horizontalAdvance(filterValue) + 15); - - m_favBtn->hide(); - m_tabBarWidget->hide(); - m_sortButton->show(); - - m_sortButton->blockSignals(true); - m_sortButton->setSortItems({tr("Date Added"), tr("Date Played"), tr("Title"), tr("Runtime"), tr("Premiere Date")}); - m_sortButton->blockSignals(false); - - restoreViewPreference(); - - - const bool shimmerEnabled = - ConfigStore::instance()->get(ConfigKeys::ShimmerAnimation, false); - if (shimmerEnabled && m_mediaGrid) { - m_mediaGrid->setLoading(true); - } - - co_await onFilterChanged(); +QCoro::Task LibraryView::loadPerson(const QString &personId, + const QString &personName) { + co_await m_viewModel->loadPerson(personId, personName); } - -QCoro::Task LibraryView::onFilterChanged() -{ - - QPointer guard(this); - - - saveSortPreference(); - ++m_requestGeneration; - resetPaginationState(); - - m_mediaGrid->setItems(QList()); - m_statsLabel->setText(tr("Loading...")); - - - const bool shimmerEnabled = - ConfigStore::instance()->get(ConfigKeys::ShimmerAnimation, false); - if (shimmerEnabled && m_mediaGrid) { - m_mediaGrid->setLoading(true); - } - - - QString sortBy = "SortName"; - switch (m_sortButton->currentIndex()) - { - case 0: - sortBy = "DateCreated"; - break; - case 1: - sortBy = "DatePlayed"; - break; - case 2: - sortBy = "SortName"; - break; - case 3: - sortBy = "Runtime"; - break; - case 4: - sortBy = "PremiereDate"; - break; - } - QString sortOrder = m_sortButton->isDescending() ? "Descending" : "Ascending"; - m_activeQuery = buildCurrentQueryState(sortBy, sortOrder); - qDebug() << "[LibraryView] onFilterChanged" - << "| generation=" << m_requestGeneration - << "| mode=" << static_cast(m_activeQuery.mode) - << "| targetId=" << m_activeQuery.targetId - << "| sortBy=" << m_activeQuery.sortBy - << "| sortOrder=" << m_activeQuery.sortOrder - << "| filters=" << m_activeQuery.filters - << "| includeTypes=" << m_activeQuery.includeTypes - << "| recursive=" << m_activeQuery.recursive; - - if (!isQueryValid(m_activeQuery)) - { - if (!guard) - co_return; - if (m_mediaGrid) { - m_mediaGrid->setLoading(false); - } - updateStatsLabel(); - co_return; - } - - co_await loadInitialItems(); +QCoro::Task LibraryView::loadFiltered(const QString &filterType, + const QString &filterValue) { + co_await m_viewModel->loadFiltered(filterType, filterValue); } -LibraryView::QueryState LibraryView::buildCurrentQueryState(const QString &sortBy, - const QString &sortOrder) const -{ - QueryState query; - query.mode = m_currentMode; - query.sortBy = sortBy; - query.sortOrder = sortOrder; +void LibraryView::prepareForStackLeave() { m_viewModel->setActive(false); } - if (m_currentMode == PersonMode) - { - query.targetId = m_currentPersonId; - return query; - } - - if (m_currentMode == FilteredMode) - { - if (m_filterType == "Genre") - query.genreFilter = m_filterValue; - else if (m_filterType == "Tag") - query.tagFilter = m_filterValue; - else if (m_filterType == "Studio") - query.studioFilter = m_filterValue; - return query; - } - - query.targetId = m_currentLibraryId; - query.includeTypes = "Movie,Series,Audio,Video"; - query.recursive = true; - query.needsPlaylistEnrichment = (m_currentMediaItem.type == "Playlist"); - - if (m_currentMediaItem.type == "Folder") - { - query.includeTypes.clear(); - query.sortBy = "IsFolder,SortName"; - query.sortOrder = "Ascending"; - query.recursive = false; - return query; - } - - if (m_currentMediaItem.type == "BoxSet" || m_currentMediaItem.type == "Playlist" || - m_currentMediaItem.collectionType == "playlists" || m_currentMediaItem.collectionType == "boxsets") - { - query.includeTypes.clear(); - query.recursive = false; - return query; - } - - const int tabIdx = m_tabGroup ? m_tabGroup->checkedId() : 0; - if (tabIdx == 1) - { - query.sortBy = "DateCreated"; - query.sortOrder = "Descending"; - } - if (tabIdx == 2) - { - query.includeTypes = "Playlist"; - } - if (tabIdx == 3) - { - query.includeTypes = "BoxSet"; - } - if (tabIdx == 4) - { - query.filters = "IsFavorite"; - query.includeTypes = "Movie,Series,Audio,Video,BoxSet,Playlist"; - } - if (tabIdx == 5) - { - query.includeTypes.clear(); - query.sortBy = "IsFolder,SortName"; - query.sortOrder = "Ascending"; - query.recursive = false; - } - - return query; +void LibraryView::scrollToTop() { + if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) + QMetaObject::invokeMethod(root, "scrollToTop"); } -bool LibraryView::isQueryValid(const QueryState &query) const -{ - switch (query.mode) - { - case PersonMode: - case LibraryMode: - return !query.targetId.trimmed().isEmpty(); - case FilteredMode: - return !query.genreFilter.trimmed().isEmpty() || - !query.tagFilter.trimmed().isEmpty() || - !query.studioFilter.trimmed().isEmpty(); - default: - return false; - } +void LibraryView::onMediaItemUpdated(const MediaItem &item) { + m_viewModel->updateItem(item); } -void LibraryView::resetPaginationState() -{ - m_loadedItems.clear(); - m_totalItemCount = 0; - m_pageSize = 0; - m_hasMoreItems = false; - m_isLoadingMore = false; -} - -void LibraryView::updateStatsLabel() -{ - const int loadedCount = m_loadedItems.size(); - const int totalCount = qMax(m_totalItemCount, loadedCount); - if (totalCount > loadedCount) - { - m_statsLabel->setText(tr("%1 / %2 Items").arg(loadedCount).arg(totalCount)); - return; - } - - m_statsLabel->setText(tr("%1 Items").arg(totalCount)); -} - -QCoro::Task LibraryView::loadInitialItems() -{ - QPointer guard(this); - const int generation = m_requestGeneration; - const QueryState query = m_activeQuery; - - auto fetchPage = [this](const QueryState &pageQuery, int startIndex, int limit) - -> QCoro::Task - { - switch (pageQuery.mode) - { - case PersonMode: - co_return co_await m_core->mediaService()->getItemsByPersonPage( - pageQuery.targetId, pageQuery.sortBy, pageQuery.sortOrder, - startIndex, limit); - case FilteredMode: - co_return co_await m_core->mediaService()->getItemsByFilterPage( - pageQuery.genreFilter, pageQuery.tagFilter, pageQuery.studioFilter, - pageQuery.sortBy, pageQuery.sortOrder, startIndex, limit); - case LibraryMode: - default: - co_return co_await m_core->mediaService()->getLibraryItemsPage( - pageQuery.targetId, pageQuery.sortBy, pageQuery.sortOrder, - pageQuery.filters, pageQuery.includeTypes, startIndex, limit, - pageQuery.recursive, pageQuery.includeChildCount); - } - }; - - m_isLoadingMore = true; - - try - { - const MediaQueryPage probePage = co_await fetchPage(query, 0, 1); - if (!guard || generation != m_requestGeneration) - co_return; - - m_totalItemCount = qMax(probePage.totalRecordCount, probePage.items.size()); - qDebug() << "[LibraryView] initial count probe" - << "| generation=" << generation - << "| returned=" << probePage.items.size() - << "| total=" << m_totalItemCount; - - QList initialItems = probePage.items; - int returnedCount = probePage.items.size(); - - if (m_totalItemCount > probePage.items.size()) - { - const MediaQueryPage fullAttemptPage = - co_await fetchPage(query, 0, m_totalItemCount); - if (!guard || generation != m_requestGeneration) - co_return; - - initialItems = fullAttemptPage.items; - returnedCount = fullAttemptPage.items.size(); - m_totalItemCount = qMax(fullAttemptPage.totalRecordCount, - fullAttemptPage.items.size()); - - qDebug() << "[LibraryView] full fetch attempt" - << "| generation=" << generation - << "| requested=" << m_totalItemCount - << "| returned=" << returnedCount - << "| total=" << m_totalItemCount; - } - - if (query.needsPlaylistEnrichment && !initialItems.isEmpty()) - { - initialItems = - co_await enrichPlaylistItemsForRemoval(std::move(initialItems)); - if (!guard || generation != m_requestGeneration) - co_return; - } - - m_loadedItems = std::move(initialItems); - m_totalItemCount = qMax(m_totalItemCount, m_loadedItems.size()); - - if (m_loadedItems.size() < m_totalItemCount && returnedCount > 0) - { - m_pageSize = returnedCount; - m_hasMoreItems = true; - qDebug() << "[LibraryView] server-side page cap detected" - << "| generation=" << generation - << "| pageSize=" << m_pageSize - << "| loaded=" << m_loadedItems.size() - << "| total=" << m_totalItemCount; - } - else - { - if (m_loadedItems.size() < m_totalItemCount && returnedCount <= 0) - { - qWarning() << "[LibraryView] Initial fetch returned no items while total count is larger" - << "| generation=" << generation - << "| loaded=" << m_loadedItems.size() - << "| total=" << m_totalItemCount; - } - m_pageSize = 0; - m_hasMoreItems = false; - } - - updateStatsLabel(); - QElapsedTimer renderTimer; - renderTimer.start(); - m_mediaGrid->setItems(m_loadedItems); - m_mediaGrid->setLoading(false); - qDebug() << "[LibraryView] initial items applied" - << "| generation=" << generation - << "| loaded=" << m_loadedItems.size() - << "| total=" << m_totalItemCount - << "| hasMore=" << m_hasMoreItems - << "| renderTime=" << renderTimer.elapsed() << "ms"; - } - catch (const std::exception &e) - { - if (!guard || generation != m_requestGeneration) - co_return; - if (m_mediaGrid) { - m_mediaGrid->setLoading(false); - } - m_statsLabel->setText(tr("Error Loading Items")); - qDebug() << "Library View initial fetching error:" << e.what(); - } - - if (guard && generation == m_requestGeneration) - { - m_isLoadingMore = false; - } -} - -QCoro::Task LibraryView::onLoadMoreRequested() -{ - QPointer guard(this); - if (m_isLoadingMore || !m_hasMoreItems || m_pageSize <= 0) - co_return; - - const int generation = m_requestGeneration; - const QueryState query = m_activeQuery; - const int startIndex = m_loadedItems.size(); - const int remainingCount = m_totalItemCount - startIndex; - if (remainingCount <= 0) - { - m_hasMoreItems = false; - co_return; - } - - const int limit = qMin(m_pageSize, remainingCount); - auto fetchPage = [this](const QueryState &pageQuery, int pageStartIndex, - int pageLimit) -> QCoro::Task - { - switch (pageQuery.mode) - { - case PersonMode: - co_return co_await m_core->mediaService()->getItemsByPersonPage( - pageQuery.targetId, pageQuery.sortBy, pageQuery.sortOrder, - pageStartIndex, pageLimit); - case FilteredMode: - co_return co_await m_core->mediaService()->getItemsByFilterPage( - pageQuery.genreFilter, pageQuery.tagFilter, pageQuery.studioFilter, - pageQuery.sortBy, pageQuery.sortOrder, pageStartIndex, pageLimit); - case LibraryMode: - default: - co_return co_await m_core->mediaService()->getLibraryItemsPage( - pageQuery.targetId, pageQuery.sortBy, pageQuery.sortOrder, - pageQuery.filters, pageQuery.includeTypes, pageStartIndex, - pageLimit, pageQuery.recursive, pageQuery.includeChildCount); - } - }; - - m_isLoadingMore = true; - qDebug() << "[LibraryView] load more START" - << "| generation=" << generation - << "| startIndex=" << startIndex - << "| limit=" << limit - << "| loaded=" << m_loadedItems.size() - << "| total=" << m_totalItemCount; - - try - { - MediaQueryPage page = co_await fetchPage(query, startIndex, limit); - if (!guard || generation != m_requestGeneration) - co_return; - - QList pageItems = page.items; - if (query.needsPlaylistEnrichment && !pageItems.isEmpty()) - { - pageItems = - co_await enrichPlaylistItemsForRemoval(std::move(pageItems)); - if (!guard || generation != m_requestGeneration) - co_return; - } - - if (pageItems.isEmpty()) - { - m_hasMoreItems = false; - qWarning() << "[LibraryView] load more returned empty page" - << "| generation=" << generation - << "| startIndex=" << startIndex - << "| limit=" << limit - << "| total=" << m_totalItemCount; - updateStatsLabel(); - } - else - { - m_loadedItems.append(pageItems); - m_totalItemCount = qMax(page.totalRecordCount, m_loadedItems.size()); - m_hasMoreItems = m_loadedItems.size() < m_totalItemCount; - - updateStatsLabel(); - m_mediaGrid->setItems(m_loadedItems); - qDebug() << "[LibraryView] load more END" - << "| generation=" << generation - << "| loaded=" << m_loadedItems.size() - << "| total=" << m_totalItemCount - << "| hasMore=" << m_hasMoreItems; - } - } - catch (const std::exception &e) - { - if (!guard || generation != m_requestGeneration) - co_return; - m_statsLabel->setText(tr("Error Loading Items")); - qDebug() << "Library View incremental fetching error:" << e.what(); - } - - if (guard && generation == m_requestGeneration) - { - m_isLoadingMore = false; - } -} - - - - -void LibraryView::onMediaItemUpdated(const MediaItem &item) -{ - MediaItem mergedItem = item; - - - QString currentId = (m_currentMode == PersonMode) ? m_currentPersonId : m_currentLibraryId; - if (currentId == item.id) - { - m_currentMediaItem = item; - m_isFavorite = item.isFavorite(); - updateFavBtnState(); - } - - for (MediaItem &loadedItem : m_loadedItems) - { - if (loadedItem.id == item.id) - { - if (mergedItem.playlistId.trimmed().isEmpty()) { - mergedItem.playlistId = loadedItem.playlistId; - } - if (mergedItem.playlistItemId.trimmed().isEmpty()) { - mergedItem.playlistItemId = loadedItem.playlistItemId; - } - loadedItem = mergedItem; - break; - } - } - - - - if (m_mediaGrid) - { - m_mediaGrid->updateItem(mergedItem); - } -} - -void LibraryView::onMediaItemRemoved(const QString& itemId) -{ - if (!m_mediaGrid) { - return; - } - - for (int i = m_loadedItems.size() - 1; i >= 0; --i) { - if (m_loadedItems.at(i).id == itemId) { - m_loadedItems.removeAt(i); - break; - } - } - - if (m_totalItemCount > 0) { - m_totalItemCount = qMax(m_loadedItems.size(), m_totalItemCount - 1); - } - m_hasMoreItems = m_loadedItems.size() < m_totalItemCount; - m_mediaGrid->removeItem(itemId); - updateStatsLabel(); -} - -QCoro::Task> LibraryView::enrichPlaylistItemsForRemoval(QList items) -{ - if (m_currentMode != LibraryMode || m_currentMediaItem.type != "Playlist" || - m_currentLibraryId.trimmed().isEmpty()) { - co_return items; - } - - for (MediaItem& item : items) { - item.playlistId = m_currentLibraryId; - } - - bool hasMissingPlaylistEntryIds = false; - for (const MediaItem& item : items) { - if (item.playlistItemId.trimmed().isEmpty()) { - hasMissingPlaylistEntryIds = true; - break; - } - } - - if (!hasMissingPlaylistEntryIds || !m_core || !m_core->adminService()) { - co_return items; - } - - QPointer guard(this); - - try - { - const QJsonObject response = - co_await m_core->adminService()->getPlaylistItems(m_currentLibraryId); - if (!guard) { - co_return items; - } - - QHash playlistEntryIdsByItemId; - const QJsonArray playlistItems = response.value("Items").toArray(); - for (const QJsonValue& value : playlistItems) { - const QJsonObject obj = value.toObject(); - const QString itemId = obj.value("Id").toString().trimmed(); - const QString playlistItemId = - obj.value("PlaylistItemId").toString().trimmed(); - if (itemId.isEmpty() || playlistItemId.isEmpty()) { - continue; - } - - playlistEntryIdsByItemId[itemId].append(playlistItemId); - } - - int enrichedCount = 0; - for (MediaItem& item : items) { - if (!item.playlistItemId.trimmed().isEmpty()) { - continue; - } - - QStringList entryIds = playlistEntryIdsByItemId.value(item.id); - if (entryIds.isEmpty()) { - continue; - } - - item.playlistItemId = entryIds.takeFirst(); - playlistEntryIdsByItemId.insert(item.id, entryIds); - ++enrichedCount; - } - - qDebug() << "[LibraryView] Playlist entry ids enriched" - << "| playlistId=" << m_currentLibraryId - << "| itemCount=" << items.size() - << "| enrichedCount=" << enrichedCount; - } - catch (const std::exception& e) - { - if (!guard) { - co_return items; - } - - qWarning() << "[LibraryView] Failed to enrich playlist entry ids" - << "| playlistId=" << m_currentLibraryId - << "| error=" << e.what(); - } - - co_return items; -} - - - - -QString LibraryView::currentPreferenceTargetId() const -{ - if (m_currentMode == PersonMode) - { - return m_currentPersonId; - } - - if (m_currentMode == LibraryMode) - { - return m_currentLibraryId; - } - - return QString(); -} - -void LibraryView::applyViewMode(bool isTile) -{ - m_viewSwitchBtn->blockSignals(true); - m_viewSwitchBtn->setChecked(isTile); - m_viewSwitchBtn->blockSignals(false); - m_mediaGrid->setCardStyle(isTile ? MediaCardDelegate::LibraryTile : MediaCardDelegate::Poster); -} - -void LibraryView::saveSortPreference() -{ - QString sid = m_core->serverManager()->activeProfile().id; - QString targetId = currentPreferenceTargetId(); - if (sid.isEmpty() || targetId.isEmpty()) - return; - - auto *store = ConfigStore::instance(); - store->set(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibrarySortIndex), m_sortButton->currentIndex()); - store->set(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibrarySortDescending), m_sortButton->isDescending()); - qDebug() << "[LibraryView] Sort preference saved:" - << "server=" << sid << "target=" << targetId << "index=" << m_sortButton->currentIndex() - << "desc=" << m_sortButton->isDescending(); -} - -void LibraryView::restoreSortPreference() -{ - QString sid = m_core->serverManager()->activeProfile().id; - QString targetId = currentPreferenceTargetId(); - if (sid.isEmpty() || targetId.isEmpty()) - return; - - auto *store = ConfigStore::instance(); - int savedIndex = store->get(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibrarySortIndex), 0); - bool savedDesc = store->get(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibrarySortDescending), true); - - m_sortButton->blockSignals(true); - m_sortButton->setCurrentIndex(savedIndex); - m_sortButton->setDescending(savedDesc); - m_sortButton->blockSignals(false); - - qDebug() << "[LibraryView] Sort preference restored:" - << "server=" << sid << "target=" << targetId << "index=" << savedIndex << "desc=" << savedDesc; -} - -void LibraryView::saveViewPreference() -{ - QString sid = m_core->serverManager()->activeProfile().id; - QString targetId = currentPreferenceTargetId(); - if (sid.isEmpty() || targetId.isEmpty()) - return; - - const QString viewMode = - m_viewSwitchBtn->isChecked() ? QStringLiteral("tile") : QStringLiteral("poster"); - - auto *store = ConfigStore::instance(); - store->set(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibraryViewMode), viewMode); - - qDebug() << "[LibraryView] View preference saved:" - << "server=" << sid << "target=" << targetId << "mode=" << viewMode; -} - -void LibraryView::restoreViewPreference() -{ - auto *store = ConfigStore::instance(); - const QString defaultViewMode = - store->get(ConfigKeys::DefaultLibraryView, QStringLiteral("poster")); - - QString sid = m_core->serverManager()->activeProfile().id; - QString targetId = currentPreferenceTargetId(); - QString viewMode = defaultViewMode; - - if (!sid.isEmpty() && !targetId.isEmpty()) - { - viewMode = - store->get(ConfigKeys::forLibrary(sid, targetId, ConfigKeys::LibraryViewMode), - defaultViewMode); - qDebug() << "[LibraryView] View preference restored:" - << "server=" << sid << "target=" << targetId << "mode=" << viewMode - << "default=" << defaultViewMode; - } - else - { - qDebug() << "[LibraryView] View preference fallback to default:" - << "mode=" << defaultViewMode; - } - - applyViewMode(viewMode == QLatin1String("tile")); +void LibraryView::onMediaItemRemoved(const QString &itemId) { + m_viewModel->removeItem(itemId); } diff --git a/src/qEmbyApp/views/media/libraryview.h b/src/qEmbyApp/views/media/libraryview.h index 80ca890..89a0cf3 100644 --- a/src/qEmbyApp/views/media/libraryview.h +++ b/src/qEmbyApp/views/media/libraryview.h @@ -2,103 +2,37 @@ #define LIBRARYVIEW_H #include "../baseview.h" -#include -#include -#include + #include -class QPushButton; -class QButtonGroup; -class MediaGridWidget; -class ModernSortButton; -class ElidedLabel; -class QLabel; +class LibraryViewModel; +class QuickPageHost; + +class LibraryView final : public BaseView { + Q_OBJECT -class LibraryView : public BaseView -{ - Q_OBJECT public: - enum ViewMode { - LibraryMode, - PersonMode, - FilteredMode - }; + explicit LibraryView(QEmbyCore *core, QWidget *parent = nullptr); - explicit LibraryView(QEmbyCore* core, QWidget *parent = nullptr); - - - QCoro::Task loadLibrary(const QString& libraryId, const QString& libraryName); - QCoro::Task loadPerson(const QString& personId, const QString& personName); - QCoro::Task loadFiltered(const QString& filterType, const QString& filterValue); + QCoro::Task loadLibrary(const QString &libraryId, + const QString &libraryName); + QCoro::Task loadPerson(const QString &personId, + const QString &personName); + QCoro::Task loadFiltered(const QString &filterType, + const QString &filterValue); + + void prepareForStackLeave() override; + +public slots: + void scrollToTop() override; protected: - - void onMediaItemUpdated(const MediaItem& item) override; - void onMediaItemRemoved(const QString& itemId) override; - -private slots: - - QCoro::Task onFilterChanged(); - QCoro::Task onLoadMoreRequested(); + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; private: - struct QueryState { - ViewMode mode = LibraryMode; - QString targetId; - QString sortBy; - QString sortOrder; - QString filters; - QString includeTypes; - QString genreFilter; - QString tagFilter; - QString studioFilter; - bool recursive = false; - bool includeChildCount = false; - bool needsPlaylistEnrichment = false; - }; - - void setupTopBar(class QHBoxLayout* headerLayout); - - void updateFavBtnState(); - QCoro::Task> enrichPlaylistItemsForRemoval(QList items); - QueryState buildCurrentQueryState(const QString& sortBy, const QString& sortOrder) const; - bool isQueryValid(const QueryState& query) const; - void resetPaginationState(); - void updateStatsLabel(); - QCoro::Task loadInitialItems(); - QString currentPreferenceTargetId() const; - void applyViewMode(bool isTile); - - void saveSortPreference(); - void restoreSortPreference(); - void saveViewPreference(); - void restoreViewPreference(); - - ViewMode m_currentMode; - QString m_currentLibraryId; - QString m_currentPersonId; - QString m_filterType; - QString m_filterValue; - MediaItem m_currentMediaItem; - bool m_isFavorite = false; - - ElidedLabel* m_titleLabel; - QPushButton* m_favBtn; - - QWidget* m_tabBarWidget; - QButtonGroup* m_tabGroup; - ModernSortButton* m_sortButton; - QPushButton* m_viewSwitchBtn; - QLabel* m_statsLabel; - - MediaGridWidget* m_mediaGrid; - QueryState m_activeQuery; - QList m_loadedItems; - int m_totalItemCount = 0; - int m_pageSize = 0; - int m_requestGeneration = 0; - bool m_hasMoreItems = false; - bool m_isLoadingMore = false; + QuickPageHost *m_quickHost = nullptr; + LibraryViewModel *m_viewModel = nullptr; }; -#endif +#endif // LIBRARYVIEW_H diff --git a/src/qEmbyApp/views/media/libraryviewmodel.cpp b/src/qEmbyApp/views/media/libraryviewmodel.cpp new file mode 100644 index 0000000..d56e91e --- /dev/null +++ b/src/qEmbyApp/views/media/libraryviewmodel.cpp @@ -0,0 +1,692 @@ +#include "libraryviewmodel.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr int kPageSize = 100; + +QString imageSource(const MediaItem &item, bool thumb) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + const QString tag = best.first; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + if (item.id.isEmpty()) + return {}; + + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(tag) && !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, tag); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) + parts.append(item.seriesName); + if (item.productionYear > 0) + parts.append(QString::number(item.productionYear)); + if (item.type == QLatin1String("Season") && item.parentIndexNumber >= 0) + parts.append(QObject::tr("Season %1").arg(item.parentIndexNumber)); + if (parts.isEmpty()) + parts.append(item.type); + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.mediaType == QLatin1String("Video") || + item.mediaType == QLatin1String("Audio") || + item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("Audio") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series"); +} + +} // namespace + +LibraryMediaModel::LibraryMediaModel(QObject *parent) + : QAbstractListModel(parent) {} + +int LibraryMediaModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant LibraryMediaModel::data(const QModelIndex &index, int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return subtitleFor(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item, false); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return isPlayable(*item); + case PlayedRole: + return item->userData.played; + case ProgressRole: + return item->userData.playedPercentage / 100.0; + default: + return {}; + } +} + +QHash LibraryMediaModel::roleNames() const { + return {{ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + {PlayedRole, "played"}, + {ProgressRole, "progress"}}; +} + +const MediaItem *LibraryMediaModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +void LibraryMediaModel::clear() { setItems({}); } + +void LibraryMediaModel::setItems(QList items) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); +} + +void LibraryMediaModel::appendUnique(const QList &items) { + QSet ids; + for (const MediaItem &item : std::as_const(m_items)) + ids.insert(item.id); + + QList additions; + for (const MediaItem &item : items) { + if (!item.id.isEmpty() && ids.contains(item.id)) + continue; + ids.insert(item.id); + additions.append(item); + } + if (additions.isEmpty()) + return; + + const int first = m_items.size(); + beginInsertRows({}, first, first + additions.size() - 1); + m_items.append(additions); + endInsertRows(); +} + +void LibraryMediaModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + MediaItem merged = item; + if (merged.playlistId.trimmed().isEmpty()) + merged.playlistId = m_items.at(row).playlistId; + if (merged.playlistItemId.trimmed().isEmpty()) + merged.playlistItemId = m_items.at(row).playlistItemId; + m_items[row] = std::move(merged); + emit dataChanged(index(row), index(row)); + return; + } +} + +bool LibraryMediaModel::removeItem(const QString &itemId) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != itemId) + continue; + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + return true; + } + return false; +} + +LibraryViewModel::LibraryViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_items(this), m_statsText(tr("0 Items")), + m_firstTabText(tr("All")) {} + +void LibraryViewModel::setTabIndex(int index) { + if (index < 0 || index > 5 || m_tabIndex == index) + return; + m_tabIndex = index; + emit controlsChanged(); + launchReload(); +} + +void LibraryViewModel::setSortIndex(int index) { + if (index < 0 || index > 4 || m_sortIndex == index) + return; + m_sortIndex = index; + saveSortPreference(); + emit controlsChanged(); + launchReload(); +} + +void LibraryViewModel::setDescending(bool descending) { + if (m_descending == descending) + return; + m_descending = descending; + saveSortPreference(); + emit controlsChanged(); + launchReload(); +} + +void LibraryViewModel::setViewStyle(int style) { + if (style < PosterView || style > ListView || m_viewStyle == style) + return; + m_viewStyle = style; + saveViewPreference(); + emit controlsChanged(); +} + +void LibraryViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_generation; + if (m_loading || m_loadingMore) + m_reloadOnActivate = true; + m_loading = false; + m_loadingMore = false; + emit stateChanged(); + } else if (m_reloadOnActivate) { + m_reloadOnActivate = false; + launchReload(); + } +} + +QCoro::Task LibraryViewModel::loadLibrary(QString libraryId, + QString libraryName) { + const quint64 generation = ++m_generation; + m_mode = LibraryMode; + m_targetId = std::move(libraryId); + m_title = std::move(libraryName); + m_filterType.clear(); + m_filterValue.clear(); + m_currentItem = {}; + m_tabIndex = 0; + m_firstTabText = tr("All"); + 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) + 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) { + m_currentItem = item; + if (item.collectionType == QLatin1String("movies")) + m_firstTabText = tr("Movies"); + else if (item.collectionType == QLatin1String("tvshows")) + m_firstTabText = tr("Shows"); + else if (item.collectionType == QLatin1String("music")) + m_firstTabText = tr("Music"); + else if (item.collectionType == QLatin1String("homevideos") || + item.collectionType == QLatin1String("photos")) + m_firstTabText = tr("Videos"); + else + m_firstTabText = tr("All"); + + const bool folder = item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder"); + const bool collection = item.collectionType == QLatin1String("playlists") || + item.collectionType == QLatin1String("boxsets"); + m_favoriteVisible = folder; + m_tabsVisible = !folder && !collection; + emit controlsChanged(); +} + +QString LibraryViewModel::preferenceTargetId() const { + return m_mode == FilteredMode ? QString() : m_targetId; +} + +QString LibraryViewModel::sortBy() const { + static const QStringList values = { + QStringLiteral("DateCreated"), QStringLiteral("DatePlayed"), + QStringLiteral("SortName"), QStringLiteral("Runtime"), + QStringLiteral("PremiereDate")}; + return values.value(m_sortIndex, QStringLiteral("SortName")); +} + +LibraryViewModel::QueryState LibraryViewModel::buildQuery() const { + QueryState query; + query.mode = m_mode; + query.sortBy = sortBy(); + query.sortOrder = + m_descending ? QStringLiteral("Descending") : QStringLiteral("Ascending"); + + if (m_mode == PersonMode) { + query.targetId = m_targetId; + return query; + } + if (m_mode == FilteredMode) { + if (m_filterType == QLatin1String("Genre")) + query.genreFilter = m_filterValue; + else if (m_filterType == QLatin1String("Tag")) + query.tagFilter = m_filterValue; + else if (m_filterType == QLatin1String("Studio")) + query.studioFilter = m_filterValue; + return query; + } + + query.targetId = m_targetId; + query.includeTypes = QStringLiteral("Movie,Series,Audio,Video"); + query.recursive = true; + query.playlist = m_currentItem.type == QLatin1String("Playlist"); + + if (m_currentItem.type == QLatin1String("Folder")) { + query.includeTypes.clear(); + query.sortBy = QStringLiteral("IsFolder,SortName"); + query.sortOrder = QStringLiteral("Ascending"); + query.recursive = false; + return query; + } + if (m_currentItem.type == QLatin1String("BoxSet") || query.playlist || + m_currentItem.collectionType == QLatin1String("playlists") || + m_currentItem.collectionType == QLatin1String("boxsets")) { + query.includeTypes.clear(); + query.recursive = false; + return query; + } + + if (m_tabIndex == 1) { + query.sortBy = QStringLiteral("DateCreated"); + query.sortOrder = QStringLiteral("Descending"); + } else if (m_tabIndex == 2) { + query.includeTypes = QStringLiteral("Playlist"); + } else if (m_tabIndex == 3) { + query.includeTypes = QStringLiteral("BoxSet"); + } else if (m_tabIndex == 4) { + query.filters = QStringLiteral("IsFavorite"); + query.includeTypes = + QStringLiteral("Movie,Series,Audio,Video,BoxSet,Playlist"); + } else if (m_tabIndex == 5) { + query.includeTypes.clear(); + query.sortBy = QStringLiteral("IsFolder,SortName"); + query.sortOrder = QStringLiteral("Ascending"); + query.recursive = false; + } + return query; +} + +bool LibraryViewModel::queryValid(const QueryState &query) const { + if (query.mode == FilteredMode) + return !query.genreFilter.trimmed().isEmpty() || + !query.tagFilter.trimmed().isEmpty() || + !query.studioFilter.trimmed().isEmpty(); + return !query.targetId.trimmed().isEmpty(); +} + +void LibraryViewModel::restorePreferences() { + auto *store = ConfigStore::instance(); + const QString fallback = store->get(ConfigKeys::DefaultLibraryView, + QStringLiteral("poster")); + QString mode = fallback; + const QString target = preferenceTargetId(); + QString serverId; + if (m_core && m_core->serverManager()) + serverId = m_core->serverManager()->activeProfile().id; + if (!serverId.isEmpty() && !target.isEmpty()) { + m_sortIndex = store->get( + ConfigKeys::forLibrary(serverId, target, ConfigKeys::LibrarySortIndex), + 0); + m_descending = store->get( + ConfigKeys::forLibrary(serverId, target, + ConfigKeys::LibrarySortDescending), + true); + mode = store->get( + ConfigKeys::forLibrary(serverId, target, ConfigKeys::LibraryViewMode), + fallback); + } + m_viewStyle = mode == QLatin1String("tile") ? TileView + : mode == QLatin1String("list") ? ListView + : PosterView; +} + +void LibraryViewModel::saveSortPreference() { + if (!m_core || !m_core->serverManager()) + return; + const QString serverId = m_core->serverManager()->activeProfile().id; + const QString target = preferenceTargetId(); + if (serverId.isEmpty() || target.isEmpty()) + return; + auto *store = ConfigStore::instance(); + store->set( + ConfigKeys::forLibrary(serverId, target, ConfigKeys::LibrarySortIndex), + m_sortIndex); + store->set(ConfigKeys::forLibrary(serverId, target, + ConfigKeys::LibrarySortDescending), + m_descending); +} + +void LibraryViewModel::saveViewPreference() { + if (!m_core || !m_core->serverManager()) + return; + const QString serverId = m_core->serverManager()->activeProfile().id; + const QString target = preferenceTargetId(); + if (serverId.isEmpty() || target.isEmpty()) + return; + const QString mode = m_viewStyle == TileView ? QStringLiteral("tile") + : m_viewStyle == ListView ? QStringLiteral("list") + : QStringLiteral("poster"); + ConfigStore::instance()->set( + ConfigKeys::forLibrary(serverId, target, ConfigKeys::LibraryViewMode), + mode); +} + +void LibraryViewModel::updateStats() { + const int loaded = m_items.count(); + const int total = qMax(m_totalCount, loaded); + m_statsText = total > loaded ? tr("%1 / %2 Items").arg(loaded).arg(total) + : tr("%1 Items").arg(total); + emit stateChanged(); +} + +void LibraryViewModel::launchReload() { + if (!m_active) { + m_reloadOnActivate = true; + return; + } + QCoro::connect(reload(), this, []() {}); +} + +QCoro::Task LibraryViewModel::reload() { + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + const QueryState query = buildQuery(); + const quint64 generation = ++m_generation; + m_query = query; + m_loading = true; + m_loadingMore = false; + m_hasMore = false; + m_totalCount = 0; + m_errorText.clear(); + m_statsText = tr("Loading..."); + m_items.clear(); + emit stateChanged(); + + if (!m_core || !m_core->mediaService() || !queryValid(query)) { + if (generation == m_generation) { + m_loading = false; + updateStats(); + } + co_return; + } + co_await fetchNextPage(generation, query, true); +} + +QCoro::Task LibraryViewModel::fetchNextPage(quint64 generation, + QueryState query, + bool initial) { + if (!m_active || generation != m_generation || m_loadingMore) + co_return; + const int startIndex = initial ? 0 : m_items.count(); + m_loadingMore = true; + emit stateChanged(); + + QPointer guard(this); + try { + MediaQueryPage page; + auto *service = m_core->mediaService(); + if (query.mode == PersonMode) { + page = co_await service->getItemsByPersonPage( + query.targetId, query.sortBy, query.sortOrder, startIndex, + m_pageSize); + } else if (query.mode == FilteredMode) { + page = co_await service->getItemsByFilterPage( + query.genreFilter, query.tagFilter, query.studioFilter, query.sortBy, + query.sortOrder, startIndex, m_pageSize); + } else { + page = co_await service->getLibraryItemsPage( + query.targetId, query.sortBy, query.sortOrder, query.filters, + query.includeTypes, startIndex, m_pageSize, query.recursive, + query.includeChildCount); + } + if (!guard || !m_active || generation != m_generation) + co_return; + + QList items = std::move(page.items); + if (query.playlist && !items.isEmpty()) { + items = co_await enrichPlaylistItems(std::move(items), query.targetId, + generation); + if (!guard || !m_active || generation != m_generation) + co_return; + } + + if (initial) + m_items.setItems(std::move(items)); + else + m_items.appendUnique(items); + m_totalCount = qMax(page.totalRecordCount, m_items.count()); + const int received = initial ? m_items.count() : items.size(); + m_hasMore = received > 0 && m_items.count() < m_totalCount; + m_loading = false; + m_loadingMore = false; + updateStats(); + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + qWarning() << "[LibraryViewModel] page load failed:" << error.what(); + m_loading = false; + m_loadingMore = false; + m_errorText = tr("Unable to load library items"); + m_statsText = tr("Error Loading Items"); + emit stateChanged(); + } +} + +QCoro::Task> LibraryViewModel::enrichPlaylistItems( + QList items, const QString &playlistId, quint64 generation) { + for (MediaItem &item : items) + item.playlistId = playlistId; + if (!m_core || !m_core->adminService()) + co_return items; + + bool missing = false; + for (const MediaItem &item : std::as_const(items)) + missing = missing || item.playlistItemId.trimmed().isEmpty(); + if (!missing) + co_return items; + + QPointer guard(this); + try { + const QJsonObject response = + co_await m_core->adminService()->getPlaylistItems(playlistId); + if (!guard || !m_active || generation != m_generation) + co_return items; + + QHash entryIds; + for (const QJsonValue &value : response.value("Items").toArray()) { + const QJsonObject object = value.toObject(); + const QString itemId = object.value("Id").toString().trimmed(); + const QString entryId = + object.value("PlaylistItemId").toString().trimmed(); + if (!itemId.isEmpty() && !entryId.isEmpty()) + entryIds[itemId].append(entryId); + } + for (MediaItem &item : items) { + if (!item.playlistItemId.trimmed().isEmpty() || + entryIds.value(item.id).isEmpty()) + continue; + QStringList ids = entryIds.value(item.id); + item.playlistItemId = ids.takeFirst(); + entryIds.insert(item.id, ids); + } + } catch (const std::exception &error) { + if (guard && generation == m_generation) + qWarning() << "[LibraryViewModel] playlist enrichment failed:" + << error.what(); + } + co_return items; +} + +void LibraryViewModel::updateItem(const MediaItem &item) { + if (item.id == m_targetId) { + m_currentItem = item; + emit controlsChanged(); + } + m_items.updateItem(item); +} + +void LibraryViewModel::removeItem(const QString &itemId) { + if (!m_items.removeItem(itemId)) + return; + if (m_totalCount > 0) + --m_totalCount; + m_hasMore = m_items.count() < m_totalCount; + updateStats(); +} + +void LibraryViewModel::activate(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit itemActivated(*item); +} + +void LibraryViewModel::requestPlay(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit playRequested(*item); +} + +void LibraryViewModel::requestFavorite(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit favoriteRequested(*item); +} + +void LibraryViewModel::requestCurrentFavorite() { + if (!m_currentItem.id.isEmpty()) + emit currentFavoriteRequested(m_currentItem); +} + +void LibraryViewModel::requestMore(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit moreRequested(*item); +} + +void LibraryViewModel::retry() { launchReload(); } + +void LibraryViewModel::refresh() { launchReload(); } + +void LibraryViewModel::loadMore() { + if (!m_active || m_loading || m_loadingMore || !m_hasMore) + return; + QCoro::connect(fetchNextPage(m_generation, m_query, false), this, []() {}); +} diff --git a/src/qEmbyApp/views/media/libraryviewmodel.h b/src/qEmbyApp/views/media/libraryviewmodel.h new file mode 100644 index 0000000..c0e122b --- /dev/null +++ b/src/qEmbyApp/views/media/libraryviewmodel.h @@ -0,0 +1,185 @@ +#ifndef LIBRARYVIEWMODEL_H +#define LIBRARYVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class LibraryMediaModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + PlayedRole, + ProgressRole, + }; + + explicit LibraryMediaModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + int count() const { return m_items.size(); } + void clear(); + void setItems(QList items); + void appendUnique(const QList &items); + void updateItem(const MediaItem &item); + bool removeItem(const QString &itemId); + +private: + QList m_items; +}; + +class LibraryViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY stateChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(QString firstTabText READ firstTabText NOTIFY controlsChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool loadingMore READ loadingMore NOTIFY stateChanged) + Q_PROPERTY(bool hasMore READ hasMore NOTIFY stateChanged) + Q_PROPERTY(bool tabsVisible READ tabsVisible NOTIFY controlsChanged) + Q_PROPERTY(bool favoriteVisible READ favoriteVisible NOTIFY controlsChanged) + Q_PROPERTY(bool favorite READ favorite NOTIFY controlsChanged) + Q_PROPERTY( + int tabIndex READ tabIndex WRITE setTabIndex NOTIFY controlsChanged) + Q_PROPERTY( + int sortIndex READ sortIndex WRITE setSortIndex NOTIFY controlsChanged) + Q_PROPERTY(bool descending READ descending WRITE setDescending NOTIFY + controlsChanged) + Q_PROPERTY( + int viewStyle READ viewStyle WRITE setViewStyle NOTIFY controlsChanged) + Q_PROPERTY(QAbstractItemModel *itemsModel READ itemsModel CONSTANT) + +public: + enum Mode { LibraryMode, PersonMode, FilteredMode }; + Q_ENUM(Mode) + enum ViewStyle { PosterView, TileView, ListView }; + Q_ENUM(ViewStyle) + + explicit LibraryViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QString title() const { return m_title; } + QString statsText() const { return m_statsText; } + QString errorText() const { return m_errorText; } + QString firstTabText() const { return m_firstTabText; } + bool loading() const { return m_loading; } + bool loadingMore() const { return m_loadingMore; } + bool hasMore() const { return m_hasMore; } + bool tabsVisible() const { return m_tabsVisible; } + bool favoriteVisible() const { return m_favoriteVisible; } + bool favorite() const { return m_currentItem.isFavorite(); } + int tabIndex() const { return m_tabIndex; } + int sortIndex() const { return m_sortIndex; } + bool descending() const { return m_descending; } + int viewStyle() const { return m_viewStyle; } + QAbstractItemModel *itemsModel() { return &m_items; } + + void setTabIndex(int index); + void setSortIndex(int index); + void setDescending(bool descending); + void setViewStyle(int style); + void setActive(bool active); + + QCoro::Task loadLibrary(QString libraryId, QString libraryName); + QCoro::Task loadPerson(QString personId, QString personName); + QCoro::Task loadFiltered(QString filterType, QString filterValue); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + + Q_INVOKABLE void activate(int row); + Q_INVOKABLE void requestPlay(int row); + Q_INVOKABLE void requestFavorite(int row); + Q_INVOKABLE void requestCurrentFavorite(); + Q_INVOKABLE void requestMore(int row); + Q_INVOKABLE void retry(); + Q_INVOKABLE void refresh(); + Q_INVOKABLE void loadMore(); + +signals: + void stateChanged(); + void controlsChanged(); + void itemActivated(const MediaItem &item); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void currentFavoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item); + +private: + struct QueryState { + Mode mode = LibraryMode; + QString targetId; + QString sortBy; + QString sortOrder; + QString filters; + QString includeTypes; + QString genreFilter; + QString tagFilter; + QString studioFilter; + bool recursive = false; + bool includeChildCount = false; + bool playlist = false; + }; + + QString preferenceTargetId() const; + QString sortBy() const; + QueryState buildQuery() const; + bool queryValid(const QueryState &query) const; + void configureCurrentItem(const MediaItem &item); + void restorePreferences(); + void saveSortPreference(); + void saveViewPreference(); + void updateStats(); + void launchReload(); + QCoro::Task reload(); + QCoro::Task fetchNextPage(quint64 generation, QueryState query, + bool initial); + QCoro::Task> enrichPlaylistItems(QList items, + const QString &playlistId, + quint64 generation); + + QPointer m_core; + LibraryMediaModel m_items; + Mode m_mode = LibraryMode; + QString m_title; + QString m_targetId; + QString m_filterType; + QString m_filterValue; + QString m_statsText; + QString m_errorText; + QString m_firstTabText; + MediaItem m_currentItem; + QueryState m_query; + bool m_loading = false; + bool m_loadingMore = false; + bool m_hasMore = false; + bool m_tabsVisible = false; + bool m_favoriteVisible = false; + bool m_active = true; + bool m_reloadOnActivate = false; + int m_tabIndex = 0; + int m_sortIndex = 0; + bool m_descending = true; + int m_viewStyle = PosterView; + int m_totalCount = 0; + int m_pageSize = 100; + quint64 m_generation = 0; +}; + +#endif // LIBRARYVIEWMODEL_H diff --git a/src/qEmbyApp/views/media/seasonview.cpp b/src/qEmbyApp/views/media/seasonview.cpp index c6daca4..6e97423 100644 --- a/src/qEmbyApp/views/media/seasonview.cpp +++ b/src/qEmbyApp/views/media/seasonview.cpp @@ -1,732 +1,72 @@ #include "seasonview.h" -#include "../../components/detailcontentwidget.h" -#include "../../components/mediagridwidget.h" -#include "../../components/modernsortbutton.h" -#include "../../components/splitplayerbutton.h" -#include "../../managers/externalplayerdetector.h" -#include "../../managers/playbackmanager.h" -#include "../../managers/thememanager.h" -#include "../../utils/mediaitemutils.h" -#include "../../utils/mediasourcepreferenceutils.h" -#include "../../utils/playerpreferenceutils.h" -#include "mediacarddelegate.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "seasonviewmodel.h" + +#include +#include +#include +#include #include -#include -#include -#include -#include -SeasonView::SeasonView(QEmbyCore *core, QWidget *parent) : BaseView(core, parent) -{ - setAttribute(Qt::WA_StyledBackground, true); - setObjectName("season-view"); - setProperty("showGlobalSearch", true); +SeasonView::SeasonView(QEmbyCore *core, QWidget *parent) + : BaseView(core, parent) { + setAttribute(Qt::WA_StyledBackground, true); + setObjectName(QStringLiteral("season-view")); + setProperty("showGlobalSearch", true); - setupUi(); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_quickHost = new QuickPageHost(this); + m_viewModel = new SeasonViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("seasonPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SeasonPage.qml"))); + layout->addWidget(m_quickHost); + + connect(m_quickHost, &QuickPageHost::pageShown, this, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, this, + [this]() { m_viewModel->setActive(false); }); + + connect(m_viewModel, &SeasonViewModel::episodeActivated, this, + [this](const MediaItem &item) { + emit navigateToDetail(item.id, item.name, item); + }); + connect(m_viewModel, &SeasonViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_viewModel, &SeasonViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &SeasonViewModel::moreRequested, this, + [this](const MediaItem &item) { + handleMoreMenuRequested(item, QCursor::pos()); + }); } -void SeasonView::setupUi() -{ - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - m_contentWidget = new DetailContentWidget(this); - m_contentWidget->setObjectName("season-content-container"); - - auto *contentLayout = new QVBoxLayout(m_contentWidget); - contentLayout->setContentsMargins(0, 40, 0, 0); - contentLayout->setSpacing(0); - - - - - auto *headerLayout = new QHBoxLayout(); - headerLayout->setSpacing(30); - headerLayout->setContentsMargins(40, 0, 40, 0); - headerLayout->setAlignment(Qt::AlignLeft | Qt::AlignTop); - - - m_posterLabel = new QLabel(m_contentWidget); - m_posterLabel->setFixedSize(250, 375); - m_posterLabel->setObjectName("detail-poster-label"); - m_posterLabel->setAlignment(Qt::AlignCenter); - - auto *shadow = new QGraphicsDropShadowEffect(this); - shadow->setBlurRadius(20); - shadow->setColor(QColor(0, 0, 0, 80)); - shadow->setOffset(0, 8); - m_posterLabel->setGraphicsEffect(shadow); - - - auto *textLayout = new QVBoxLayout(); - textLayout->setSpacing(10); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setAlignment(Qt::AlignTop); - - m_seriesTitleLabel = new QLabel(m_contentWidget); - m_seriesTitleLabel->setObjectName("detail-title"); - m_seriesTitleLabel->setWordWrap(true); - - m_titleLabel = new QLabel(m_contentWidget); - m_titleLabel->setObjectName("detail-series-title"); - m_titleLabel->setWordWrap(true); - - m_overviewLabel = new QLabel(m_contentWidget); - m_overviewLabel->setObjectName("detail-overview"); - m_overviewLabel->setWordWrap(true); - m_overviewLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); - m_overviewLabel->setContentsMargins(0, 5, 0, 15); - - auto *actionsLayout = new QHBoxLayout(); - actionsLayout->setSpacing(12); - actionsLayout->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - - m_playBtn = new QPushButton(tr("▶ Play"), m_contentWidget); - m_playBtn->setObjectName("detail-play-btn"); - m_playBtn->setCursor(Qt::PointingHandCursor); - m_playBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - m_favBtn = new QPushButton(m_contentWidget); - m_favBtn->setObjectName("detail-fav-btn"); - m_favBtn->setIconSize(QSize(20, 20)); - m_favBtn->setFixedSize(36, 36); - m_favBtn->setCursor(Qt::PointingHandCursor); - - - m_extPlayerBtn = new SplitPlayerButton(m_contentWidget); - m_extPlayerBtn->setObjectName("detail-ext-player-btn"); - m_extPlayerBtn->setIconOnly(true); - m_extPlayerBtn->hide(); - - actionsLayout->addWidget(m_playBtn); - actionsLayout->addWidget(m_extPlayerBtn); - actionsLayout->addWidget(m_favBtn); - - textLayout->addWidget(m_seriesTitleLabel); - textLayout->addWidget(m_titleLabel); - textLayout->addWidget(m_overviewLabel); - textLayout->addLayout(actionsLayout); - - headerLayout->addWidget(m_posterLabel, 0, Qt::AlignTop); - headerLayout->addLayout(textLayout, 1); - - - - - auto *filterLayout = new QHBoxLayout(); - filterLayout->setContentsMargins(40, 0, 40, 0); - - m_sortButton = new ModernSortButton(this); - m_sortButton->setSortItems({tr("Episode Number"), tr("Title"), tr("Premiere Date"), tr("Runtime")}); - m_statsLabel = new QLabel(tr("0 Episodes"), this); - m_statsLabel->setObjectName("library-stats-label"); - - filterLayout->addStretch(); - filterLayout->addWidget(m_statsLabel); - filterLayout->addSpacing(15); - filterLayout->addWidget(m_sortButton); - - - - - m_mediaGrid = new MediaGridWidget(m_core, m_contentWidget); - m_mediaGrid->setCardStyle(MediaCardDelegate::EpisodeList); - m_mediaGrid->setBasePadding(40); - - contentLayout->addLayout(headerLayout); - contentLayout->addSpacing(0); - contentLayout->addLayout(filterLayout); - contentLayout->addWidget(m_mediaGrid, 1); - - mainLayout->addWidget(m_contentWidget); - - connect(m_playBtn, &QPushButton::clicked, this, - [this]() -> QCoro::Task - { - if (m_currentEpisodes.isEmpty()) - co_return; - - - if (!m_nextUpEpisodeId.isEmpty()) - { - for (const auto &ep : m_currentEpisodes) - { - if (ep.id == m_nextUpEpisodeId) - { - co_await executeInternalPlay(ep); - co_return; - } - } - } - - - co_await executeInternalPlay(m_currentEpisodes.first()); - }); - - - connect(m_extPlayerBtn, &SplitPlayerButton::playRequested, this, - [this](const QString &playerPath) -> QCoro::Task - { - if (m_currentEpisodes.isEmpty()) - co_return; - - MediaItem target; - if (!m_nextUpEpisodeId.isEmpty()) - { - for (const auto &ep : m_currentEpisodes) - { - if (ep.id == m_nextUpEpisodeId) - { - target = ep; - break; - } - } - } - if (target.id.isEmpty()) - target = m_currentEpisodes.first(); - co_await executeExternalPlay(target, playerPath); - }); - connect(m_extPlayerBtn, &SplitPlayerButton::playerSelected, this, - [this](const QString &playerPath) -> QCoro::Task - { - if (m_currentEpisodes.isEmpty()) - co_return; - MediaItem target; - if (!m_nextUpEpisodeId.isEmpty()) - { - for (const auto &ep : m_currentEpisodes) - { - if (ep.id == m_nextUpEpisodeId) - { - target = ep; - break; - } - } - } - if (target.id.isEmpty()) - target = m_currentEpisodes.first(); - co_await executeExternalPlay(target, playerPath); - }); - - connect(m_favBtn, &QPushButton::clicked, this, - [this]() - { - if (!m_currentSeasonId.isEmpty()) - { - handleFavoriteRequested(m_currentSeasonItem); - } - }); - - connect(m_sortButton, &ModernSortButton::sortChanged, this, [this]() { onFilterChanged(); }); - - connect(m_mediaGrid, &MediaGridWidget::itemClicked, this, - [this](const MediaItem &item) { Q_EMIT navigateToDetail(item.id, item.name, item); }); - connect(m_mediaGrid, &MediaGridWidget::playRequested, this, &BaseView::handlePlayRequested); - connect(m_mediaGrid, &MediaGridWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); - connect(m_mediaGrid, &MediaGridWidget::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); +QCoro::Task SeasonView::loadSeason(QString seriesId, QString seasonId, + QString seasonName) { + co_await m_viewModel->loadSeason(std::move(seriesId), std::move(seasonId), + std::move(seasonName)); } -void SeasonView::updateFavBtnState() -{ - m_favBtn->setProperty("isFavorite", m_isFavorite); +void SeasonView::prepareForStackLeave() { m_viewModel->setActive(false); } - - bool isDark = ThemeManager::instance()->isDarkMode(); - QString themeFolder = isDark ? "dark" : "light"; - QString iconState = m_isFavorite ? "fill" : "outline"; - - m_favBtn->setIcon(QIcon(QString(":/svg/%1/heart-%2.svg").arg(themeFolder).arg(iconState))); - - m_favBtn->style()->unpolish(m_favBtn); - m_favBtn->style()->polish(m_favBtn); +void SeasonView::scrollToTop() { + if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) + QMetaObject::invokeMethod(root, "scrollToTop"); } -QCoro::Task SeasonView::loadSeason(QString seriesId, QString seasonId, QString seasonName) -{ - QPointer guard(this); - m_currentSeriesId = seriesId; - m_currentSeasonId = seasonId; - m_currentSeasonItem = MediaItem(); - m_currentSeasonItem.id = seasonId; - - m_seriesTitleLabel->setText(tr("Loading...")); - m_titleLabel->setText(seasonName); - - - - m_overviewLabel->clear(); - - m_isFavorite = false; - updateFavBtnState(); - - m_contentWidget->setBackdrop(QPixmap()); - m_posterLabel->setPixmap(QPixmap()); - - m_sortButton->blockSignals(true); - m_sortButton->setSortItems({tr("Episode Number"), tr("Title"), tr("Premiere Date"), tr("Runtime")}); - m_sortButton->setDescending(false); - m_sortButton->blockSignals(false); - - - refreshExtPlayerButton(); - - QString fallbackBackdropTag; - - - if (!seriesId.isEmpty()) - { - try - { - MediaItem seriesItem = co_await m_core->mediaService()->getItemDetail(seriesId); - if (guard) - { - m_seriesTitleLabel->setText(seriesItem.name); - fallbackBackdropTag = seriesItem.images.backdropTag; - - - m_overviewLabel->setText(seriesItem.overview); - } - } - catch (...) - { - if (guard) - m_seriesTitleLabel->setText(tr("Unknown Series")); - } - } - - - if (!seasonId.isEmpty() && guard) - { - try - { - MediaItem detail = co_await m_core->mediaService()->getItemDetail(seasonId); - if (guard && detail.id == m_currentSeasonId) - { - m_currentSeasonItem = detail; - m_isFavorite = detail.isFavorite(); - updateFavBtnState(); - - - if (!detail.overview.isEmpty()) - { - m_overviewLabel->setText(detail.overview); - } - } - } - catch (...) - { - - } - } - - - if (!guard) - co_return; - - loadImages(fallbackBackdropTag); - co_await onFilterChanged(); +void SeasonView::onMediaItemUpdated(const MediaItem &item) { + m_viewModel->updateItem(item); } -QCoro::Task SeasonView::loadImages(const QString &fallbackSeriesBackdropTag) -{ - QPointer guard(this); - - - if (!guard || !m_core || !m_core->mediaService()) - co_return; - - bool adaptive = ConfigStore::instance()->get(ConfigKeys::AdaptiveImages, true); - - - - - { - QString posterType = "Primary"; - QString posterTag = m_currentSeasonItem.images.primaryTag; - QString posterId = m_currentSeasonId; - - - if (posterTag.isEmpty() && !m_currentSeriesId.isEmpty()) - { - posterId = m_currentSeriesId; - - if (adaptive) - { - auto best = m_currentSeasonItem.images.bestPoster(); - posterTag = best.first; - posterType = best.second; - posterId = m_currentSeasonItem.images.isParentTag(posterTag) ? m_currentSeriesId : m_currentSeasonId; - } - } - - if (!posterTag.isEmpty()) - { - try - { - int posterMaxWidth = (posterType == "Primary") ? 400 : 800; - QPixmap pix = - co_await m_core->mediaService()->fetchImage(posterId, posterType, posterTag, posterMaxWidth); - if (guard && !pix.isNull()) - { - const QSize posterSize(250, 375); - QPixmap scaled = pix.scaled(posterSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - int cropX = (scaled.width() - posterSize.width()) / 2; - int cropY = (scaled.height() - posterSize.height()) / 2; - QPixmap cropped = scaled.copy(cropX, cropY, posterSize.width(), posterSize.height()); - - - QPixmap rounded(posterSize); - rounded.fill(Qt::transparent); - QPainter p(&rounded); - p.setRenderHint(QPainter::Antialiasing); - QPainterPath path; - path.addRoundedRect(QRectF(0, 0, posterSize.width(), posterSize.height()), 12, 12); - p.setClipPath(path); - p.drawPixmap(0, 0, cropped); - p.end(); - m_posterLabel->setPixmap(rounded); - } - } - catch (...) - { - } - } - } - - if (!guard) - co_return; - - - - - { - QString tag = m_currentSeasonItem.images.backdropTag; - QString imageId = m_currentSeasonId; - - if (tag.isEmpty() && !fallbackSeriesBackdropTag.isEmpty()) - { - tag = fallbackSeriesBackdropTag; - imageId = m_currentSeriesId; - } - - - - QString imgType = "Backdrop"; - if (tag.isEmpty() && adaptive) - { - auto best = m_currentSeasonItem.images.bestBackdrop(); - tag = best.first; - imgType = best.second; - - imageId = m_currentSeasonItem.images.isParentTag(tag) ? m_currentSeriesId : m_currentSeasonId; - } - - if (!tag.isEmpty()) - { - try - { - QPixmap pix = co_await m_core->mediaService()->fetchImage(imageId, imgType, tag, 1920); - if (guard && !pix.isNull()) - { - m_contentWidget->setBackdrop(pix); - } - } - catch (...) - { - } - } - } -} - -QCoro::Task SeasonView::onFilterChanged(bool preserveScroll) -{ - QPointer guard(this); - - - if (!guard || !m_core || !m_core->mediaService()) - co_return; - - - int savedScrollPos = 0; - if (preserveScroll) - { - savedScrollPos = m_mediaGrid->saveScrollPosition(); - } - else - { - m_mediaGrid->setItems(QList()); - } - m_statsLabel->setText(tr("Loading...")); - - QString sortBy = "ParentIndexNumber,IndexNumber"; - switch (m_sortButton->currentIndex()) - { - case 0: - sortBy = "ParentIndexNumber,IndexNumber"; - break; - case 1: - sortBy = "SortName"; - break; - case 2: - sortBy = "PremiereDate"; - break; - case 3: - sortBy = "Runtime"; - break; - } - QString sortOrder = m_sortButton->isDescending() ? "Descending" : "Ascending"; - - try - { - auto episodes = - co_await m_core->mediaService()->getEpisodes(m_currentSeriesId, m_currentSeasonId, sortBy, sortOrder); - if (!guard) - co_return; - - m_currentEpisodes = episodes; - m_statsLabel->setText(tr("%1 Episodes").arg(episodes.size())); - m_mediaGrid->setItems(episodes); - - - if (preserveScroll) - { - m_mediaGrid->restoreScrollPosition(savedScrollPos); - } - - - if (!guard) - co_return; - - - co_await updatePlayButtonFromNextUp(); - } - catch (...) - { - if (!guard) - co_return; - m_statsLabel->setText(tr("Error Loading Episodes")); - } -} - -void SeasonView::showEvent(QShowEvent *event) -{ - BaseView::showEvent(event); - - - - - if (!m_currentSeasonId.isEmpty() && m_core && m_core->mediaService() && isVisible()) - { - onFilterChanged(true); - refreshExtPlayerButton(); - } -} - -void SeasonView::onMediaItemUpdated(const MediaItem &item) -{ - if (m_currentSeasonId == item.id) - { - m_currentSeasonItem = item; - m_isFavorite = item.isFavorite(); - updateFavBtnState(); - } - if (m_mediaGrid) - { - m_mediaGrid->updateItem(item); - } - - - for (auto &ep : m_currentEpisodes) - { - if (ep.id == item.id) - { - ep = item; - break; - } - } -} - -QCoro::Task SeasonView::updatePlayButtonFromNextUp() -{ - QPointer guard(this); - m_nextUpEpisodeId.clear(); - - - if (!guard || !m_core || !m_core->mediaService()) - co_return; - - try - { - auto nextUp = co_await m_core->mediaService()->getNextUp(m_currentSeriesId); - if (!guard) - co_return; - - if (!nextUp.isEmpty()) - { - MediaItem nextEp = nextUp.first(); - - for (const auto &ep : m_currentEpisodes) - { - if (ep.id == nextEp.id) - { - m_nextUpEpisodeId = nextEp.id; - m_playBtn->setText(tr("▶ Continue E%1").arg(ep.indexNumber)); - co_return; - } - } - } - } - catch (...) - { - } - - if (guard) - { - m_playBtn->setText(tr("▶ Play")); - } -} - - - - -void SeasonView::refreshExtPlayerButton() -{ - bool extEnabled = ConfigStore::instance()->get(ConfigKeys::ExtPlayerEnable, false); - if (!extEnabled) - { - m_extPlayerBtn->hide(); - return; - } - - QString currentPath = ConfigStore::instance()->get(ConfigKeys::ExtPlayerPath); - - - QList allPlayers = ExternalPlayerDetector::loadFromConfig(); - - - QSet knownPaths; - for (const auto &p : allPlayers) - knownPaths.insert(p.path); - if (!currentPath.isEmpty() && currentPath != "custom" && !knownPaths.contains(currentPath) && - QFileInfo::exists(currentPath)) - { - allPlayers.prepend({QFileInfo(currentPath).baseName(), currentPath}); - } - - if (allPlayers.isEmpty()) - { - m_extPlayerBtn->hide(); - return; - } - - - QString activePlayerPath = currentPath; - if (activePlayerPath.isEmpty() || activePlayerPath == "custom") - { - activePlayerPath = allPlayers.first().path; - } - m_extPlayerBtn->setPlayers(allPlayers, activePlayerPath); -} - - - - -QCoro::Task SeasonView::executeExternalPlay(MediaItem targetItem, QString playerPath) -{ - if (targetItem.id.isEmpty() || playerPath.isEmpty()) - co_return; - try - { - MediaItem actualItem = targetItem; - if (actualItem.mediaSources.isEmpty()) - { - PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); - actualItem.mediaSources = info.mediaSources; - } - if (actualItem.mediaSources.isEmpty()) - co_return; - - int sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( - actualItem.mediaSources, - ConfigStore::instance()->get(ConfigKeys::PlayerPreferredVersion).trimmed()); - - if (sourceIdx >= actualItem.mediaSources.size()) - sourceIdx = 0; - MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; - - PlayerPreferenceUtils::applyPreferredStreamRules( - modifiedSource, - ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); - - QString streamUrl = m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); - - const QString playTitle = - MediaItemUtils::playbackTitle(actualItem, m_seriesTitleLabel->text()); - - long long startTicks = actualItem.userData.playbackPositionTicks; - PlaybackManager::instance()->startExternalPlayback(playerPath, actualItem.id, playTitle, streamUrl, startTicks, - QVariant::fromValue(modifiedSource)); - } - catch (...) - { - } -} - - - - -QCoro::Task SeasonView::executeInternalPlay(MediaItem targetItem) -{ - if (targetItem.id.isEmpty()) - co_return; - try - { - MediaItem actualItem = targetItem; - if (actualItem.mediaSources.isEmpty()) - { - PlaybackInfo info = co_await m_core->mediaService()->getPlaybackInfo(actualItem.id); - actualItem.mediaSources = info.mediaSources; - } - if (actualItem.mediaSources.isEmpty()) - co_return; - - int sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( - actualItem.mediaSources, - ConfigStore::instance()->get(ConfigKeys::PlayerPreferredVersion).trimmed()); - - if (sourceIdx >= actualItem.mediaSources.size()) - sourceIdx = 0; - MediaSourceInfo modifiedSource = actualItem.mediaSources[sourceIdx]; - - PlayerPreferenceUtils::applyPreferredStreamRules( - modifiedSource, - ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); - - QString streamUrl = m_core->mediaService()->getStreamUrl(actualItem.id, modifiedSource.id); - - const QString playTitle = - MediaItemUtils::playbackTitle(actualItem, m_seriesTitleLabel->text()); - - long long startTicks = actualItem.userData.playbackPositionTicks; - PlaybackManager::instance()->startInternalPlayback(actualItem.id, playTitle, streamUrl, startTicks, - QVariant::fromValue(modifiedSource)); - } - catch (...) - { - } +void SeasonView::onMediaItemRemoved(const QString &itemId) { + m_viewModel->removeItem(itemId); } diff --git a/src/qEmbyApp/views/media/seasonview.h b/src/qEmbyApp/views/media/seasonview.h index 8905990..3d56d25 100644 --- a/src/qEmbyApp/views/media/seasonview.h +++ b/src/qEmbyApp/views/media/seasonview.h @@ -2,61 +2,32 @@ #define SEASONVIEW_H #include "../baseview.h" -#include + #include -class MediaGridWidget; -class ModernSortButton; -class QPushButton; -class QLabel; -class DetailContentWidget; -class SplitPlayerButton; +class QuickPageHost; +class SeasonViewModel; -class SeasonView : public BaseView { +class SeasonView final : public BaseView { Q_OBJECT + public: explicit SeasonView(QEmbyCore *core, QWidget *parent = nullptr); QCoro::Task loadSeason(QString seriesId, QString seasonId, QString seasonName); + void prepareForStackLeave() override; + +public slots: + void scrollToTop() override; protected: void onMediaItemUpdated(const MediaItem &item) override; - void showEvent(QShowEvent *event) override; - -private slots: - QCoro::Task onFilterChanged(bool preserveScroll = false); + void onMediaItemRemoved(const QString &itemId) override; private: - void setupUi(); - void updateFavBtnState(); - void refreshExtPlayerButton(); - QCoro::Task loadImages(const QString &fallbackSeriesBackdropTag); - QCoro::Task updatePlayButtonFromNextUp(); - QCoro::Task executeExternalPlay(MediaItem targetItem, QString playerPath); - QCoro::Task executeInternalPlay(MediaItem targetItem); - - QString m_currentSeriesId; - QString m_currentSeasonId; - MediaItem m_currentSeasonItem; - QList m_currentEpisodes; - QString m_nextUpEpisodeId; - bool m_isFavorite = false; - - DetailContentWidget *m_contentWidget = nullptr; - - - QLabel *m_posterLabel = nullptr; - QLabel *m_seriesTitleLabel = nullptr; - QLabel *m_titleLabel = nullptr; - QLabel *m_overviewLabel = nullptr; - - QPushButton *m_playBtn = nullptr; - QPushButton *m_favBtn = nullptr; - ModernSortButton *m_sortButton = nullptr; - QLabel *m_statsLabel = nullptr; - MediaGridWidget *m_mediaGrid = nullptr; - SplitPlayerButton *m_extPlayerBtn = nullptr; + QuickPageHost *m_quickHost = nullptr; + SeasonViewModel *m_viewModel = nullptr; }; -#endif +#endif // SEASONVIEW_H diff --git a/src/qEmbyApp/views/media/seasonviewmodel.cpp b/src/qEmbyApp/views/media/seasonviewmodel.cpp new file mode 100644 index 0000000..ff20c82 --- /dev/null +++ b/src/qEmbyApp/views/media/seasonviewmodel.cpp @@ -0,0 +1,492 @@ +#include "seasonviewmodel.h" + +#include +#include + +#include +#include +#include + +#include + +namespace { + +QString imageCandidate(const QString &itemId, const QString &type, + const QString &tag) { + if (itemId.isEmpty() || tag.isEmpty()) + return {}; + return QStringLiteral("%1,%2,%3").arg(itemId, type, tag); +} + +QString imageSource(const QStringList &candidates) { + QStringList usable; + for (const QString &candidate : candidates) { + if (!candidate.isEmpty() && !usable.contains(candidate)) + usable.append(candidate); + } + return usable.isEmpty() + ? QString() + : QStringLiteral("image://emby/%1").arg(usable.join(';')); +} + +QString episodeImageSource(const MediaItem &item) { + QStringList candidates; + const auto thumb = item.images.bestThumb(); + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(thumb.first) && + !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + candidates.append(imageCandidate( + imageId, + thumb.second.isEmpty() ? QStringLiteral("Primary") : thumb.second, + thumb.first)); + candidates.append(imageCandidate(item.id, QStringLiteral("Primary"), + item.images.primaryTag)); + return imageSource(candidates); +} + +QString episodeSubtitle(const MediaItem &item) { + QStringList parts; + if (item.parentIndexNumber >= 0 && item.indexNumber >= 0) { + parts.append(QObject::tr("S%1 E%2") + .arg(item.parentIndexNumber) + .arg(item.indexNumber)); + } else if (item.indexNumber >= 0) { + parts.append(QObject::tr("Episode %1").arg(item.indexNumber)); + } + if (!item.premiereDate.trimmed().isEmpty()) + parts.append(item.premiereDate.trimmed().left(10)); + if (item.runTimeTicks > 0) { + const qint64 minutes = item.runTimeTicks / 10000000 / 60; + if (minutes > 0) + parts.append(QObject::tr("%1 min").arg(minutes)); + } + return parts.join(QStringLiteral(" · ")); +} + +} // namespace + +EpisodeListModel::EpisodeListModel(QObject *parent) + : QAbstractListModel(parent) {} + +int EpisodeListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant EpisodeListModel::data(const QModelIndex &index, int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return episodeSubtitle(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + case ThumbSourceRole: + return episodeImageSource(*item); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return true; + case PlayedRole: + return item->userData.played; + case ProgressRole: + return item->userData.playedPercentage; + default: + return {}; + } +} + +QHash EpisodeListModel::roleNames() const { + return {{ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + {PlayedRole, "played"}, + {ProgressRole, "progress"}}; +} + +const MediaItem *EpisodeListModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +const MediaItem *EpisodeListModel::itemById(const QString &itemId) const { + for (const MediaItem &item : m_items) { + if (item.id == itemId) + return &item; + } + return nullptr; +} + +void EpisodeListModel::clear() { setItems({}); } + +void EpisodeListModel::setItems(QList items) { + if (m_items.isEmpty() || items.isEmpty()) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); + return; + } + + QSet incomingIds; + for (const MediaItem &item : std::as_const(items)) + incomingIds.insert(item.id); + + for (int row = m_items.size() - 1; row >= 0; --row) { + if (incomingIds.contains(m_items.at(row).id)) + continue; + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + } + + for (int target = 0; target < items.size(); ++target) { + int current = -1; + for (int row = target; row < m_items.size(); ++row) { + if (m_items.at(row).id == items.at(target).id) { + current = row; + break; + } + } + if (current < 0) { + beginInsertRows({}, target, target); + m_items.insert(target, items.at(target)); + endInsertRows(); + } else if (current != target) { + beginMoveRows({}, current, current, {}, target); + m_items.move(current, target); + endMoveRows(); + } + m_items[target] = items.at(target); + emit dataChanged(index(target), index(target)); + } +} + +void EpisodeListModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + m_items[row] = item; + emit dataChanged(index(row), index(row)); + return; + } +} + +void EpisodeListModel::removeItem(const QString &itemId) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != itemId) + continue; + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + return; + } +} + +SeasonViewModel::SeasonViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_episodes(this), + m_statsText(tr("0 Episodes")), m_playText(tr("Play")) {} + +void SeasonViewModel::setSortIndex(int sortIndex) { + if (sortIndex < 0 || sortIndex > 3 || m_sortIndex == sortIndex) + return; + m_sortIndex = sortIndex; + emit sortChanged(); + launchReload(false); +} + +void SeasonViewModel::setDescending(bool descending) { + if (m_descending == descending) + return; + m_descending = descending; + emit sortChanged(); + launchReload(false); +} + +void SeasonViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_generation; + if (m_loading) + m_reloadOnActivate = true; + m_loading = false; + emit stateChanged(); + } else if (m_reloadOnActivate || !m_seasonId.isEmpty()) { + m_reloadOnActivate = false; + launchReload(true); + } +} + +QCoro::Task SeasonViewModel::loadSeason(QString seriesId, + QString seasonId, + QString seasonName) { + ++m_generation; + m_seriesId = std::move(seriesId); + m_seasonId = std::move(seasonId); + m_seasonTitle = std::move(seasonName); + m_seriesTitle = tr("Loading..."); + m_overview.clear(); + m_posterSource.clear(); + m_backdropSource.clear(); + m_errorText.clear(); + m_nextUpEpisodeId.clear(); + m_seriesItem = {}; + m_seasonItem = {}; + m_seasonItem.id = m_seasonId; + m_sortIndex = 0; + m_descending = false; + emit sortChanged(); + emit stateChanged(); + co_await reload(false); +} + +QString SeasonViewModel::sortBy() const { + static const QStringList values = { + QStringLiteral("ParentIndexNumber,IndexNumber"), + QStringLiteral("SortName"), QStringLiteral("PremiereDate"), + QStringLiteral("Runtime")}; + return values.value(m_sortIndex, values.first()); +} + +void SeasonViewModel::launchReload(bool preserveItems) { + if (!m_active) { + m_reloadOnActivate = true; + return; + } + QCoro::connect(reload(preserveItems), this, []() {}); +} + +QCoro::Task SeasonViewModel::reload(bool preserveItems) { + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + if (!m_core || !m_core->mediaService() || m_seasonId.isEmpty()) + co_return; + + const quint64 generation = ++m_generation; + m_loading = true; + m_errorText.clear(); + m_statsText = tr("Loading..."); + if (!preserveItems) + m_episodes.clear(); + emit stateChanged(); + + QPointer guard(this); + co_await loadHeader(generation); + if (!guard || !guard->m_active || generation != guard->m_generation) + co_return; + co_await guard->loadEpisodes(generation, preserveItems); +} + +QCoro::Task SeasonViewModel::loadHeader(quint64 generation) { + QPointer guard(this); + auto *service = m_core ? m_core->mediaService() : nullptr; + if (!service) + co_return; + + if (!m_seriesId.isEmpty()) { + try { + MediaItem series = co_await service->getItemDetail(m_seriesId); + if (!guard || !m_active || generation != m_generation) + co_return; + m_seriesItem = std::move(series); + m_seriesTitle = m_seriesItem.name.isEmpty() ? tr("Unknown Series") + : m_seriesItem.name; + m_overview = m_seriesItem.overview; + rebuildImageSources(); + emit stateChanged(); + } catch (...) { + if (!guard || !m_active || generation != m_generation) + co_return; + m_seriesTitle = tr("Unknown Series"); + emit stateChanged(); + } + } + + try { + MediaItem season = co_await service->getItemDetail(m_seasonId); + if (!guard || !m_active || generation != m_generation) + co_return; + m_seasonItem = std::move(season); + if (!m_seasonItem.name.isEmpty()) + m_seasonTitle = m_seasonItem.name; + if (!m_seasonItem.overview.isEmpty()) + m_overview = m_seasonItem.overview; + rebuildImageSources(m_seriesItem.images.backdropTag); + emit stateChanged(); + } catch (...) { + // Season metadata is optional; the episode request remains useful. + } +} + +QCoro::Task SeasonViewModel::loadEpisodes(quint64 generation, + bool preserveItems) { + QPointer guard(this); + Q_UNUSED(preserveItems) + try { + const QString order = m_descending ? QStringLiteral("Descending") + : QStringLiteral("Ascending"); + QList episodes = co_await m_core->mediaService()->getEpisodes( + m_seriesId, m_seasonId, sortBy(), order); + if (!guard || !m_active || generation != m_generation) + co_return; + + m_episodes.setItems(std::move(episodes)); + m_statsText = tr("%1 Episodes").arg(m_episodes.rowCount()); + m_errorText.clear(); + co_await loadNextUp(generation); + if (!guard || !m_active || generation != m_generation) + co_return; + m_loading = false; + emit stateChanged(); + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + m_loading = false; + m_statsText = tr("Error Loading Episodes"); + m_errorText = QString::fromUtf8(error.what()); + if (m_errorText.trimmed().isEmpty()) + m_errorText = tr("Unable to load this season's episodes."); + emit stateChanged(); + } catch (...) { + if (!guard || !m_active || generation != m_generation) + co_return; + m_loading = false; + m_statsText = tr("Error Loading Episodes"); + m_errorText = tr("Unable to load this season's episodes."); + emit stateChanged(); + } +} + +QCoro::Task SeasonViewModel::loadNextUp(quint64 generation) { + QPointer guard(this); + m_nextUpEpisodeId.clear(); + m_playText = tr("Play"); + if (m_seriesId.isEmpty()) + co_return; + + try { + const QList nextUp = + co_await m_core->mediaService()->getNextUp(m_seriesId); + if (!guard || !m_active || generation != m_generation) + co_return; + if (!nextUp.isEmpty()) { + const MediaItem *episode = m_episodes.itemById(nextUp.first().id); + if (episode) { + m_nextUpEpisodeId = episode->id; + m_playText = episode->indexNumber >= 0 + ? tr("Continue E%1").arg(episode->indexNumber) + : tr("Continue"); + } + } + } catch (...) { + // Falling back to the first episode preserves the original behavior. + } +} + +void SeasonViewModel::rebuildImageSources(const QString &) { + const auto seasonPoster = m_seasonItem.images.bestPoster(); + const auto seriesPoster = m_seriesItem.images.bestPoster(); + m_posterSource = imageSource( + {imageCandidate(m_seasonId, + seasonPoster.second.isEmpty() ? QStringLiteral("Primary") + : seasonPoster.second, + seasonPoster.first), + imageCandidate(m_seriesId, + seriesPoster.second.isEmpty() ? QStringLiteral("Primary") + : seriesPoster.second, + seriesPoster.first)}); + + const auto seasonBackdrop = m_seasonItem.images.bestBackdrop(); + const auto seriesBackdrop = m_seriesItem.images.bestBackdrop(); + m_backdropSource = + imageSource({imageCandidate(m_seasonId, + seasonBackdrop.second.isEmpty() + ? QStringLiteral("Backdrop") + : seasonBackdrop.second, + seasonBackdrop.first), + imageCandidate(m_seriesId, + seriesBackdrop.second.isEmpty() + ? QStringLiteral("Backdrop") + : seriesBackdrop.second, + seriesBackdrop.first)}); +} + +const MediaItem *SeasonViewModel::primaryPlayItem() const { + if (!m_nextUpEpisodeId.isEmpty()) { + if (const MediaItem *episode = m_episodes.itemById(m_nextUpEpisodeId)) + return episode; + } + return m_episodes.itemAt(0); +} + +void SeasonViewModel::updateItem(const MediaItem &item) { + if (item.id == m_seasonId) + m_seasonItem = item; + if (item.id == m_seriesId) + m_seriesItem = item; + m_episodes.updateItem(item); + rebuildImageSources(); + emit stateChanged(); +} + +void SeasonViewModel::removeItem(const QString &itemId) { + m_episodes.removeItem(itemId); + if (itemId == m_nextUpEpisodeId) + m_nextUpEpisodeId.clear(); + m_statsText = tr("%1 Episodes").arg(m_episodes.rowCount()); + m_playText = m_nextUpEpisodeId.isEmpty() ? tr("Play") : m_playText; + emit stateChanged(); +} + +void SeasonViewModel::activateEpisode(int row) { + if (const MediaItem *item = m_episodes.itemAt(row)) + emit episodeActivated(*item); +} + +void SeasonViewModel::requestEpisodePlay(int row) { + if (const MediaItem *item = m_episodes.itemAt(row)) + emit playRequested(*item); +} + +void SeasonViewModel::requestEpisodeFavorite(int row) { + if (const MediaItem *item = m_episodes.itemAt(row)) + emit favoriteRequested(*item); +} + +void SeasonViewModel::requestEpisodeMore(int row) { + if (const MediaItem *item = m_episodes.itemAt(row)) + emit moreRequested(*item); +} + +void SeasonViewModel::requestPrimaryPlay() { + if (const MediaItem *item = primaryPlayItem()) + emit playRequested(*item); +} + +void SeasonViewModel::requestPrimaryMore() { + if (const MediaItem *item = primaryPlayItem()) + emit moreRequested(*item); +} + +void SeasonViewModel::requestSeasonFavorite() { + if (!m_seasonItem.id.isEmpty()) + emit favoriteRequested(m_seasonItem); +} + +void SeasonViewModel::retry() { launchReload(false); } + +void SeasonViewModel::refresh() { launchReload(true); } diff --git a/src/qEmbyApp/views/media/seasonviewmodel.h b/src/qEmbyApp/views/media/seasonviewmodel.h new file mode 100644 index 0000000..21677db --- /dev/null +++ b/src/qEmbyApp/views/media/seasonviewmodel.h @@ -0,0 +1,142 @@ +#ifndef SEASONVIEWMODEL_H +#define SEASONVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class EpisodeListModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + PlayedRole, + ProgressRole, + }; + + explicit EpisodeListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + const MediaItem *itemById(const QString &itemId) const; + void clear(); + void setItems(QList items); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + +private: + QList m_items; +}; + +class SeasonViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QString seriesTitle READ seriesTitle NOTIFY stateChanged) + Q_PROPERTY(QString seasonTitle READ seasonTitle NOTIFY stateChanged) + Q_PROPERTY(QString overview READ overview NOTIFY stateChanged) + Q_PROPERTY(QString posterSource READ posterSource NOTIFY stateChanged) + Q_PROPERTY(QString backdropSource READ backdropSource NOTIFY stateChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY stateChanged) + Q_PROPERTY(QString playText READ playText NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool favorite READ favorite NOTIFY stateChanged) + Q_PROPERTY(bool canPlay READ canPlay NOTIFY stateChanged) + Q_PROPERTY(int sortIndex READ sortIndex WRITE setSortIndex NOTIFY sortChanged) + Q_PROPERTY( + bool descending READ descending WRITE setDescending NOTIFY sortChanged) + Q_PROPERTY(QAbstractItemModel *episodesModel READ episodesModel CONSTANT) + +public: + explicit SeasonViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QString seriesTitle() const { return m_seriesTitle; } + QString seasonTitle() const { return m_seasonTitle; } + QString overview() const { return m_overview; } + QString posterSource() const { return m_posterSource; } + QString backdropSource() const { return m_backdropSource; } + QString statsText() const { return m_statsText; } + QString playText() const { return m_playText; } + QString errorText() const { return m_errorText; } + bool loading() const { return m_loading; } + bool favorite() const { return m_seasonItem.isFavorite(); } + bool canPlay() const { return m_episodes.rowCount() > 0; } + int sortIndex() const { return m_sortIndex; } + bool descending() const { return m_descending; } + QAbstractItemModel *episodesModel() { return &m_episodes; } + + void setSortIndex(int sortIndex); + void setDescending(bool descending); + void setActive(bool active); + QCoro::Task loadSeason(QString seriesId, QString seasonId, + QString seasonName); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + + Q_INVOKABLE void activateEpisode(int row); + Q_INVOKABLE void requestEpisodePlay(int row); + Q_INVOKABLE void requestEpisodeFavorite(int row); + Q_INVOKABLE void requestEpisodeMore(int row); + Q_INVOKABLE void requestPrimaryPlay(); + Q_INVOKABLE void requestPrimaryMore(); + Q_INVOKABLE void requestSeasonFavorite(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void refresh(); + +signals: + void stateChanged(); + void sortChanged(); + void episodeActivated(const MediaItem &item); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item); + +private: + QString sortBy() const; + void launchReload(bool preserveItems); + QCoro::Task reload(bool preserveItems); + QCoro::Task loadHeader(quint64 generation); + QCoro::Task loadEpisodes(quint64 generation, bool preserveItems); + QCoro::Task loadNextUp(quint64 generation); + void rebuildImageSources(const QString &seriesBackdropTag = {}); + const MediaItem *primaryPlayItem() const; + + QPointer m_core; + EpisodeListModel m_episodes; + MediaItem m_seriesItem; + MediaItem m_seasonItem; + QString m_seriesId; + QString m_seasonId; + QString m_seriesTitle; + QString m_seasonTitle; + QString m_overview; + QString m_posterSource; + QString m_backdropSource; + QString m_statsText; + QString m_playText; + QString m_errorText; + QString m_nextUpEpisodeId; + bool m_loading = false; + bool m_active = true; + bool m_reloadOnActivate = false; + int m_sortIndex = 0; + bool m_descending = false; + quint64 m_generation = 0; +}; + +#endif // SEASONVIEWMODEL_H diff --git a/src/qEmbyApp/views/public/loginview.cpp b/src/qEmbyApp/views/public/loginview.cpp index a6022f8..d535cbe 100644 --- a/src/qEmbyApp/views/public/loginview.cpp +++ b/src/qEmbyApp/views/public/loginview.cpp @@ -1,1174 +1,35 @@ #include "loginview.h" -#include "../../components/loadingoverlay.h" -#include "../../components/moderncombobox.h" -#include "../../components/modernmessagebox.h" -#include "../../components/modernswitch.h" -#include "../../components/proxysettingsdialog.h" -#include "../../components/serverwheelview.h" -#include "../../components/webdavsyncdialog.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include + +#include "../../quick/loginviewmodel.h" +#include "../../quick/quickpagehost.h" + +#include #include #include -#include -#include -#include -#include -#include -#include - - -class CardEventFilter : public QObject { -public: - CardEventFilter(QWidget *actionContainer, std::function onClick, - QObject *parent = nullptr) - : QObject(parent), m_actionContainer(actionContainer), - m_onClick(onClick) {} - -protected: - bool eventFilter(QObject *obj, QEvent *event) override { - QWidget *card = static_cast(obj); - - bool isDepthCard = card->property("isDepthCard").toBool(); - - if (event->type() == QEvent::Enter) { - - if (!isDepthCard) - m_actionContainer->show(); - } else if (event->type() == QEvent::Leave) { - m_actionContainer->hide(); - } else if (event->type() == QEvent::MouseButtonRelease) { - auto *mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::LeftButton) { - m_onClick(); - return true; - } - } - - - if (isDepthCard && m_actionContainer->isVisible()) { - m_actionContainer->hide(); - } - - return QObject::eventFilter(obj, event); - } - -private: - QWidget *m_actionContainer; - std::function m_onClick; -}; LoginView::LoginView(QEmbyCore *core, QWidget *parent) - : QWidget(parent), m_core(core) { - setupUi(); + : QWidget(parent), m_host(new QuickPageHost(this)), + m_viewModel(new LoginViewModel(core, this, this)) +{ + setProperty("showGlobalSearch", false); + setProperty("viewTitle", tr("qEmby")); + setProperty("showGlobalBack", false); + setProperty("showGlobalHome", false); + setProperty("showGlobalFav", false); - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - &LoginView::onThemeChanged); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_host); + + m_host->setContextProperty(QStringLiteral("login"), m_viewModel); + m_host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/LoginPage.qml"))); + + connect(m_viewModel, &LoginViewModel::loginCompleted, + this, &LoginView::loginCompleted); } -void LoginView::showEvent(QShowEvent *event) { - QWidget::showEvent(event); - - if (m_loadingOverlay) { - m_loadingOverlay->hide(); - } - refreshServerList(); - - - if (!m_autoLoginAttempted && m_loadingOverlay != nullptr) { - m_autoLoginAttempted = true; - - qDebug() << "LoginView::showEvent: auto login checks starting"; - - bool rememberServer = - ConfigStore::instance()->get(ConfigKeys::RememberServer, false); - QString lastServerId = - ConfigStore::instance()->get(ConfigKeys::LastSelectedServerId); - - qDebug() << "LoginView::showEvent: rememberServer =" << rememberServer - << ", lastServerId =" << lastServerId; - - if (rememberServer && !lastServerId.isEmpty()) { - - bool serverExists = false; - auto servers = m_core->serverManager()->servers(); - qDebug() << "LoginView::showEvent: checking if server exists among" - << servers.size() << "servers"; - - for (const auto &s : servers) { - if (s.id == lastServerId) { - serverExists = true; - break; - } - } - - qDebug() << "LoginView::showEvent: serverExists =" << serverExists; - - if (serverExists) { - - - qDebug() << "LoginView::showEvent: setting up QTimer::singleShot(0)"; - QTimer::singleShot(0, this, [this, lastServerId]() { - qDebug() << "LoginView::showEvent delayed: starting m_loadingOverlay"; - - m_loadingOverlay->start(); - - qDebug() << "LoginView::showEvent delayed: setting up " - "QTimer::singleShot(800)"; - - QTimer::singleShot(800, this, [this, lastServerId]() { - - qDebug() << "LoginView::showEvent 800ms delayed: checking 'this' " - "pointer"; - if (this) { - qDebug() << "LoginView::showEvent 800ms delayed: calling " - "onServerCardClicked"; - onServerCardClicked(lastServerId); - } else { - qDebug() << "LoginView::showEvent 800ms delayed: 'this' pointer " - "is null, aborting"; - } - }); - }); - } - } - } +void LoginView::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + m_viewModel->activate(); } - -void LoginView::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - - if (m_loadingOverlay) { - m_loadingOverlay->resize(event->size()); - } -} - - -QString LoginView::getThemeSvgPath(const QString &iconName) const { - return ThemeManager::instance()->isDarkMode() ? (":/svg/dark/" + iconName) - : (":/svg/light/" + iconName); -} - -void LoginView::onThemeChanged(ThemeManager::Theme theme) { - Q_UNUSED(theme); - - - if (m_togglePwdAction) { - if (m_togglePwdAction->isChecked()) { - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye-off.svg"))); - } else { - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye.svg"))); - } - } - - - if (m_serverProxyBtn) { - m_serverProxyBtn->setIcon(QIcon(getThemeSvgPath("proxy.svg"))); - } - - - if (m_cloudSyncBtn) { - m_cloudSyncBtn->setIcon(QIcon(getThemeSvgPath("cloud-sync.svg"))); - } - - - - if (m_pageSwitcher->currentWidget() == m_listPage) { - refreshServerList(); - } -} - -void LoginView::onCloudSyncClicked() { - - if (!m_webdavStore) { - m_webdavStore = new WebdavProfileStore(this); - m_webdavStore->load(); - qInfo() << "[LoginView] WebdavProfileStore loaded | hasProfile:" - << m_webdavStore->hasProfile(); - } - - ServerManager *sm = m_core ? m_core->serverManager() : nullptr; - WebdavSyncDialog dlg(m_webdavStore, sm, this); - dlg.exec(); - - - refreshServerList(); -} - -void LoginView::updateSslOptionsVisibility() { - const bool isHttps = - m_protocolInput != nullptr && - m_protocolInput->currentText().compare("https://", Qt::CaseInsensitive) == - 0; - - if (m_sslOptionsRow != nullptr) { - m_sslOptionsRow->setVisible(isHttps); - } - - if (!isHttps && m_ignoreSslSwitch != nullptr) { - m_ignoreSslSwitch->setChecked(false); - } - } - -void LoginView::syncProtocolSelectionFromUrlText(const QString &text) { - if (m_protocolInput == nullptr) { - return; - } - - const QString trimmedText = text.trimmed(); - int targetIndex = -1; - if (trimmedText.startsWith("https://", Qt::CaseInsensitive)) { - targetIndex = 1; - } else if (trimmedText.startsWith("http://", Qt::CaseInsensitive)) { - targetIndex = 0; - } - - if (targetIndex != -1 && m_protocolInput->currentIndex() != targetIndex) { - const QSignalBlocker blocker(m_protocolInput); - m_protocolInput->setCurrentIndex(targetIndex); - updateSslOptionsVisibility(); - } -} - -QUrl LoginView::buildNormalizedServerUrl(QString *errorMessage) const { - const QString rawAddress = m_serverAddressInput->text().trimmed(); - const QString rawPort = m_portInput != nullptr ? m_portInput->text().trimmed() - : QString(); - QString candidate = rawAddress; - - int portFromField = -1; - if (!rawPort.isEmpty()) { - bool ok = false; - const int parsedPort = rawPort.toInt(&ok); - if (!ok || parsedPort < 1 || parsedPort > 65535) { - if (errorMessage != nullptr) { - *errorMessage = tr("Please enter a valid port number."); - } - qWarning() << "[LoginView] Invalid port input" << "| port:" << rawPort; - return QUrl(); - } - portFromField = parsedPort; - } - - if (!candidate.startsWith("http://", Qt::CaseInsensitive) && - !candidate.startsWith("https://", Qt::CaseInsensitive)) { - candidate.prepend(m_protocolInput->currentText()); - } - - QUrl normalizedUrl(candidate, QUrl::TolerantMode); - if (!normalizedUrl.isValid() || normalizedUrl.host().isEmpty()) { - if (errorMessage != nullptr) { - *errorMessage = tr("Please enter a valid server address."); - } - return QUrl(); - } - - const QString scheme = normalizedUrl.scheme().toLower(); - if (scheme != "http" && scheme != "https") { - if (errorMessage != nullptr) { - *errorMessage = tr("Server URL must use http:// or https://."); - } - return QUrl(); - } - - if (!normalizedUrl.query().isEmpty() || !normalizedUrl.fragment().isEmpty()) { - qWarning() << "[LoginView] Stripping query or fragment from server URL" - << "| original:" << candidate; - } - if (!normalizedUrl.userName().isEmpty() || - !normalizedUrl.password().isEmpty()) { - qWarning() << "[LoginView] Stripping embedded credentials from server URL" - << "| original:" << candidate; - } - - normalizedUrl.setUserName(QString()); - normalizedUrl.setPassword(QString()); - normalizedUrl.setQuery(QString()); - normalizedUrl.setFragment(QString()); - - const int parsedPort = normalizedUrl.port(-1); - if (portFromField != -1 && parsedPort != portFromField) { - if (parsedPort != -1) { - qDebug() << "[LoginView] Overriding URL port with port field" - << "| parsedPort:" << parsedPort - << "| inputPort:" << portFromField; - } - normalizedUrl.setPort(portFromField); - } - - QString normalizedPath = normalizedUrl.path(QUrl::FullyDecoded); - if (normalizedPath == "/") { - normalizedPath.clear(); - } - while (normalizedPath.size() > 1 && normalizedPath.endsWith('/')) { - normalizedPath.chop(1); - } - normalizedUrl.setPath(normalizedPath); - - qDebug() << "[LoginView] Normalized server URL" - << "| address:" << displayServerAddress(normalizedUrl) - << "| port:" << normalizedUrl.port(-1) - << "| finalUrl:" << normalizedUrl.toString(QUrl::FullyEncoded); - - return normalizedUrl; -} - -QString LoginView::displayServerAddress(const QUrl &url) const { - QString address = url.host(QUrl::FullyDecoded); - if (address.contains(':') && !address.startsWith('[')) { - address = QStringLiteral("[%1]").arg(address); - } - const QString path = url.path(QUrl::FullyDecoded); - if (!path.isEmpty() && path != "/") { - address += path; - } - return address; -} - -void LoginView::applyServerUrlToForm(const QUrl &url, - bool ignoreSslVerification) { - if (m_protocolInput == nullptr || m_serverAddressInput == nullptr || - m_portInput == nullptr) { - return; - } - - { - const QSignalBlocker blocker(m_protocolInput); - m_protocolInput->setCurrentIndex( - url.scheme().compare("https", Qt::CaseInsensitive) == 0 ? 1 : 0); - } - - { - const QSignalBlocker blocker(m_serverAddressInput); - m_serverAddressInput->setText(displayServerAddress(url)); - } - - { - const QSignalBlocker blocker(m_portInput); - const int port = url.port(-1); - m_portInput->setText(port > 0 ? QString::number(port) : QString()); - } - - if (m_ignoreSslSwitch != nullptr) { - m_ignoreSslSwitch->setChecked(ignoreSslVerification); - } - updateSslOptionsVisibility(); -} - -void LoginView::setupUi() { - this->setProperty("showGlobalSearch", false); - this->setProperty("viewTitle", tr("qEmby")); - this->setProperty("showGlobalBack", false); - this->setProperty("showGlobalHome", false); - this->setProperty("showGlobalFav", false); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 8); - - m_pageSwitcher = new QStackedWidget(this); - m_pageSwitcher->setFixedWidth(360); - - setupListPage(); - setupAddPage(); - - m_pageSwitcher->addWidget(m_listPage); - m_pageSwitcher->addWidget(m_addPage); - - mainLayout->addStretch(); - mainLayout->addWidget(m_pageSwitcher, 0, Qt::AlignHCenter); - mainLayout->addStretch(); - - - QString orgName = qApp->organizationName(); - QString version = qApp->applicationVersion(); - if (version.isEmpty()) - version = "1.0.0"; - if (orgName.isEmpty()) - orgName = "AlanHJ"; - - - auto *footerRow = new QWidget(this); - footerRow->setObjectName("login-footer-row"); - auto *footerLayout = new QHBoxLayout(footerRow); - footerLayout->setContentsMargins(12, 0, 12, 0); - footerLayout->setSpacing(6); - - m_cloudSyncBtn = new QPushButton(this); - m_cloudSyncBtn->setObjectName("login-cloud-sync-btn"); - m_cloudSyncBtn->setCursor(Qt::PointingHandCursor); - m_cloudSyncBtn->setIcon(QIcon(getThemeSvgPath("cloud-sync.svg"))); - m_cloudSyncBtn->setIconSize(QSize(14, 14)); - m_cloudSyncBtn->setFixedSize(22, 22); - m_cloudSyncBtn->setToolTip(tr("Cloud Sync (WebDAV)")); - connect(m_cloudSyncBtn, &QPushButton::clicked, this, - &LoginView::onCloudSyncClicked); - - auto *versionLabel = - new QLabel(QString("%1 v%2").arg(orgName, version), this); - versionLabel->setObjectName("app-version-label"); - - - auto *footerSpacer = new QWidget(this); - footerSpacer->setFixedSize(22, 22); - - footerLayout->addWidget(m_cloudSyncBtn, 0, Qt::AlignVCenter); - footerLayout->addStretch(); - footerLayout->addWidget(versionLabel, 0, Qt::AlignVCenter); - footerLayout->addStretch(); - footerLayout->addWidget(footerSpacer, 0, Qt::AlignVCenter); - mainLayout->addWidget(footerRow, 0, Qt::AlignHCenter); - - - m_loadingOverlay = new LoadingOverlay(this); -} - -void LoginView::setupListPage() { - m_listPage = new QWidget(this); - auto *layout = new QVBoxLayout(m_listPage); - - - - - layout->setSpacing(0); - layout->setContentsMargins(8, 8, 8, 8); - - auto *titleLabel = new QLabel(tr("Select Server"), this); - titleLabel->setObjectName("login-title"); - titleLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(titleLabel); - layout->addSpacing(8); - - - m_wheelView = new ServerWheelView(this); - - m_wheelView->setFixedSize(340, 220); - layout->addWidget(m_wheelView, 0, Qt::AlignHCenter); -} - -void LoginView::setupAddPage() { - m_addPage = new QWidget(this); - m_addPage->setObjectName("login-add-page"); - m_addPage->setAttribute(Qt::WA_StyledBackground, true); - auto *layout = new QVBoxLayout(m_addPage); - layout->setSpacing(8); - layout->setContentsMargins(8, 8, 8, 8); - - auto *titleLabel = new QLabel(tr("Connect to Server"), this); - titleLabel->setObjectName("login-title"); - titleLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(titleLabel); - - m_errorLabel = new QLabel(this); - m_errorLabel->setObjectName("login-error-label"); - m_errorLabel->setAlignment(Qt::AlignCenter); - m_errorLabel->setWordWrap(true); - m_errorLabel->hide(); - layout->addWidget(m_errorLabel); - - const QString serverAddressTooltip = tr( - "Server Address (e.g. example.com/emby or 192.168.1.1/jellyfin)"); - m_serverAddressInput = new QLineEdit(this); - m_serverAddressInput->setPlaceholderText(tr("Server Address")); - m_serverAddressInput->setToolTip(serverAddressTooltip); - m_serverAddressInput->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - layout->addWidget(m_serverAddressInput); - - auto *protoPortLayout = new QHBoxLayout(); - protoPortLayout->setSpacing(10); - - m_protocolInput = new ModernComboBox(this); - m_protocolInput->setObjectName("login-protocol-combo"); - m_protocolInput->addItems({"http://", "https://"}); - m_protocolInput->setCursor(Qt::PointingHandCursor); - m_protocolInput->setFixedWidth(qMax(126, m_protocolInput->sizeHint().width())); - - m_portInput = new QLineEdit(this); - m_portInput->setPlaceholderText(tr("Port")); - m_portInput->setToolTip(tr("Port (e.g. 8096)")); - m_portInput->setValidator(new QIntValidator(1, 65535, m_portInput)); - m_portInput->setMaxLength(5); - m_portInput->setMinimumWidth(110); - m_portInput->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - m_portInput->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - - - - - - m_serverProxyBtn = new QPushButton(this); - m_serverProxyBtn->setObjectName("login-proxy-icon-btn"); - m_serverProxyBtn->setCursor(Qt::PointingHandCursor); - m_serverProxyBtn->setIcon(QIcon(getThemeSvgPath("proxy.svg"))); - m_serverProxyBtn->setIconSize(QSize(18, 18)); - m_serverProxyBtn->setFixedWidth(36); - m_serverProxyBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - - protoPortLayout->addWidget(m_protocolInput, 0); - protoPortLayout->addWidget(m_portInput, 1); - - - protoPortLayout->addWidget(m_serverProxyBtn, 0); - layout->addLayout(protoPortLayout); - - connect(m_serverProxyBtn, &QPushButton::clicked, this, - &LoginView::openProxyDialogForCurrentEntry); - - refreshServerProxyTooltip(); - - m_sslOptionsRow = new QWidget(this); - m_sslOptionsRow->setObjectName("login-ssl-card"); - m_sslOptionsRow->setAttribute(Qt::WA_StyledBackground, true); - auto *sslOptionsLayout = new QHBoxLayout(m_sslOptionsRow); - sslOptionsLayout->setContentsMargins(14, 8, 10, 8); - sslOptionsLayout->setSpacing(10); - - const QString sslTooltip = tr( - "Use this only for trusted self-signed or private CA HTTPS servers."); - - auto *sslTitleLabel = - new QLabel(tr("Ignore SSL certificate verification"), m_sslOptionsRow); - sslTitleLabel->setObjectName("login-ssl-title"); - sslTitleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - sslTitleLabel->setToolTip(sslTooltip); - - m_ignoreSslSwitch = new ModernSwitch(m_sslOptionsRow); - m_ignoreSslSwitch->setToolTip(sslTooltip); - - sslOptionsLayout->addWidget(sslTitleLabel, 1); - sslOptionsLayout->addWidget(m_ignoreSslSwitch, 0, Qt::AlignVCenter); - m_sslOptionsRow->setToolTip(sslTooltip); - layout->addWidget(m_sslOptionsRow); - - m_usernameInput = new QLineEdit(this); - m_usernameInput->setPlaceholderText(tr("Username")); - layout->addWidget(m_usernameInput); - - m_passwordInput = new QLineEdit(this); - m_passwordInput->setPlaceholderText(tr("Password")); - m_passwordInput->setEchoMode(QLineEdit::Password); - - - m_togglePwdAction = - new QAction(QIcon(getThemeSvgPath("eye.svg")), tr("Show Password"), this); - m_togglePwdAction->setCheckable(true); - m_passwordInput->addAction(m_togglePwdAction, QLineEdit::TrailingPosition); - - connect(m_togglePwdAction, &QAction::toggled, this, [this](bool checked) { - if (checked) { - m_passwordInput->setEchoMode(QLineEdit::Normal); - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye-off.svg"))); - } else { - m_passwordInput->setEchoMode(QLineEdit::Password); - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye.svg"))); - } - }); - - layout->addWidget(m_passwordInput); - - auto *btnLayout = new QHBoxLayout(); - btnLayout->setSpacing(8); - - auto *cancelBtn = new QPushButton(tr("Cancel"), this); - cancelBtn->setCursor(Qt::PointingHandCursor); - connect(cancelBtn, &QPushButton::clicked, this, &LoginView::showListPage); - - m_loginButton = new QPushButton(tr("Login"), this); - m_loginButton->setObjectName("login-button"); - m_loginButton->setCursor(Qt::PointingHandCursor); - connect(m_loginButton, &QPushButton::clicked, this, - &LoginView::onLoginClicked); - - btnLayout->addWidget(cancelBtn); - btnLayout->addWidget(m_loginButton); - layout->addLayout(btnLayout); - - layout->addStretch(); - - - - - connect(m_protocolInput, &QComboBox::currentTextChanged, this, - [this](const QString &) { updateSslOptionsVisibility(); }); - connect(m_serverAddressInput, &QLineEdit::textChanged, this, - &LoginView::syncProtocolSelectionFromUrlText); - connect(m_serverAddressInput, &QLineEdit::returnPressed, this, - &LoginView::onLoginClicked); - connect(m_portInput, &QLineEdit::returnPressed, this, - &LoginView::onLoginClicked); - connect(m_usernameInput, &QLineEdit::returnPressed, this, - &LoginView::onLoginClicked); - connect(m_passwordInput, &QLineEdit::returnPressed, this, - &LoginView::onLoginClicked); - - updateSslOptionsVisibility(); -} - -void LoginView::refreshServerList() { - int lastSelectedIndex = m_wheelView->currentIndex(); - m_wheelView->clear(); - - QList servers = m_core->serverManager()->servers(); - if (servers.isEmpty()) { - showAddPage(); - return; - } - - - - - - auto createAddCardNode = [this]() { - auto *addCard = new QWidget(this); - addCard->setObjectName("server-card"); - addCard->setAttribute(Qt::WA_StyledBackground, true); - addCard->setCursor(Qt::PointingHandCursor); - - auto *addCardLayout = new QHBoxLayout(addCard); - addCardLayout->setContentsMargins(15, 8, 15, 8); - - auto *addIconLabel = new QLabel(this); - addIconLabel->setFixedSize(40, 40); - addIconLabel->setScaledContents(true); - addIconLabel->setAlignment(Qt::AlignCenter); - - - addIconLabel->setPixmap(QPixmap(getThemeSvgPath("add.svg"))); - - auto *addInfoLayout = new QVBoxLayout(); - addInfoLayout->setSpacing(2); - - - auto *addNameLabel = new QLabel(tr("Add New Server"), this); - addNameLabel->setObjectName("server-name-label"); - QFont nFont = addNameLabel->font(); - nFont.setPixelSize(15); - nFont.setBold(true); - addNameLabel->setFont(nFont); - - - auto *addDescLabel = new QLabel(tr("Connect to another server"), this); - addDescLabel->setObjectName("server-url-label"); - QFont uFont = addDescLabel->font(); - uFont.setPixelSize(12); - addDescLabel->setFont(uFont); - - addInfoLayout->addWidget(addNameLabel); - addInfoLayout->addWidget(addDescLabel); - - auto *dummyActionContainer = new QWidget(this); - dummyActionContainer->hide(); - - addCardLayout->addWidget(addIconLabel); - addCardLayout->addSpacing(12); - addCardLayout->addLayout(addInfoLayout); - addCardLayout->addStretch(); - addCardLayout->addWidget(dummyActionContainer); - - int addCardIndex = m_wheelView->count(); - - auto onAddClick = [this, addCardIndex]() { - int N = m_wheelView->count(); - int currentIndex = m_wheelView->currentIndex(); - int diff = qAbs(currentIndex - addCardIndex); - int distance = qMin(diff, N - diff); - - - if (m_wheelView->currentIndex() != addCardIndex) { - m_wheelView->setCurrentIndex(addCardIndex, true); - } - - - - if (distance <= 1) { - showAddPage(); - } - }; - addCard->installEventFilter( - new CardEventFilter(dummyActionContainer, onAddClick, addCard)); - - m_wheelView->addCard(addCard); - }; - - - - - - - int baseCount = 1 + servers.size(); - int repeatCount = 1; - while (baseCount * repeatCount < 5) { - repeatCount++; - } - - - for (int r = 0; r < repeatCount; ++r) { - - createAddCardNode(); - - for (const auto &server : servers) { - auto *card = new QWidget(this); - card->setObjectName("server-card"); - card->setAttribute(Qt::WA_StyledBackground, true); - card->setCursor(Qt::PointingHandCursor); - - auto *cardLayout = new QHBoxLayout(card); - cardLayout->setContentsMargins(15, 8, 15, 8); - - auto *iconLabel = new QLabel(this); - iconLabel->setFixedSize(40, 40); - iconLabel->setScaledContents(true); - iconLabel->setAlignment(Qt::AlignCenter); - - if (server.type == ServerProfile::Jellyfin) { - iconLabel->setPixmap(QPixmap(":/svg/jellyfin.svg")); - } else { - if (!server.iconBase64.isEmpty()) { - QPixmap pix; - pix.loadFromData(QByteArray::fromBase64(server.iconBase64.toUtf8())); - iconLabel->setPixmap(pix); - } else { - iconLabel->setPixmap(QPixmap(":/svg/emby.svg")); - } - } - - auto *infoLayout = new QVBoxLayout(); - infoLayout->setSpacing(2); - - - auto *nameLabel = new QLabel(server.name, this); - nameLabel->setObjectName("server-name-label"); - QFont nFont = nameLabel->font(); - nFont.setPixelSize(15); - nFont.setBold(true); - nameLabel->setFont(nFont); - - - auto *urlLabel = new QLabel(server.url, this); - urlLabel->setObjectName("server-url-label"); - QFont uFont = urlLabel->font(); - uFont.setPixelSize(12); - urlLabel->setFont(uFont); - - infoLayout->addWidget(nameLabel); - infoLayout->addWidget(urlLabel); - - auto *actionContainer = new QWidget(this); - auto *actionLayout = new QHBoxLayout(actionContainer); - actionLayout->setContentsMargins(0, 0, 0, 0); - actionLayout->setSpacing(8); - - auto *editBtn = new QPushButton(this); - editBtn->setObjectName("action-edit-btn"); - editBtn->setToolTip(tr("Edit")); - editBtn->setCursor(Qt::PointingHandCursor); - connect(editBtn, &QPushButton::clicked, this, - [this, id = server.id]() { onEditServerClicked(id); }); - - auto *delBtn = new QPushButton(this); - delBtn->setObjectName("action-del-btn"); - delBtn->setToolTip(tr("Delete")); - delBtn->setCursor(Qt::PointingHandCursor); - connect(delBtn, &QPushButton::clicked, this, - [this, id = server.id]() { onRemoveServerClicked(id); }); - - actionLayout->addWidget(editBtn); - actionLayout->addWidget(delBtn); - actionContainer->hide(); - - cardLayout->addWidget(iconLabel); - cardLayout->addSpacing(12); - cardLayout->addLayout(infoLayout); - cardLayout->addStretch(); - cardLayout->addWidget(actionContainer); - - int cardIndex = m_wheelView->count(); - - auto onClick = [this, id = server.id, cardIndex]() { - int N = m_wheelView->count(); - int currentIndex = m_wheelView->currentIndex(); - int diff = qAbs(currentIndex - cardIndex); - int distance = qMin(diff, N - diff); - - - if (m_wheelView->currentIndex() != cardIndex) { - m_wheelView->setCurrentIndex(cardIndex, true); - } - - - if (distance <= 1) { - onServerCardClicked(id); - } - }; - card->installEventFilter( - new CardEventFilter(actionContainer, onClick, card)); - - m_wheelView->addCard(card); - } - } - - int totalCount = m_wheelView->count(); - int targetIndex = 1; - - - - - if (lastSelectedIndex > 0 && lastSelectedIndex < totalCount) { - targetIndex = lastSelectedIndex; - } else { - QString lastServerId = - ConfigStore::instance()->get(ConfigKeys::LastSelectedServerId); - if (!lastServerId.isEmpty()) { - for (int i = 0; i < servers.size(); ++i) { - if (servers[i].id == lastServerId) { - targetIndex = i + 1; - break; - } - } - } - } - - targetIndex = qBound(0, targetIndex, totalCount - 1); - m_wheelView->setCurrentIndex(targetIndex, false); - - - m_wheelView->setTransitionMode(true); - - - QTimer::singleShot(450, m_wheelView, [this]() { - if (m_wheelView) - m_wheelView->setTransitionMode( - false); - }); - - m_editingServerId.clear(); - m_pageSwitcher->setCurrentWidget(m_listPage); -} - -void LoginView::showAddPage() { - m_editingServerId.clear(); - - m_pendingProxy = ProxyConfig{}; - m_pendingUseGlobalProxy = false; - refreshServerProxyTooltip(); - m_protocolInput->setCurrentIndex(0); - m_serverAddressInput->clear(); - if (m_portInput) { - m_portInput->clear(); - } - m_usernameInput->clear(); - m_passwordInput->clear(); - if (m_ignoreSslSwitch) { - m_ignoreSslSwitch->setChecked(false); - } - updateSslOptionsVisibility(); - - m_passwordInput->setEchoMode(QLineEdit::Password); - - - if (m_togglePwdAction) { - m_togglePwdAction->setChecked(false); - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye.svg"))); - } - - m_loginButton->setText(tr("Login")); - m_errorLabel->hide(); - m_pageSwitcher->setCurrentWidget(m_addPage); - - - - m_serverAddressInput->setFocus(); -} - -void LoginView::showListPage() { - if (m_core->serverManager()->servers().isEmpty()) - return; - refreshServerList(); -} - -void LoginView::onEditServerClicked(const QString &serverId) { - QList servers = m_core->serverManager()->servers(); - for (const auto &server : servers) { - if (server.id == serverId) { - m_editingServerId = serverId; - - QUrl parsedUrl(server.url); - if (parsedUrl.isValid() && !parsedUrl.scheme().isEmpty()) { - applyServerUrlToForm(parsedUrl, server.ignoreSslVerification); - } else { - m_protocolInput->setCurrentIndex( - server.url.startsWith("https://", Qt::CaseInsensitive) ? 1 : 0); - m_serverAddressInput->setText(server.url); - if (m_portInput) { - m_portInput->clear(); - } - if (m_ignoreSslSwitch) { - m_ignoreSslSwitch->setChecked(server.ignoreSslVerification); - } - updateSslOptionsVisibility(); - } - - m_usernameInput->setText(server.userName); - m_passwordInput->clear(); - - m_passwordInput->setEchoMode(QLineEdit::Password); - if (m_togglePwdAction) { - m_togglePwdAction->setChecked(false); - m_togglePwdAction->setIcon(QIcon(getThemeSvgPath("eye.svg"))); - } - - - m_pendingProxy = server.proxy; - m_pendingUseGlobalProxy = server.useGlobalProxy; - refreshServerProxyTooltip(); - - m_loginButton->setText(tr("Save & Login")); - m_errorLabel->hide(); - m_pageSwitcher->setCurrentWidget(m_addPage); - - - m_passwordInput->setFocus(); - break; - } - } -} - -void LoginView::onRemoveServerClicked(const QString &serverId) { - - - bool confirm = - ModernMessageBox::question(this, tr("Remove Server"), - tr("Are you sure you want to remove this " - "server?\nThis action cannot be undone."), - tr("Remove"), - tr("Cancel"), - ModernMessageBox::Danger); - if (confirm) { - m_core->serverManager()->removeServer(serverId); - refreshServerList(); - } -} - - -QCoro::Task LoginView::onServerCardClicked(const QString &serverId) { - - - - QString safeServerId = serverId; - - - QPointer guard(this); - - - m_loadingOverlay->start(); - - try { - co_await m_core->authService()->validateSession(safeServerId); - if (!guard) - co_return; - - m_loadingOverlay->stop(); - m_loadingOverlay->hide(); - - ConfigStore::instance()->set(ConfigKeys::LastSelectedServerId, safeServerId); - - - - m_wheelView->setTransitionMode(true); - Q_EMIT loginCompleted(); - } catch (const std::exception &e) { - if (!guard) - co_return; - - m_loadingOverlay->stop(); - refreshServerList(); - ModernMessageBox::warning(this, tr("Connection Failed"), - tr("Failed to connect or session expired. Please " - "log in again.\nError: ") + - QString::fromStdString(e.what())); - onEditServerClicked(safeServerId); - } -} - - -QCoro::Task LoginView::onLoginClicked() { - - QPointer guard(this); - - QString user = m_usernameInput->text().trimmed(); - QString pass = m_passwordInput->text(); - - if (m_serverAddressInput->text().trimmed().isEmpty() || user.isEmpty()) { - m_errorLabel->setText(tr("Server address and username cannot be empty.")); - m_errorLabel->show(); - co_return; - } - - QString urlError; - const QUrl normalizedUrl = buildNormalizedServerUrl(&urlError); - if (!urlError.isEmpty()) { - m_errorLabel->setText(urlError); - m_errorLabel->show(); - co_return; - } - - const bool ignoreSslVerification = - normalizedUrl.scheme().compare("https", Qt::CaseInsensitive) == 0 && - m_ignoreSslSwitch != nullptr && m_ignoreSslSwitch->isChecked(); - applyServerUrlToForm(normalizedUrl, ignoreSslVerification); - - const QString fullUrl = normalizedUrl.toString(QUrl::FullyEncoded); - qDebug() << "[LoginView] Attempting login" - << "| url:" << fullUrl - << "| ignoreSslVerification:" << ignoreSslVerification; - - m_errorLabel->hide(); - m_loginButton->setEnabled(false); - m_loginButton->setText(tr("Connecting...")); - - m_loadingOverlay->start(); - - try { - ServerProfile profile = - co_await m_core->authService()->login(fullUrl, user, pass, - ignoreSslVerification); - - if (!guard) - co_return; - - m_loadingOverlay->stop(); - m_loadingOverlay->hide(); - - ConfigStore::instance()->set(ConfigKeys::LastSelectedServerId, profile.id); - - if (!m_editingServerId.isEmpty()) { - m_core->serverManager()->removeServer(m_editingServerId); - m_editingServerId.clear(); - } - - - - if (m_pendingProxy != ProxyConfig{} || m_pendingUseGlobalProxy) { - m_core->serverManager()->updateServerProxy( - profile.id, m_pendingProxy, m_pendingUseGlobalProxy); - qInfo() << "[LoginView] applied draft proxy to new profile" - << "| id:" << profile.id - << "| useGlobal:" << m_pendingUseGlobalProxy - << "| proxy:" << m_pendingProxy.summary(); - } - - m_pendingProxy = ProxyConfig{}; - m_pendingUseGlobalProxy = false; - - m_loginButton->setEnabled(true); - m_loginButton->setText(tr("Login")); - - - m_wheelView->setTransitionMode(true); - Q_EMIT loginCompleted(); - - } catch (const std::exception &e) { - if (!guard) - co_return; - - m_loadingOverlay->stop(); - m_loginButton->setEnabled(true); - m_loginButton->setText(m_editingServerId.isEmpty() ? tr("Login") - : tr("Save & Login")); - m_errorLabel->setText(QString::fromStdString(e.what())); - m_errorLabel->show(); - } -} - - - - - -void LoginView::refreshServerProxyTooltip() { - if (!m_serverProxyBtn) { - return; - } - - - QString state; - if (m_pendingUseGlobalProxy) { - state = tr("Using global proxy"); - } else { - switch (m_pendingProxy.mode) { - case ProxyConfig::None: - state = tr("No proxy"); - break; - case ProxyConfig::System: - state = tr("System proxy"); - break; - case ProxyConfig::Custom: { - const QString typeLabel = - (m_pendingProxy.type == ProxyConfig::Socks5) - ? QStringLiteral("SOCKS5") - : QStringLiteral("HTTP"); - if (m_pendingProxy.host.isEmpty() || m_pendingProxy.port == 0) { - state = tr("Custom (incomplete)"); - } else { - state = QStringLiteral("%1 %2:%3") - .arg(typeLabel, m_pendingProxy.host, - QString::number(m_pendingProxy.port)); - } - break; - } - } - } - m_serverProxyBtn->setToolTip( - tr("Server Proxy — %1").arg(state)); -} - -void LoginView::openProxyDialogForCurrentEntry() { - - - if (!m_editingServerId.isEmpty()) { - auto *dlg = ProxySettingsDialog::createForServer( - m_core->serverManager(), m_editingServerId, this); - if (dlg->exec() == QDialog::Accepted) { - - const auto servers = m_core->serverManager()->servers(); - for (const auto &s : servers) { - if (s.id == m_editingServerId) { - m_pendingProxy = s.proxy; - m_pendingUseGlobalProxy = s.useGlobalProxy; - break; - } - } - refreshServerProxyTooltip(); - } - dlg->deleteLater(); - return; - } - - - auto *dlg = ProxySettingsDialog::createForDraft(m_pendingProxy, - m_pendingUseGlobalProxy, this); - if (dlg->exec() == QDialog::Accepted) { - m_pendingProxy = dlg->resultConfig(); - m_pendingUseGlobalProxy = dlg->resultUseGlobal(); - qInfo() << "[LoginView] draft proxy updated" - << "| useGlobal:" << m_pendingUseGlobalProxy - << "| proxy:" << m_pendingProxy.summary(); - refreshServerProxyTooltip(); - } - dlg->deleteLater(); -} - - diff --git a/src/qEmbyApp/views/public/loginview.h b/src/qEmbyApp/views/public/loginview.h index 19f4b5e..4754a01 100644 --- a/src/qEmbyApp/views/public/loginview.h +++ b/src/qEmbyApp/views/public/loginview.h @@ -2,119 +2,29 @@ #define LOGINVIEW_H #include -#include -#include "../../managers/thememanager.h" -#include "models/profile/proxyconfig.h" -class QLineEdit; -class QPushButton; -class QLabel; -class QStackedWidget; -class QVBoxLayout; +class LoginViewModel; class QEmbyCore; -class QResizeEvent; -class QAction; -class QUrl; - - -class LoadingOverlay; -class ModernComboBox; -class ModernSwitch; -class ServerWheelView; -class WebdavProfileStore; +class QuickPageHost; +class QShowEvent; +// Compatibility shell kept for MainWindow. The implementation is a QML page; +// authentication and profile mutations live in LoginViewModel. class LoginView : public QWidget { Q_OBJECT public: - explicit LoginView(QEmbyCore* core, QWidget *parent = nullptr); + explicit LoginView(QEmbyCore *core, QWidget *parent = nullptr); -Q_SIGNALS: +signals: void loginCompleted(); protected: void showEvent(QShowEvent *event) override; - - void resizeEvent(QResizeEvent *event) override; - -private Q_SLOTS: - - QCoro::Task onLoginClicked(); - QCoro::Task onServerCardClicked(const QString& serverId); - - void showAddPage(); - void showListPage(); - void onRemoveServerClicked(const QString& serverId); - void onEditServerClicked(const QString& serverId); - - - void onCloudSyncClicked(); - - - void onThemeChanged(ThemeManager::Theme theme); private: - void updateSslOptionsVisibility(); - void syncProtocolSelectionFromUrlText(const QString& text); - QUrl buildNormalizedServerUrl(QString* errorMessage) const; - void applyServerUrlToForm(const QUrl& url, bool ignoreSslVerification); - QString displayServerAddress(const QUrl& url) const; - - QEmbyCore* m_core; - - QStackedWidget* m_pageSwitcher; - - QWidget* m_listPage; - QWidget* m_addPage; - - - ServerWheelView* m_wheelView; - - ModernComboBox* m_protocolInput; - QLineEdit* m_serverAddressInput; - QLineEdit* m_portInput = nullptr; - QWidget* m_sslOptionsRow = nullptr; - ModernSwitch* m_ignoreSslSwitch = nullptr; - - QLineEdit* m_usernameInput; - QLineEdit* m_passwordInput; - QPushButton* m_loginButton; - QLabel* m_errorLabel; - - - QAction* m_togglePwdAction = nullptr; - - QString m_editingServerId; - - - LoadingOverlay* m_loadingOverlay = nullptr; - - bool m_autoLoginAttempted = false; - - - - QPushButton* m_serverProxyBtn = nullptr; - - - QPushButton* m_cloudSyncBtn = nullptr; - - - WebdavProfileStore* m_webdavStore = nullptr; - - - - ProxyConfig m_pendingProxy; - bool m_pendingUseGlobalProxy = false; - - void setupUi(); - void setupListPage(); - void setupAddPage(); - void refreshServerList(); - void refreshServerProxyTooltip(); - void openProxyDialogForCurrentEntry(); - - - QString getThemeSvgPath(const QString& iconName) const; + QuickPageHost *m_host = nullptr; + LoginViewModel *m_viewModel = nullptr; }; -#endif +#endif // LOGINVIEW_H diff --git a/src/qEmbyApp/views/search/searchview.cpp b/src/qEmbyApp/views/search/searchview.cpp index 3b1ece8..2e32178 100644 --- a/src/qEmbyApp/views/search/searchview.cpp +++ b/src/qEmbyApp/views/search/searchview.cpp @@ -1,288 +1,58 @@ #include "searchview.h" -#include -#include -#include "../../components/mediagridwidget.h" -#include "../media/mediacarddelegate.h" +#include "searchviewmodel.h" + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" + #include -#include -#include -#include -#include -#include -#include -#include -namespace { -enum SearchTabId { - SearchTabAll = 0, - SearchTabMovies = 1, - SearchTabShows = 2, - SearchTabCollections = 3, - SearchTabPeople = 4 -}; +SearchView::SearchView(QEmbyCore *core, QWidget *parent) + : BaseView(core, parent) { + setObjectName(QStringLiteral("search-view")); + setProperty("showGlobalSearch", true); + setAttribute(Qt::WA_StyledBackground, true); -QString searchIncludeItemTypesForTab(int tabId) -{ - switch (tabId) { - case SearchTabAll: - return QStringLiteral("Movie,Series,BoxSet,Person"); - case SearchTabShows: - return QStringLiteral("Series"); - case SearchTabCollections: - return QStringLiteral("BoxSet"); - case SearchTabPeople: - return QStringLiteral("Person"); - case SearchTabMovies: - default: - return QStringLiteral("Movie"); - } -} + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_quickHost = new QuickPageHost(this); + m_viewModel = new SearchViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("searchPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SearchPage.qml"))); + layout->addWidget(m_quickHost); + + connect(m_viewModel, &SearchViewModel::itemActivated, this, + [this](const MediaItem &item) { + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) { + emit navigateToFolder(item.id, item.name); + } else if (item.type == QLatin1String("Person")) { + emit navigateToPerson(item.id, item.name); + } else { + emit navigateToDetail(item.id, item.name, item); + } + }); + connect(m_viewModel, &SearchViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_viewModel, &SearchViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &SearchViewModel::moreRequested, this, + [this](const MediaItem &item, const QPoint &localPos) { + handleMoreMenuRequested(item, m_quickHost->mapToGlobal(localPos)); + }); } -SearchView::SearchView(QEmbyCore* core, QWidget *parent) - : BaseView(core, parent) -{ - setObjectName("search-view"); - setProperty("showGlobalSearch", true); - setAttribute(Qt::WA_StyledBackground, true); - - setupUi(); - - -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - connect(m_tabGroup, &QButtonGroup::idClicked, this, &SearchView::onTabChanged); -#else - connect(m_tabGroup, QOverload::of(&QButtonGroup::buttonClicked), this, &SearchView::onTabChanged); -#endif - - - - connect(m_mediaGrid, &MediaGridWidget::itemClicked, this, [this](const MediaItem& item){ - if (item.type == "BoxSet" || item.type == "Playlist" || item.type == "Folder") { - Q_EMIT navigateToFolder(item.id, item.name); - } else if (item.type == "Person") { - Q_EMIT navigateToPerson(item.id, item.name); - } else { - Q_EMIT navigateToDetail(item.id, item.name, item); - } - }); - - - - - connect(m_mediaGrid, &MediaGridWidget::playRequested, this, &BaseView::handlePlayRequested); - connect(m_mediaGrid, &MediaGridWidget::favoriteRequested, this, &BaseView::handleFavoriteRequested); - connect(m_mediaGrid, &MediaGridWidget::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); - +QCoro::Task SearchView::performSearch(QString query) { + co_await m_viewModel->search(std::move(query)); } -void SearchView::setupUi() -{ - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - - QScrollArea* headerScrollArea = new QScrollArea(this); - headerScrollArea->setFrameShape(QFrame::NoFrame); - headerScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setWidgetResizable(true); - - headerScrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } " - "QWidget#search-header-container { background: transparent; }"); - - headerScrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - headerScrollArea->setFixedHeight(65); - - QWidget* headerContainer = new QWidget(headerScrollArea); - headerContainer->setObjectName("search-header-container"); - - auto *headerLayout = new QHBoxLayout(headerContainer); - headerLayout->setContentsMargins(20, 15, 20, 10); - headerLayout->setSpacing(20); - headerLayout->setAlignment(Qt::AlignVCenter); - - setupTopBar(headerLayout); - headerScrollArea->setWidget(headerContainer); - - mainLayout->addWidget(headerScrollArea); - - - - m_mediaGrid = new MediaGridWidget(m_core, this); - mainLayout->addWidget(m_mediaGrid, 1); -} - -void SearchView::setupTopBar(QHBoxLayout* headerLayout) -{ - - m_titleLabel = new QLabel(tr("Search Results"), this); - m_titleLabel->setObjectName("library-title"); - m_titleLabel->setStyleSheet("padding: 0px;"); - headerLayout->addWidget(m_titleLabel); - - - QWidget* tabBarWidget = new QWidget(this); - auto* tabLayout = new QHBoxLayout(tabBarWidget); - tabLayout->setContentsMargins(0, 0, 0, 0); - tabLayout->setSpacing(8); - - m_tabGroup = new QButtonGroup(this); - m_tabGroup->setExclusive(true); - - auto *tabAll = new QPushButton(tr("All"), tabBarWidget); - tabAll->setObjectName("library-tab-btn"); - tabAll->setCheckable(true); - tabAll->setChecked(true); - m_tabGroup->addButton(tabAll, SearchTabAll); - - auto *tabMovies = new QPushButton(tr("Movies"), tabBarWidget); - tabMovies->setObjectName("library-tab-btn"); - tabMovies->setCheckable(true); - m_tabGroup->addButton(tabMovies, SearchTabMovies); - - auto *tabShows = new QPushButton(tr("Shows"), tabBarWidget); - tabShows->setObjectName("library-tab-btn"); - tabShows->setCheckable(true); - m_tabGroup->addButton(tabShows, SearchTabShows); - - auto *tabCollections = new QPushButton(tr("Collections"), tabBarWidget); - tabCollections->setObjectName("library-tab-btn"); - tabCollections->setCheckable(true); - m_tabGroup->addButton(tabCollections, SearchTabCollections); - - auto *tabActors = new QPushButton(tr("People"), tabBarWidget); - tabActors->setObjectName("library-tab-btn"); - tabActors->setCheckable(true); - m_tabGroup->addButton(tabActors, SearchTabPeople); - - tabLayout->addWidget(tabAll); - tabLayout->addWidget(tabMovies); - tabLayout->addWidget(tabShows); - tabLayout->addWidget(tabCollections); - tabLayout->addWidget(tabActors); - - headerLayout->addWidget(tabBarWidget); - headerLayout->addStretch(); - - - QWidget* filterBarWidget = new QWidget(this); - auto* filterLayout = new QHBoxLayout(filterBarWidget); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(10); - - - m_btnViewToggle = new QPushButton(filterBarWidget); - m_btnViewToggle->setObjectName("icon-action-btn"); - m_btnViewToggle->setCheckable(true); - m_btnViewToggle->setFixedSize(32, 32); - m_btnViewToggle->setCursor(Qt::PointingHandCursor); - m_btnViewToggle->setToolTip(tr("Toggle View Mode"));; - connect(m_btnViewToggle, &QPushButton::clicked, this, &SearchView::onViewModeChanged); - - m_statsLabel = new QLabel(tr("0 Results"), filterBarWidget); - m_statsLabel->setObjectName("library-stats-label"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - filterLayout->addWidget(m_btnViewToggle); - filterLayout->addWidget(m_statsLabel); - - headerLayout->addWidget(filterBarWidget); -} - - -QCoro::Task SearchView::performSearch(QString query) -{ - - QPointer guard(this); - - query = query.trimmed(); - if (query.isEmpty()) co_return; - - m_currentQuery = query; - m_titleLabel->setText(tr("Search: \"%1\"").arg(query)); - m_statsLabel->setText(tr("Searching...")); - - - m_currentItems.clear(); - m_mediaGrid->setItems(QList()); - - const int checkedTabId = m_tabGroup ? m_tabGroup->checkedId() : SearchTabAll; - const QString typeFilter = searchIncludeItemTypesForTab(checkedTabId); - qDebug() << "[SearchView] Performing search | query:" << query - << "| tabId:" << checkedTabId - << "| includeTypes:" << typeFilter; - - try { - - QList results = co_await m_core->mediaService()->searchMedia( - query, - typeFilter, - "", - "", - 0); - - if (!guard) co_return; - - m_statsLabel->setText(tr("%1 Results").arg(results.size())); - qDebug() << "[SearchView] Search finished | query:" << query - << "| includeTypes:" << typeFilter - << "| resultCount:" << results.size(); - - m_currentItems = results; - m_mediaGrid->setItems(m_currentItems); - - } catch (const std::exception& e) { - if (!guard) co_return; - m_statsLabel->setText(tr("Error Loading Results")); - qDebug() << "[SearchView] Search failed | query:" << query - << "| includeTypes:" << typeFilter - << "| error:" << e.what(); - } -} - - -QCoro::Task SearchView::onTabChanged(int tabId) -{ - - QPointer guard(this); - Q_UNUSED(tabId); - if (!m_currentQuery.isEmpty()) { - co_await performSearch(m_currentQuery); - if (!guard) co_return; - } -} - -void SearchView::onViewModeChanged() -{ - if (m_btnViewToggle->isChecked()) { - m_mediaGrid->setCardStyle(MediaCardDelegate::LibraryTile); - } else { - m_mediaGrid->setCardStyle(MediaCardDelegate::Poster); - } - - if (!m_currentItems.isEmpty()) { - m_mediaGrid->setItems(m_currentItems); - } -} - - - - -void SearchView::onMediaItemUpdated(const MediaItem& item) -{ - - for (int i = 0; i < m_currentItems.size(); ++i) { - if (m_currentItems[i].id == item.id) { - m_currentItems[i] = item; - break; - } - } - - - - if (m_mediaGrid) { - m_mediaGrid->updateItem(item); - } +void SearchView::onMediaItemUpdated(const MediaItem &item) { + m_viewModel->updateItem(item); } diff --git a/src/qEmbyApp/views/search/searchview.h b/src/qEmbyApp/views/search/searchview.h index abb63a0..3790275 100644 --- a/src/qEmbyApp/views/search/searchview.h +++ b/src/qEmbyApp/views/search/searchview.h @@ -2,50 +2,24 @@ #define SEARCHVIEW_H #include "../baseview.h" -#include #include -class MediaGridWidget; -class QLabel; -class QButtonGroup; -class QPushButton; +class QuickPageHost; +class SearchViewModel; -class SearchView : public BaseView -{ - Q_OBJECT +class SearchView : public BaseView { + Q_OBJECT public: - explicit SearchView(QEmbyCore* core, QWidget *parent = nullptr); + explicit SearchView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task performSearch(QString query); + QCoro::Task performSearch(QString query); protected: - - void onMediaItemUpdated(const MediaItem& item) override; - -private slots: - - QCoro::Task onTabChanged(int tabId); - - - void onViewModeChanged(); + void onMediaItemUpdated(const MediaItem &item) override; private: - void setupUi(); - void setupTopBar(class QHBoxLayout* headerLayout); - - QString m_currentQuery; - - - QList m_currentItems; - - QLabel* m_titleLabel; - QLabel* m_statsLabel; - - QButtonGroup* m_tabGroup; - MediaGridWidget* m_mediaGrid; - - QPushButton* m_btnViewToggle; + QuickPageHost *m_quickHost = nullptr; + SearchViewModel *m_viewModel = nullptr; }; -#endif +#endif // SEARCHVIEW_H diff --git a/src/qEmbyApp/views/search/searchviewmodel.cpp b/src/qEmbyApp/views/search/searchviewmodel.cpp new file mode 100644 index 0000000..e739b77 --- /dev/null +++ b/src/qEmbyApp/views/search/searchviewmodel.cpp @@ -0,0 +1,221 @@ +#include "searchviewmodel.h" + +#include +#include + +#include + +namespace { + +QString imageSource(const MediaItem &item, bool thumb) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + const QString tag = best.first; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + if (item.id.isEmpty()) + return {}; + + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(tag) && !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, tag); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) + parts.append(item.seriesName); + if (item.productionYear > 0) + parts.append(QString::number(item.productionYear)); + if (parts.isEmpty()) + parts.append(item.type); + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.mediaType == QLatin1String("Video") || + item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series"); +} + +} // namespace + +SearchResultsModel::SearchResultsModel(QObject *parent) + : QAbstractListModel(parent) {} + +int SearchResultsModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant SearchResultsModel::data(const QModelIndex &index, int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return subtitleFor(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item, false); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return isPlayable(*item); + default: + return {}; + } +} + +QHash SearchResultsModel::roleNames() const { + return { + {ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + }; +} + +const MediaItem *SearchResultsModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +void SearchResultsModel::setItems(const QList &items) { + beginResetModel(); + m_items = items; + endResetModel(); +} + +void SearchResultsModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + m_items[row] = item; + const QModelIndex changed = index(row); + emit dataChanged(changed, changed); + return; + } +} + +SearchViewModel::SearchViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_resultsModel(this), + m_statsText(tr("0 Results")) {} + +QString SearchViewModel::title() const { + return m_query.isEmpty() ? tr("Search Results") + : tr("Search: \"%1\"").arg(m_query); +} + +void SearchViewModel::setTileView(bool tileView) { + if (m_tileView == tileView) + return; + m_tileView = tileView; + emit tileViewChanged(); +} + +void SearchViewModel::setCurrentTab(int tab) { + if (tab < 0 || tab > 4 || m_currentTab == tab) + return; + m_currentTab = tab; + emit currentTabChanged(); + launchCurrentSearch(); +} + +QString SearchViewModel::includeItemTypes(int tab) { + switch (tab) { + case 0: + return QStringLiteral("Movie,Series,BoxSet,Person"); + case 2: + return QStringLiteral("Series"); + case 3: + return QStringLiteral("BoxSet"); + case 4: + return QStringLiteral("Person"); + case 1: + default: + return QStringLiteral("Movie"); + } +} + +void SearchViewModel::launchCurrentSearch() { + if (m_query.isEmpty()) + return; + QCoro::connect(search(m_query), this, []() {}); +} + +QCoro::Task SearchViewModel::search(QString query) { + query = query.trimmed(); + if (query.isEmpty() || !m_core || !m_core->mediaService()) + co_return; + + m_query = query; + const quint64 generation = ++m_searchGeneration; + const QString types = includeItemTypes(m_currentTab); + m_loading = true; + m_errorText.clear(); + m_statsText = tr("Searching..."); + m_resultsModel.setItems({}); + emit stateChanged(); + + QPointer guard(this); + qDebug() << "[SearchViewModel] search" << query << types; + try { + const QList results = + co_await m_core->mediaService()->searchMedia(query, types, {}, {}, 0); + if (!guard || generation != m_searchGeneration) + co_return; + + m_resultsModel.setItems(results); + m_statsText = tr("%1 Results").arg(results.size()); + } catch (const std::exception &error) { + if (!guard || generation != m_searchGeneration) + co_return; + m_errorText = tr("Unable to load search results"); + m_statsText = tr("Error Loading Results"); + qDebug() << "[SearchViewModel] search failed:" << error.what(); + } + + m_loading = false; + emit stateChanged(); +} + +void SearchViewModel::updateItem(const MediaItem &item) { + m_resultsModel.updateItem(item); +} + +void SearchViewModel::activate(int row) { + if (const MediaItem *item = m_resultsModel.itemAt(row)) + emit itemActivated(*item); +} + +void SearchViewModel::requestPlay(int row) { + if (const MediaItem *item = m_resultsModel.itemAt(row)) + emit playRequested(*item); +} + +void SearchViewModel::requestFavorite(int row) { + if (const MediaItem *item = m_resultsModel.itemAt(row)) + emit favoriteRequested(*item); +} + +void SearchViewModel::requestMore(int row, qreal x, qreal y) { + if (const MediaItem *item = m_resultsModel.itemAt(row)) + emit moreRequested(*item, QPoint(qRound(x), qRound(y))); +} + +void SearchViewModel::retry() { launchCurrentSearch(); } diff --git a/src/qEmbyApp/views/search/searchviewmodel.h b/src/qEmbyApp/views/search/searchviewmodel.h new file mode 100644 index 0000000..d195988 --- /dev/null +++ b/src/qEmbyApp/views/search/searchviewmodel.h @@ -0,0 +1,100 @@ +#ifndef SEARCHVIEWMODEL_H +#define SEARCHVIEWMODEL_H + +#include +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class SearchResultsModel final : public QAbstractListModel { + Q_OBJECT +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + }; + + explicit SearchResultsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + void setItems(const QList &items); + void updateItem(const MediaItem &item); + +private: + QList m_items; +}; + +class SearchViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY stateChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY( + bool tileView READ tileView WRITE setTileView NOTIFY tileViewChanged) + Q_PROPERTY(int currentTab READ currentTab WRITE setCurrentTab NOTIFY + currentTabChanged) + Q_PROPERTY(QAbstractItemModel *resultsModel READ resultsModel CONSTANT) + +public: + explicit SearchViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QString title() const; + QString statsText() const { return m_statsText; } + QString errorText() const { return m_errorText; } + bool loading() const { return m_loading; } + bool tileView() const { return m_tileView; } + int currentTab() const { return m_currentTab; } + QAbstractItemModel *resultsModel() { return &m_resultsModel; } + + void setTileView(bool tileView); + void setCurrentTab(int tab); + QCoro::Task search(QString query); + void updateItem(const MediaItem &item); + + Q_INVOKABLE void activate(int row); + Q_INVOKABLE void requestPlay(int row); + Q_INVOKABLE void requestFavorite(int row); + Q_INVOKABLE void requestMore(int row, qreal x, qreal y); + Q_INVOKABLE void retry(); + +signals: + void stateChanged(); + void tileViewChanged(); + void currentTabChanged(); + void itemActivated(const MediaItem &item); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item, const QPoint &localPos); + +private: + void launchCurrentSearch(); + static QString includeItemTypes(int tab); + + QPointer m_core; + SearchResultsModel m_resultsModel; + QString m_query; + QString m_statsText; + QString m_errorText; + bool m_loading = false; + bool m_tileView = false; + int m_currentTab = 0; + quint64 m_searchGeneration = 0; +}; + +#endif // SEARCHVIEWMODEL_H diff --git a/src/qEmbyApp/views/settings/settingsview.cpp b/src/qEmbyApp/views/settings/settingsview.cpp index dc6d0f2..7f3825f 100644 --- a/src/qEmbyApp/views/settings/settingsview.cpp +++ b/src/qEmbyApp/views/settings/settingsview.cpp @@ -1,4 +1,7 @@ #include "settingsview.h" +#include "../../quick/aboutviewmodel.h" +#include "../../quick/quickpagehost.h" +#include "../../quick/settingsviewmodel.h" #include "../../tokens/AnimationTokens.h" #include "../../utils/motionprefs.h" #include "../../components/slidingstackedwidget.h" @@ -23,6 +26,8 @@ SettingsView::SettingsView(QEmbyCore *core, QWidget *parent) setAttribute(Qt::WA_StyledBackground, true); setObjectName("SettingsRootView"); + m_settingsVm = new SettingsViewModel(this); + setupUi(); setupConnections(); @@ -170,39 +175,59 @@ void SettingsView::ensurePageAt(int row) { qDebug() << "[SettingsView] Lazy instantiate page row=" << row; - QWidget *page = nullptr; + QWidget *pageWidget = nullptr; switch (row) { - case 0: - page = new PageGeneral(m_core, m_stack); + case 0: { + auto *host = new QuickPageHost(m_stack); + host->setContextProperty(QStringLiteral("settings"), m_settingsVm); + host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsGeneralPage.qml"))); + pageWidget = host; break; - case 1: - page = new PageAppearance(m_core, m_stack); + } + case 1: { + auto *host = new QuickPageHost(m_stack); + host->setContextProperty(QStringLiteral("settings"), m_settingsVm); + host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsAppearancePage.qml"))); + pageWidget = host; break; - case 2: - page = new PageLibrary(m_core, m_stack); + } + case 2: { + auto *host = new QuickPageHost(m_stack); + host->setContextProperty(QStringLiteral("settings"), m_settingsVm); + host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsLibraryPage.qml"))); + pageWidget = host; break; - case 3: - page = new PagePlayer(m_core, m_stack); + } + case 3: { + auto *host = new QuickPageHost(m_stack); + host->setContextProperty(QStringLiteral("settings"), m_settingsVm); + host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsPlayerPage.qml"))); + pageWidget = host; break; - case 4: - page = new PageAbout(m_core, m_stack); + } + case 4: { + // About 已迁移为 QML 试点页,内容自适应窗口高度,不套 QScrollArea + auto *aboutHost = new QuickPageHost(m_stack); + auto *aboutVm = new AboutViewModel(aboutHost, aboutHost); + aboutHost->setContextProperty(QStringLiteral("about"), aboutVm); + aboutHost->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsAboutPage.qml"))); + pageWidget = aboutHost; break; + } default: return; } - QScrollArea *scroll = wrapInScrollArea(page, row); - - + QWidget *placeholder = m_stack->widget(row); const bool wasBlocked = m_stack->blockSignals(true); - m_stack->insertWidget(row, scroll); + m_stack->insertWidget(row, pageWidget); m_stack->removeWidget(placeholder); m_stack->blockSignals(wasBlocked); m_stack->disposeWidgetWhenSafe(placeholder); - m_pages[row] = scroll; + m_pages[row] = pageWidget; } void SettingsView::setupConnections() { diff --git a/src/qEmbyApp/views/settings/settingsview.h b/src/qEmbyApp/views/settings/settingsview.h index df6d2ec..69e4790 100644 --- a/src/qEmbyApp/views/settings/settingsview.h +++ b/src/qEmbyApp/views/settings/settingsview.h @@ -11,6 +11,7 @@ #include class SlidingStackedWidget; +class SettingsViewModel; class SettingsView : public BaseView { Q_OBJECT @@ -42,6 +43,7 @@ private: QLabel* m_titleLabel; QListWidget* m_navMenu; SlidingStackedWidget* m_stack; + SettingsViewModel* m_settingsVm; diff --git a/src/qEmbyApp/views/user/categoryview.cpp b/src/qEmbyApp/views/user/categoryview.cpp index 05a5afb..7f2835f 100644 --- a/src/qEmbyApp/views/user/categoryview.cpp +++ b/src/qEmbyApp/views/user/categoryview.cpp @@ -1,812 +1,78 @@ #include "categoryview.h" -#include "../../tokens/AnimationTokens.h" -#include "../../components/elidedlabel.h" -#include "../../components/mediagridwidget.h" -#include "../../components/modernsortbutton.h" -#include "../../managers/thememanager.h" -#include "../../utils/dashboardrequestlimitutils.h" -#include "../../utils/mediaitemutils.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "categoryviewmodel.h" + +#include +#include +#include +#include #include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr int kDashboardCategoryFirstPageSize = 100; -constexpr int kDashboardCategoryBackgroundPageSize = 300; - -QString pageFingerprint(const QList &items) { - QString fingerprint; - for (const MediaItem &item : items) { - const QString id = item.id.trimmed(); - if (id.isEmpty()) { - continue; - } - fingerprint += id; - fingerprint += QLatin1Char('|'); - } - return fingerprint; -} -} CategoryView::CategoryView(QEmbyCore *core, QWidget *parent) : BaseView(core, parent) { + setObjectName(QStringLiteral("category-view")); setAttribute(Qt::WA_StyledBackground, true); - setObjectName("category-view"); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); - - - QScrollArea *headerScrollArea = new QScrollArea(this); - headerScrollArea->setFrameShape(QFrame::NoFrame); - headerScrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - headerScrollArea->setWidgetResizable(true); + m_quickHost = new QuickPageHost(this); + m_viewModel = new CategoryViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("categoryPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/CategoryPage.qml"))); + layout->addWidget(m_quickHost); - headerScrollArea->setStyleSheet( - "QScrollArea { background: transparent; border: none; } " - "QWidget#category-header-container { background: transparent; }"); + connect(m_quickHost, &QuickPageHost::pageShown, this, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, this, + [this]() { m_viewModel->setActive(false); }); - headerScrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - headerScrollArea->setFixedHeight(65); - - QWidget *headerContainer = new QWidget(headerScrollArea); - headerContainer->setObjectName("category-header-container"); - - auto *headerLayout = new QHBoxLayout(headerContainer); - headerLayout->setContentsMargins(20, 15, 20, 10); - headerLayout->setSpacing(20); - headerLayout->setAlignment(Qt::AlignVCenter); - - setupTopBar(headerLayout); - headerScrollArea->setWidget(headerContainer); - - mainLayout->addWidget(headerScrollArea); - - - - m_mediaGrid = new MediaGridWidget(m_core, this); - mainLayout->addWidget(m_mediaGrid); - - - connect(m_mediaGrid, &MediaGridWidget::itemClicked, this, + connect(m_viewModel, &CategoryViewModel::itemActivated, this, [this](const MediaItem &item) { - if (item.type == "BoxSet" || item.type == "Playlist" || - item.type == "Folder") { - Q_EMIT navigateToFolder(item.id, item.name); - } else if (item.type == "Person") { - Q_EMIT navigateToPerson(item.id, item.name); + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) { + emit navigateToFolder(item.id, item.name); + } else if (item.type == QLatin1String("Person")) { + emit navigateToPerson(item.id, item.name); } else { - Q_EMIT navigateToDetail(item.id, item.name, item); + emit navigateToDetail(item.id, item.name, item); } }); - - - - - connect(m_mediaGrid, &MediaGridWidget::playRequested, this, + connect(m_viewModel, &CategoryViewModel::playRequested, this, &BaseView::handlePlayRequested); - connect(m_mediaGrid, &MediaGridWidget::favoriteRequested, this, + connect(m_viewModel, &CategoryViewModel::favoriteRequested, this, &BaseView::handleFavoriteRequested); - connect(m_mediaGrid, &MediaGridWidget::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - -} - -void CategoryView::setupTopBar(QHBoxLayout *headerLayout) { - - m_titleLabel = new ElidedLabel(this); - m_titleLabel->setObjectName("library-title"); - m_titleLabel->setStyleSheet("padding: 0px;"); - headerLayout->addWidget(m_titleLabel); - - headerLayout->addStretch(); - - - QWidget *filterBarWidget = new QWidget(this); - auto *filterLayout = new QHBoxLayout(filterBarWidget); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(10); - - - m_refreshBtn = new QPushButton(filterBarWidget); - m_refreshBtn->setObjectName("refresh-btn"); - m_refreshBtn->setFixedSize(28, 28); - m_refreshBtn->setCursor(Qt::PointingHandCursor); - m_refreshBtn->setToolTip(tr("Refresh Recommendations")); - auto refreshIcon = []() { - return QIcon(ThemeManager::instance()->isDarkMode() - ? ":/svg/dark/refresh.svg" - : ":/svg/light/refresh.svg"); - }; - m_refreshBtn->setIcon(refreshIcon()); - m_refreshBtn->setIconSize(QSize(16, 16)); - m_refreshBtn->setVisible(false); - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [this, refreshIcon]() { m_refreshBtn->setIcon(refreshIcon()); }); - - - m_refreshAnimation = new QPropertyAnimation(this, QByteArray()); - m_refreshAnimation->setDuration(AnimationTokens::DurationMs::kGentle); - m_refreshAnimation->setStartValue(0.0); - m_refreshAnimation->setEndValue(360.0); - m_refreshAnimation->setEasingCurve(AnimationTokens::Curve::loop()); - - connect(m_refreshAnimation, &QPropertyAnimation::valueChanged, this, - [this, refreshIcon](const QVariant &value) { - qreal angle = value.toReal(); - QPixmap originalPix = - refreshIcon().pixmap(QSize(32, 32)); - QPixmap rotatedPix(originalPix.size()); - rotatedPix.fill(Qt::transparent); - QPainter painter(&rotatedPix); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setRenderHint(QPainter::SmoothPixmapTransform, true); - painter.translate(rotatedPix.width() / 2.0, - rotatedPix.height() / 2.0); - painter.rotate(angle); - painter.translate(-originalPix.width() / 2.0, - -originalPix.height() / 2.0); - painter.drawPixmap(0, 0, originalPix); - painter.end(); - m_refreshBtn->setIcon(QIcon(rotatedPix)); + connect(m_viewModel, &CategoryViewModel::moreRequested, this, + [this](const MediaItem &item) { + handleMoreMenuRequested(item, QCursor::pos()); }); - - connect(m_refreshAnimation, &QPropertyAnimation::finished, this, - [this, refreshIcon]() { - m_refreshBtn->setIcon(refreshIcon()); - m_refreshBtn->setEnabled(true); - }); - - connect(m_refreshBtn, &QPushButton::clicked, this, - [this]() { refreshData(); }); - - - m_sortButton = new ModernSortButton(filterBarWidget); - m_sortButton->setSortItems({tr("Date Added"), tr("Date Played"), tr("Title"), - tr("Runtime"), tr("Premiere Date")}); - connect(m_sortButton, &ModernSortButton::sortChanged, this, - &CategoryView::onFilterChanged); - - - m_viewSwitchBtn = new QPushButton(filterBarWidget); - m_viewSwitchBtn->setObjectName("icon-action-btn"); - m_viewSwitchBtn->setCheckable(true); - m_viewSwitchBtn->setFixedSize(32, 32); - m_viewSwitchBtn->setCursor(Qt::PointingHandCursor); - m_viewSwitchBtn->setToolTip(tr("Toggle View Mode")); - - connect(m_viewSwitchBtn, &QPushButton::clicked, this, [this](bool checked) { - m_mediaGrid->setCardStyle(checked ? MediaCardDelegate::LibraryTile - : MediaCardDelegate::Poster); - saveViewPreference(); - onFilterChanged(); - }); - - m_statsLabel = new QLabel(tr("0 Items"), filterBarWidget); - m_statsLabel->setObjectName("library-stats-label"); - m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - filterLayout->addWidget(m_refreshBtn); - filterLayout->addWidget(m_sortButton); - filterLayout->addWidget(m_viewSwitchBtn); - filterLayout->addWidget(m_statsLabel); - - headerLayout->addWidget(filterBarWidget); } -bool CategoryView::isCastStyleCategory(const QString &categoryType) const { - return categoryType == "Favorite_Person" || categoryType == "Person"; -} - -bool CategoryView::isProgressiveDashboardCategory( - const QString &categoryType) const { - return categoryType == "resume" || categoryType == "latest" || - categoryType == "played"; -} - -QString CategoryView::currentViewPreferenceCategoryId() const { - const QString categoryType = m_currentCategory.trimmed(); - if (categoryType.isEmpty() || isCastStyleCategory(categoryType)) { - return QString(); - } - - return QStringLiteral("category_%1").arg(categoryType); -} - -void CategoryView::applyViewMode(bool isTile) { - m_viewSwitchBtn->blockSignals(true); - m_viewSwitchBtn->setChecked(isTile); - m_viewSwitchBtn->blockSignals(false); - m_mediaGrid->setCardStyle(isTile ? MediaCardDelegate::LibraryTile - : MediaCardDelegate::Poster); -} - -void CategoryView::saveViewPreference() { - if (!m_core || !m_core->serverManager()) { - return; - } - - const QString serverId = m_core->serverManager()->activeProfile().id; - const QString categoryId = currentViewPreferenceCategoryId(); - if (serverId.isEmpty() || categoryId.isEmpty()) { - return; - } - - const QString viewMode = m_viewSwitchBtn->isChecked() - ? QStringLiteral("tile") - : QStringLiteral("poster"); - auto *store = ConfigStore::instance(); - store->set( - ConfigKeys::forCategory(serverId, categoryId, ConfigKeys::CategoryViewMode), - viewMode); - - qDebug() << "[CategoryView] View preference saved:" - << "server=" << serverId << "category=" << categoryId - << "mode=" << viewMode; -} - -void CategoryView::restoreViewPreference() { - auto *store = ConfigStore::instance(); - const QString defaultViewMode = - store->get(ConfigKeys::DefaultLibraryView, QStringLiteral("poster")); - - QString viewMode = defaultViewMode; - if (m_core && m_core->serverManager()) { - const QString serverId = m_core->serverManager()->activeProfile().id; - const QString categoryId = currentViewPreferenceCategoryId(); - if (!serverId.isEmpty() && !categoryId.isEmpty()) { - viewMode = store->get( - ConfigKeys::forCategory(serverId, categoryId, ConfigKeys::CategoryViewMode), - defaultViewMode); - qDebug() << "[CategoryView] View preference restored:" - << "server=" << serverId << "category=" << categoryId - << "mode=" << viewMode << "default=" << defaultViewMode; - } else { - qDebug() << "[CategoryView] View preference fallback to default:" - << "mode=" << defaultViewMode; - } - } - - applyViewMode(viewMode == QLatin1String("tile")); -} - -int CategoryView::dashboardCategoryRequestLimit( - const QString &categoryType) const { - const QString serverId = - (m_core && m_core->serverManager()) - ? m_core->serverManager()->activeProfile().id - : QString(); - - if (categoryType == "resume") { - return DashboardRequestLimitUtils::configuredRequestLimit( - serverId, ConfigKeys::ContinueWatchingRequestLimit, 0); - } - if (categoryType == "latest") { - return DashboardRequestLimitUtils::configuredRequestLimit( - serverId, ConfigKeys::LatestMediaRequestLimit, 1000); - } - if (categoryType == "recommended") { - return DashboardRequestLimitUtils::configuredRequestLimit( - serverId, ConfigKeys::RecommendedRequestLimit, 1000); - } - if (categoryType == "played") { - return DashboardRequestLimitUtils::configuredRequestLimit( - serverId, ConfigKeys::CompletedWatchingRequestLimit, 0); - } - return 0; -} - -CategoryView::DashboardCategoryQuery -CategoryView::buildDashboardCategoryQuery(const QString &sortBy, - const QString &sortOrder) const { - DashboardCategoryQuery query; - query.category = m_currentCategory; - query.sortBy = sortBy; - query.sortOrder = sortOrder; - query.requestLimit = dashboardCategoryRequestLimit(m_currentCategory); - query.firstPageSize = kDashboardCategoryFirstPageSize; - query.pageSize = kDashboardCategoryBackgroundPageSize; - - qDebug() << "[CategoryView] dashboard category query" - << "| category=" << query.category - << "| sortBy=" << query.sortBy - << "| sortOrder=" << query.sortOrder - << "| requestLimit=" << query.requestLimit - << "| firstPageSize=" << query.firstPageSize - << "| pageSize=" << query.pageSize; - return query; -} - -void CategoryView::setLoadedItems(const QList &items) { - m_loadedItems = items; - m_statsLabel->setText(tr("%1 Items").arg(m_loadedItems.size())); - m_mediaGrid->setItems(m_loadedItems); -} - -void CategoryView::appendUniqueLoadedItems(const QList &items) { - QSet existingIds; - for (const MediaItem &item : std::as_const(m_loadedItems)) { - const QString id = item.id.trimmed(); - if (!id.isEmpty()) { - existingIds.insert(id); - } - } - - for (const MediaItem &item : items) { - const QString id = item.id.trimmed(); - if (!id.isEmpty() && existingIds.contains(id)) { - continue; - } - if (!id.isEmpty()) { - existingIds.insert(id); - } - m_loadedItems.append(item); - } - - m_statsLabel->setText(tr("%1 Items").arg(m_loadedItems.size())); - m_mediaGrid->setItems(m_loadedItems); -} - -QCoro::Task -CategoryView::fetchDashboardCategoryPage(DashboardCategoryQuery query, - int startIndex, int limit) { - QPointer guard(this); - DashboardCategoryPage categoryPage; - auto *mediaService = m_core->mediaService(); - MediaQueryPage page; - - if (query.category == "resume") { - page = co_await mediaService->getResumeItemsPage( - query.sortBy, query.sortOrder, startIndex, limit); - if (!guard) - co_return categoryPage; - - categoryPage.rawItemCount = page.items.size(); - categoryPage.fingerprint = pageFingerprint(page.items); - - QSet seenSeriesIds; - QStringList seriesIdsToFetch; - QList insertIndices; - QList resumeContextItems; - QList displayItems; - - for (MediaItem item : page.items) { - if (item.type == "Episode" && !item.seriesId.isEmpty()) { - if (seenSeriesIds.contains(item.seriesId)) { - continue; - } - seenSeriesIds.insert(item.seriesId); - seriesIdsToFetch.append(item.seriesId); - insertIndices.append(displayItems.size()); - resumeContextItems.append(item); - displayItems.append(MediaItem {}); - } else { - displayItems.append(MediaItemUtils::withResumeContext(item, item)); - } - } - - std::vector> detailTasks; - detailTasks.reserve(seriesIdsToFetch.size()); - for (const QString &seriesId : std::as_const(seriesIdsToFetch)) { - detailTasks.push_back(mediaService->getItemDetail(seriesId)); - } - - for (int i = 0; i < static_cast(detailTasks.size()); ++i) { - try { - MediaItem seriesItem = co_await std::move(detailTasks[i]); - if (!guard) - co_return categoryPage; - displayItems[insertIndices[i]] = MediaItemUtils::withResumeContext( - seriesItem, resumeContextItems[i]); - } catch (const std::exception &e) { - if (!guard) - co_return categoryPage; - qWarning() << "[CategoryView] failed to resolve resume series" - << "| seriesId=" << seriesIdsToFetch.value(i) - << "| error=" << e.what(); - } - } - - for (int i = displayItems.size() - 1; i >= 0; --i) { - if (displayItems.at(i).id.isEmpty()) { - displayItems.removeAt(i); - } - } - categoryPage.items = std::move(displayItems); - } else if (query.category == "latest") { - page = co_await mediaService->getLatestItemsPage( - query.sortBy, query.sortOrder, startIndex, limit); - if (!guard) - co_return categoryPage; - categoryPage.items = page.items; - categoryPage.rawItemCount = page.items.size(); - categoryPage.fingerprint = pageFingerprint(page.items); - } else if (query.category == "played") { - page = co_await mediaService->getPlayedItemsPage( - query.sortBy, query.sortOrder, startIndex, limit); - if (!guard) - co_return categoryPage; - categoryPage.items = page.items; - categoryPage.rawItemCount = page.items.size(); - categoryPage.fingerprint = pageFingerprint(page.items); - } - - categoryPage.totalRecordCount = page.totalRecordCount; - categoryPage.hasTotalRecordCount = page.hasTotalRecordCount; - - qDebug() << "[CategoryView] fetched dashboard category page" - << "| category=" << query.category - << "| startIndex=" << startIndex - << "| limit=" << limit - << "| rawReturned=" << categoryPage.rawItemCount - << "| displayReturned=" << categoryPage.items.size() - << "| hasTotal=" << categoryPage.hasTotalRecordCount - << "| total=" << categoryPage.totalRecordCount; - co_return categoryPage; -} - -QCoro::Task -CategoryView::loadDashboardCategoryProgressively( - DashboardCategoryQuery query) { - QPointer guard(this); - const int generation = m_requestGeneration; - - m_loadedItems.clear(); - QSet pageFingerprints; - int loadedRawCount = 0; - bool firstDisplayUpdate = true; - - while (true) { - if (!guard || generation != m_requestGeneration) { - co_return; - } - - const int preferredPageSize = - firstDisplayUpdate ? query.firstPageSize : query.pageSize; - int pageLimit = qMax(1, preferredPageSize); - if (query.requestLimit > 0) { - const int remaining = query.requestLimit - loadedRawCount; - if (remaining <= 0) { - break; - } - pageLimit = qMin(pageLimit, remaining); - } - - DashboardCategoryPage page = - co_await fetchDashboardCategoryPage(query, loadedRawCount, pageLimit); - if (!guard || generation != m_requestGeneration) { - co_return; - } - - if (page.rawItemCount <= 0) { - qDebug() << "[CategoryView] progressive load stopped on empty page" - << "| category=" << query.category - << "| loadedRaw=" << loadedRawCount; - break; - } - - if (!page.fingerprint.isEmpty()) { - if (pageFingerprints.contains(page.fingerprint)) { - qWarning() << "[CategoryView] progressive load stopped on repeated page" - << "| category=" << query.category - << "| startIndex=" << loadedRawCount - << "| limit=" << pageLimit; - break; - } - pageFingerprints.insert(page.fingerprint); - } - - loadedRawCount += page.rawItemCount; - if (firstDisplayUpdate) { - setLoadedItems(page.items); - firstDisplayUpdate = false; - m_mediaGrid->setLoading(false); - } else { - appendUniqueLoadedItems(page.items); - } - - if (query.requestLimit > 0 && loadedRawCount >= query.requestLimit) { - break; - } - if (page.hasTotalRecordCount && loadedRawCount >= page.totalRecordCount) { - break; - } - if (page.rawItemCount < pageLimit) { - break; - } - } - - if (guard && generation == m_requestGeneration) { - m_mediaGrid->setLoading(false); - m_statsLabel->setText(tr("%1 Items").arg(m_loadedItems.size())); - qDebug() << "[CategoryView] progressive load complete" - << "| category=" << query.category - << "| loadedRaw=" << loadedRawCount - << "| displayed=" << m_loadedItems.size(); - } -} - - QCoro::Task CategoryView::loadCategory(const QString &categoryType, const QString &title) { - - QPointer guard(this); - - m_currentCategory = categoryType; - m_titleLabel->setFullText(title); - - - m_sortButton->blockSignals(true); - if (categoryType == "latest") { - m_sortButton->setCurrentIndex(0); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(false); - } else if (categoryType == "resume") { - m_sortButton->setCurrentIndex(1); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(false); - } else if (categoryType == "played") { - m_sortButton->setCurrentIndex(1); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(true); - } else if (categoryType == "Favorite_Movie" || categoryType == "Movie") { - m_sortButton->setCurrentIndex(1); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(true); - } else if (categoryType == "recommended") { - m_sortButton->setCurrentIndex(0); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(false); - } else { - m_sortButton->setCurrentIndex( - 0); - m_sortButton->setDescending(true); - m_sortButton->setEnabled(true); - } - m_sortButton->blockSignals(false); - - - if (isCastStyleCategory(categoryType)) { - m_mediaGrid->setCardStyle(MediaCardDelegate::Cast); - m_viewSwitchBtn->setVisible(false); - } else { - restoreViewPreference(); - m_viewSwitchBtn->setVisible(true); - } - - - m_refreshBtn->setVisible(categoryType == "recommended"); - - - co_await onFilterChanged(); + co_await m_viewModel->loadCategory(categoryType, title); } +void CategoryView::prepareForStackLeave() { m_viewModel->setActive(false); } -QCoro::Task CategoryView::onFilterChanged() { - - QPointer guard(this); - - - m_mediaGrid->setItems(QList()); - m_mediaGrid->setLoading(false); - m_statsLabel->setText(tr("Loading...")); - - - QString sortBy = "SortName"; - switch (m_sortButton->currentIndex()) { - case 0: - sortBy = "DateCreated"; - break; - case 1: - sortBy = "DatePlayed"; - break; - case 2: - sortBy = "SortName"; - break; - case 3: - sortBy = "Runtime"; - break; - case 4: - sortBy = "PremiereDate"; - break; - } - QString sortOrder = m_sortButton->isDescending() ? "Descending" : "Ascending"; - const int generation = ++m_requestGeneration; - m_loadedItems.clear(); - - if (isProgressiveDashboardCategory(m_currentCategory)) { - co_await loadDashboardCategoryProgressively( - buildDashboardCategoryQuery(sortBy, sortOrder)); - co_return; - } - - const int requestLimit = dashboardCategoryRequestLimit(m_currentCategory); - - auto *mediaService = m_core->mediaService(); - - try { - QList resultItems; - - - if (m_currentCategory == "resume") { - QList rawItems = - co_await mediaService->getResumeItems(requestLimit, sortBy, - sortOrder); - if (!guard) - co_return; - - - QSet seenSeriesIds; - for (MediaItem item : rawItems) { - if (item.type == "Episode" && !item.seriesId.isEmpty()) { - if (seenSeriesIds.contains(item.seriesId)) - continue; - seenSeriesIds.insert(item.seriesId); - try { - MediaItem seriesItem = - co_await mediaService->getItemDetail(item.seriesId); - if (!guard) - co_return; - resultItems.append( - MediaItemUtils::withResumeContext(seriesItem, item)); - } catch (...) { - if (!guard) - co_return; - } - } else { - resultItems.append(MediaItemUtils::withResumeContext(item, item)); - } - } - } else if (m_currentCategory == "latest") { - resultItems = - co_await mediaService->getLatestItems(requestLimit, sortBy, - sortOrder); - } else if (m_currentCategory == "recommended") { - resultItems = co_await mediaService->getRecommendedMovies( - requestLimit, QStringLiteral("Random"), QStringLiteral("Ascending")); - } else if (m_currentCategory == "played") { - resultItems = - co_await mediaService->getPlayedItems(requestLimit, sortBy, - sortOrder); - } else if (m_currentCategory == "Favorite_Movie" || - m_currentCategory == "Movie") { - resultItems = - co_await mediaService->getFavoriteMovies(0, sortBy, sortOrder); - } else if (m_currentCategory == "Favorite_BoxSet" || - m_currentCategory == "BoxSet") { - resultItems = - co_await mediaService->getFavoriteCollections(0, sortBy, sortOrder); - } else if (m_currentCategory == "Favorite_Playlist" || - m_currentCategory == "Playlist") { - resultItems = - co_await mediaService->getFavoritePlaylists(0, sortBy, sortOrder); - } else if (m_currentCategory == "Favorite_Folder" || - m_currentCategory == "Folder") { - resultItems = - co_await mediaService->getFavoriteFolders(0, sortBy, sortOrder); - } else if (m_currentCategory == "Favorite_Person" || - m_currentCategory == "Person") { - resultItems = - co_await mediaService->getFavoritePeople(0, sortBy, sortOrder); - } - - - - - if (!guard || generation != m_requestGeneration) - co_return; - - - m_statsLabel->setText(tr("%1 Items").arg(resultItems.size())); - m_mediaGrid->setItems(resultItems); - m_mediaGrid->setLoading(false); - - } catch (const std::exception &e) { - - if (!guard) - co_return; - m_mediaGrid->setLoading(false); - m_statsLabel->setText(tr("Error Loading Items")); - qDebug() << "Category View fetching error:" << e.what(); - } +void CategoryView::scrollToTop() { + if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) + QMetaObject::invokeMethod(root, "scrollToTop"); } - - - void CategoryView::onMediaItemUpdated(const MediaItem &item) { - if (m_mediaGrid) { - - if (m_currentCategory == "resume") { - const bool isSeriesDetailWithoutResumeContext = - item.type == "Series" && !item.hasResumeContext; - const bool canRemoveFromResume = - MediaItemUtils::canRemoveFromResume(item); - - - if ((!isSeriesDetailWithoutResumeContext || item.userData.played) && - !canRemoveFromResume) { - m_mediaGrid->removeItem(item.id); - - m_statsLabel->setText(tr("%1 Items").arg(m_mediaGrid->itemCount())); - return; - } - } - if (m_currentCategory == "played") { - if (MediaItemUtils::isCompletedWatchingItem(item)) { - m_mediaGrid->prependOrUpdateItem(item); - } else { - m_mediaGrid->removeItem(item.id); - } - m_statsLabel->setText(tr("%1 Items").arg(m_mediaGrid->itemCount())); - return; - } - - - m_mediaGrid->updateItem(item); - } + m_viewModel->updateItem(item); } - - - - void CategoryView::onMediaItemRemoved(const QString &itemId) { - if (m_mediaGrid) { - m_mediaGrid->removeItem(itemId); - - m_statsLabel->setText(tr("%1 Items").arg(m_mediaGrid->itemCount())); - } -} - - - - - - -QCoro::Task CategoryView::refreshData() { - QPointer guard(this); - m_refreshBtn->setEnabled(false); - - - bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (!reduceAnimations) { - m_refreshAnimation->setLoopCount(-1); - m_refreshAnimation->start(); - } - - auto *mediaService = m_core->mediaService(); - mediaService->clearRecommendCache(); - co_await onFilterChanged(); - - if (!guard) - co_return; - - - if (m_refreshAnimation->state() == QAbstractAnimation::Running) { - m_refreshAnimation->setLoopCount(m_refreshAnimation->currentLoop() + 1); - - } else { - m_refreshBtn->setEnabled(true); - } + m_viewModel->removeItem(itemId); } diff --git a/src/qEmbyApp/views/user/categoryview.h b/src/qEmbyApp/views/user/categoryview.h index 6ebb844..9bbabe4 100644 --- a/src/qEmbyApp/views/user/categoryview.h +++ b/src/qEmbyApp/views/user/categoryview.h @@ -1,92 +1,33 @@ #ifndef CATEGORYVIEW_H #define CATEGORYVIEW_H -#include "../baseview.h" -#include -#include -#include +#include "../baseview.h" + #include -class MediaGridWidget; -class ElidedLabel; -class ModernSortButton; -class QPushButton; -class QLabel; -class QHBoxLayout; +class CategoryViewModel; +class QuickPageHost; -class QPropertyAnimation; +class CategoryView final : public BaseView { + Q_OBJECT -class CategoryView : public BaseView { - Q_OBJECT public: - explicit CategoryView(QEmbyCore* core, QWidget *parent = nullptr); - - - QCoro::Task loadCategory(const QString& categoryType, const QString& title); + explicit CategoryView(QEmbyCore *core, QWidget *parent = nullptr); + + QCoro::Task loadCategory(const QString &categoryType, + const QString &title); + void prepareForStackLeave() override; + +public slots: + void scrollToTop() override; protected: - - void onMediaItemUpdated(const MediaItem& item) override; - - - void onMediaItemRemoved(const QString& itemId) override; - -private slots: - - QCoro::Task onFilterChanged(); + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; private: - struct DashboardCategoryQuery { - QString category; - QString sortBy; - QString sortOrder; - int requestLimit = 0; - int firstPageSize = 100; - int pageSize = 300; - }; - - struct DashboardCategoryPage { - QList items; - QString fingerprint; - int rawItemCount = 0; - int totalRecordCount = 0; - bool hasTotalRecordCount = false; - }; - - void setupTopBar(QHBoxLayout* headerLayout); - bool isCastStyleCategory(const QString& categoryType) const; - bool isProgressiveDashboardCategory(const QString& categoryType) const; - QString currentViewPreferenceCategoryId() const; - void applyViewMode(bool isTile); - void saveViewPreference(); - void restoreViewPreference(); - int dashboardCategoryRequestLimit(const QString& categoryType) const; - DashboardCategoryQuery buildDashboardCategoryQuery(const QString& sortBy, - const QString& sortOrder) const; - QCoro::Task loadDashboardCategoryProgressively(DashboardCategoryQuery query); - QCoro::Task fetchDashboardCategoryPage(DashboardCategoryQuery query, - int startIndex, - int limit); - void appendUniqueLoadedItems(const QList& items); - void setLoadedItems(const QList& items); - - - QCoro::Task refreshData(); - - QString m_currentCategory; - - ElidedLabel* m_titleLabel; - ModernSortButton* m_sortButton; - QPushButton* m_viewSwitchBtn; - QPushButton* m_refreshBtn; - QLabel* m_statsLabel; - - MediaGridWidget* m_mediaGrid; - QList m_loadedItems; - int m_requestGeneration = 0; - - - QPropertyAnimation* m_refreshAnimation = nullptr; + QuickPageHost *m_quickHost = nullptr; + CategoryViewModel *m_viewModel = nullptr; }; -#endif +#endif // CATEGORYVIEW_H diff --git a/src/qEmbyApp/views/user/categoryviewmodel.cpp b/src/qEmbyApp/views/user/categoryviewmodel.cpp new file mode 100644 index 0000000..8156ae5 --- /dev/null +++ b/src/qEmbyApp/views/user/categoryviewmodel.cpp @@ -0,0 +1,600 @@ +#include "categoryviewmodel.h" + +#include "../../utils/dashboardrequestlimitutils.h" +#include "../../utils/mediaitemutils.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace { + +constexpr int kFirstPageSize = 100; +constexpr int kBackgroundPageSize = 300; + +QString imageSource(const MediaItem &item, bool thumb) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + const QString tag = best.first; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + if (item.id.isEmpty()) + return {}; + + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(tag) && !item.images.parentImageItemId.isEmpty()) + imageId = item.images.parentImageItemId; + + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, tag); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) + parts.append(item.seriesName); + if (item.productionYear > 0) + parts.append(QString::number(item.productionYear)); + if (parts.isEmpty()) + parts.append(item.type); + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.mediaType == QLatin1String("Video") || + item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series"); +} + +QString pageFingerprint(const QList &items) { + QString result; + for (const MediaItem &item : items) { + if (!item.id.trimmed().isEmpty()) { + result += item.id; + result += QLatin1Char('|'); + } + } + return result; +} + +} // namespace + +CategoryMediaModel::CategoryMediaModel(QObject *parent) + : QAbstractListModel(parent) {} + +int CategoryMediaModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant CategoryMediaModel::data(const QModelIndex &index, int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + + const MediaUserDataInfo &displayUserData = + item->hasResumeContext ? item->resumeUserData : item->userData; + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return subtitleFor(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item, false); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return isPlayable(*item); + case PlayedRole: + return displayUserData.played; + case ProgressRole: + return displayUserData.playedPercentage; + default: + return {}; + } +} + +QHash CategoryMediaModel::roleNames() const { + return {{ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + {PlayedRole, "played"}, + {ProgressRole, "progress"}}; +} + +const MediaItem *CategoryMediaModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +void CategoryMediaModel::clear() { setItems({}); } + +void CategoryMediaModel::setItems(QList items) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); +} + +void CategoryMediaModel::appendUnique(const QList &items) { + QSet ids; + for (const MediaItem &item : std::as_const(m_items)) + ids.insert(item.id); + + QList additions; + for (const MediaItem &item : items) { + if (!item.id.isEmpty() && ids.contains(item.id)) + continue; + ids.insert(item.id); + additions.append(item); + } + if (additions.isEmpty()) + return; + + const int first = m_items.size(); + beginInsertRows({}, first, first + additions.size() - 1); + m_items.append(additions); + endInsertRows(); +} + +void CategoryMediaModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + m_items[row] = item; + emit dataChanged(index(row), index(row)); + return; + } +} + +void CategoryMediaModel::prependOrUpdateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + m_items[row] = item; + emit dataChanged(index(row), index(row)); + return; + } + beginInsertRows({}, 0, 0); + m_items.prepend(item); + endInsertRows(); +} + +void CategoryMediaModel::removeItem(const QString &itemId) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != itemId) + continue; + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + return; + } +} + +CategoryViewModel::CategoryViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_items(this), m_statsText(tr("0 Items")) { +} + +bool CategoryViewModel::castView() const { + return m_category == QLatin1String("Favorite_Person") || + m_category == QLatin1String("Person"); +} + +bool CategoryViewModel::refreshVisible() const { + return m_category == QLatin1String("recommended"); +} + +void CategoryViewModel::setTileView(bool tileView) { + if (castView() || m_tileView == tileView) + return; + m_tileView = tileView; + saveViewPreference(); + emit viewChanged(); +} + +void CategoryViewModel::setSortIndex(int sortIndex) { + if (sortIndex < 0 || sortIndex > 4 || m_sortIndex == sortIndex) + return; + m_sortIndex = sortIndex; + emit sortChanged(); + launchReload(); +} + +void CategoryViewModel::setDescending(bool descending) { + if (m_descending == descending) + return; + m_descending = descending; + emit sortChanged(); + launchReload(); +} + +void CategoryViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_generation; + if (m_loading || m_loadingMore) + m_reloadOnActivate = true; + m_loading = false; + m_loadingMore = false; + emit stateChanged(); + } else if (m_reloadOnActivate) { + m_reloadOnActivate = false; + launchReload(); + } +} + +QCoro::Task CategoryViewModel::loadCategory(QString categoryType, + QString title) { + m_category = std::move(categoryType); + m_title = std::move(title); + configureCategory(); + restoreViewPreference(); + emit stateChanged(); + emit sortChanged(); + emit viewChanged(); + co_await reload(); +} + +void CategoryViewModel::configureCategory() { + m_descending = true; + m_sortEnabled = true; + if (m_category == QLatin1String("latest")) { + m_sortIndex = 0; + m_sortEnabled = false; + } else if (m_category == QLatin1String("resume")) { + m_sortIndex = 1; + m_sortEnabled = false; + } else if (m_category == QLatin1String("played") || + m_category == QLatin1String("Favorite_Movie") || + m_category == QLatin1String("Movie")) { + m_sortIndex = 1; + } else if (m_category == QLatin1String("recommended")) { + m_sortIndex = 0; + m_sortEnabled = false; + } else { + m_sortIndex = 0; + } +} + +void CategoryViewModel::restoreViewPreference() { + if (castView()) { + m_tileView = false; + return; + } + auto *store = ConfigStore::instance(); + const QString fallback = store->get(ConfigKeys::DefaultLibraryView, + QStringLiteral("poster")); + QString viewMode = fallback; + if (m_core && m_core->serverManager()) { + const QString serverId = m_core->serverManager()->activeProfile().id; + if (!serverId.isEmpty() && !m_category.trimmed().isEmpty()) { + const QString categoryId = QStringLiteral("category_%1").arg(m_category); + viewMode = store->get( + ConfigKeys::forCategory(serverId, categoryId, + ConfigKeys::CategoryViewMode), + fallback); + } + } + m_tileView = viewMode == QLatin1String("tile"); +} + +void CategoryViewModel::saveViewPreference() { + if (!m_core || !m_core->serverManager() || m_category.trimmed().isEmpty()) + return; + const QString serverId = m_core->serverManager()->activeProfile().id; + if (serverId.isEmpty()) + return; + const QString categoryId = QStringLiteral("category_%1").arg(m_category); + ConfigStore::instance()->set( + ConfigKeys::forCategory(serverId, categoryId, + ConfigKeys::CategoryViewMode), + m_tileView ? QStringLiteral("tile") : QStringLiteral("poster")); +} + +bool CategoryViewModel::isProgressiveCategory() const { + return m_category == QLatin1String("resume") || + m_category == QLatin1String("latest") || + m_category == QLatin1String("played"); +} + +int CategoryViewModel::requestLimit() const { + const QString serverId = m_core && m_core->serverManager() + ? m_core->serverManager()->activeProfile().id + : QString(); + if (m_category == QLatin1String("resume")) + return DashboardRequestLimitUtils::configuredRequestLimit( + serverId, ConfigKeys::ContinueWatchingRequestLimit, 0); + if (m_category == QLatin1String("latest")) + return DashboardRequestLimitUtils::configuredRequestLimit( + serverId, ConfigKeys::LatestMediaRequestLimit, 1000); + if (m_category == QLatin1String("recommended")) + return DashboardRequestLimitUtils::configuredRequestLimit( + serverId, ConfigKeys::RecommendedRequestLimit, 1000); + if (m_category == QLatin1String("played")) + return DashboardRequestLimitUtils::configuredRequestLimit( + serverId, ConfigKeys::CompletedWatchingRequestLimit, 0); + return 0; +} + +QString CategoryViewModel::sortBy() const { + static const QStringList values = { + QStringLiteral("DateCreated"), QStringLiteral("DatePlayed"), + QStringLiteral("SortName"), QStringLiteral("Runtime"), + QStringLiteral("PremiereDate")}; + return values.value(m_sortIndex, QStringLiteral("SortName")); +} + +void CategoryViewModel::launchReload(bool clearRecommendCache) { + if (!m_active) { + m_reloadOnActivate = true; + return; + } + QCoro::connect(reload(clearRecommendCache), this, []() {}); +} + +QCoro::Task CategoryViewModel::reload(bool clearRecommendCache) { + if (!m_active) { + m_reloadOnActivate = true; + co_return; + } + if (!m_core || !m_core->mediaService() || m_category.isEmpty()) + co_return; + + const quint64 generation = ++m_generation; + m_reloadOnActivate = false; + m_loading = true; + m_loadingMore = false; + m_errorText.clear(); + m_statsText = tr("Loading..."); + m_items.clear(); + emit stateChanged(); + + auto *service = m_core->mediaService(); + if (clearRecommendCache) + service->clearRecommendCache(); + + if (isProgressiveCategory()) { + co_await loadProgressively(generation); + co_return; + } + + QPointer guard(this); + try { + const QString order = m_descending ? QStringLiteral("Descending") + : QStringLiteral("Ascending"); + QList result; + if (m_category == QLatin1String("recommended")) { + result = co_await service->getRecommendedMovies( + requestLimit(), QStringLiteral("Random"), + QStringLiteral("Ascending")); + } else if (m_category == QLatin1String("Favorite_Movie") || + m_category == QLatin1String("Movie")) { + result = co_await service->getFavoriteMovies(0, sortBy(), order); + } else if (m_category == QLatin1String("Favorite_BoxSet") || + m_category == QLatin1String("BoxSet")) { + result = co_await service->getFavoriteCollections(0, sortBy(), order); + } else if (m_category == QLatin1String("Favorite_Playlist") || + m_category == QLatin1String("Playlist")) { + result = co_await service->getFavoritePlaylists(0, sortBy(), order); + } else if (m_category == QLatin1String("Favorite_Folder") || + m_category == QLatin1String("Folder")) { + result = co_await service->getFavoriteFolders(0, sortBy(), order); + } else if (m_category == QLatin1String("Favorite_Person") || + m_category == QLatin1String("Person")) { + result = co_await service->getFavoritePeople(0, sortBy(), order); + } + + if (!guard || !m_active || generation != m_generation) + co_return; + m_items.setItems(std::move(result)); + m_loading = false; + updateStats(); + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + qWarning() << "[CategoryViewModel] load failed:" << error.what(); + m_loading = false; + m_errorText = tr("Unable to load items"); + m_statsText = tr("Error Loading Items"); + emit stateChanged(); + } +} + +QCoro::Task +CategoryViewModel::fetchPage(int startIndex, int limit, quint64 generation) { + PageResult result; + if (!m_core || !m_core->mediaService()) + co_return result; + + QPointer guard(this); + auto *service = m_core->mediaService(); + const QString order = + m_descending ? QStringLiteral("Descending") : QStringLiteral("Ascending"); + MediaQueryPage page; + if (m_category == QLatin1String("resume")) { + page = co_await service->getResumeItemsPage(sortBy(), order, startIndex, + limit); + if (!guard || generation != guard->m_generation || !guard->m_active) + co_return result; + + QSet seenSeriesIds; + for (MediaItem item : page.items) { + if (item.type != QLatin1String("Episode") || item.seriesId.isEmpty()) { + result.items.append(MediaItemUtils::withResumeContext(item, item)); + continue; + } + if (seenSeriesIds.contains(item.seriesId)) + continue; + seenSeriesIds.insert(item.seriesId); + try { + MediaItem series = co_await service->getItemDetail(item.seriesId); + if (!guard || generation != guard->m_generation || !guard->m_active) + co_return PageResult{}; + result.items.append(MediaItemUtils::withResumeContext(series, item)); + } catch (const std::exception &error) { + if (!guard || generation != guard->m_generation || !guard->m_active) + co_return PageResult{}; + qWarning() << "[CategoryViewModel] resume series failed" + << item.seriesId << error.what(); + } + } + } else if (m_category == QLatin1String("latest")) { + page = co_await service->getLatestItemsPage(sortBy(), order, startIndex, + limit); + result.items = page.items; + } else if (m_category == QLatin1String("played")) { + page = co_await service->getPlayedItemsPage(sortBy(), order, startIndex, + limit); + result.items = page.items; + } + + if (!guard || generation != guard->m_generation || !guard->m_active) + co_return PageResult{}; + result.rawCount = page.items.size(); + result.fingerprint = pageFingerprint(page.items); + result.totalCount = page.totalRecordCount; + result.hasTotal = page.hasTotalRecordCount; + co_return result; +} + +QCoro::Task CategoryViewModel::loadProgressively(quint64 generation) { + QPointer guard(this); + QSet fingerprints; + int rawLoaded = 0; + bool first = true; + const int maximum = requestLimit(); + + try { + while (guard && m_active && generation == m_generation) { + int pageLimit = first ? kFirstPageSize : kBackgroundPageSize; + if (maximum > 0) { + const int remaining = maximum - rawLoaded; + if (remaining <= 0) + break; + pageLimit = qMin(pageLimit, remaining); + } + + PageResult page = co_await fetchPage(rawLoaded, pageLimit, generation); + if (!guard || !m_active || generation != m_generation) + co_return; + if (page.rawCount <= 0) + break; + if (!page.fingerprint.isEmpty() && + fingerprints.contains(page.fingerprint)) { + qWarning() << "[CategoryViewModel] repeated progressive page" + << m_category << rawLoaded; + break; + } + fingerprints.insert(page.fingerprint); + rawLoaded += page.rawCount; + + if (first) { + m_items.setItems(std::move(page.items)); + first = false; + m_loading = false; + m_loadingMore = true; + } else { + m_items.appendUnique(page.items); + } + updateStats(); + + if ((maximum > 0 && rawLoaded >= maximum) || + (page.hasTotal && rawLoaded >= page.totalCount) || + page.rawCount < pageLimit) + break; + } + + if (!guard || !m_active || generation != m_generation) + co_return; + m_loading = false; + m_loadingMore = false; + updateStats(); + } catch (const std::exception &error) { + if (!guard || !m_active || generation != m_generation) + co_return; + qWarning() << "[CategoryViewModel] progressive load failed:" + << error.what(); + m_loading = false; + m_loadingMore = false; + if (m_items.rowCount() == 0) { + m_errorText = tr("Unable to load items"); + m_statsText = tr("Error Loading Items"); + } + emit stateChanged(); + } +} + +void CategoryViewModel::updateStats() { + m_statsText = tr("%1 Items").arg(m_items.rowCount()); + emit stateChanged(); +} + +void CategoryViewModel::updateItem(const MediaItem &item) { + if (m_category == QLatin1String("resume")) { + const bool bareSeries = + item.type == QLatin1String("Series") && !item.hasResumeContext; + if ((!bareSeries || item.userData.played) && + !MediaItemUtils::canRemoveFromResume(item)) { + m_items.removeItem(item.id); + updateStats(); + return; + } + } + if (m_category == QLatin1String("played")) { + if (MediaItemUtils::isCompletedWatchingItem(item)) + m_items.prependOrUpdateItem(item); + else + m_items.removeItem(item.id); + updateStats(); + return; + } + m_items.updateItem(item); +} + +void CategoryViewModel::removeItem(const QString &itemId) { + m_items.removeItem(itemId); + updateStats(); +} + +void CategoryViewModel::activate(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit itemActivated(*item); +} + +void CategoryViewModel::requestPlay(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit playRequested(*item); +} + +void CategoryViewModel::requestFavorite(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit favoriteRequested(*item); +} + +void CategoryViewModel::requestMore(int row) { + if (const MediaItem *item = m_items.itemAt(row)) + emit moreRequested(*item); +} + +void CategoryViewModel::retry() { launchReload(); } + +void CategoryViewModel::refresh() { launchReload(true); } diff --git a/src/qEmbyApp/views/user/categoryviewmodel.h b/src/qEmbyApp/views/user/categoryviewmodel.h new file mode 100644 index 0000000..4f53ad8 --- /dev/null +++ b/src/qEmbyApp/views/user/categoryviewmodel.h @@ -0,0 +1,145 @@ +#ifndef CATEGORYVIEWMODEL_H +#define CATEGORYVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class CategoryMediaModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + PlayedRole, + ProgressRole, + }; + + explicit CategoryMediaModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + void clear(); + void setItems(QList items); + void appendUnique(const QList &items); + void updateItem(const MediaItem &item); + void prependOrUpdateItem(const MediaItem &item); + void removeItem(const QString &itemId); + +private: + QList m_items; +}; + +class CategoryViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY stateChanged) + Q_PROPERTY(QString statsText READ statsText NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool loadingMore READ loadingMore NOTIFY stateChanged) + Q_PROPERTY(bool tileView READ tileView WRITE setTileView NOTIFY viewChanged) + Q_PROPERTY(bool castView READ castView NOTIFY viewChanged) + Q_PROPERTY(bool viewSwitchVisible READ viewSwitchVisible NOTIFY viewChanged) + Q_PROPERTY(bool refreshVisible READ refreshVisible NOTIFY stateChanged) + Q_PROPERTY(bool sortEnabled READ sortEnabled NOTIFY sortChanged) + Q_PROPERTY(int sortIndex READ sortIndex WRITE setSortIndex NOTIFY sortChanged) + Q_PROPERTY( + bool descending READ descending WRITE setDescending NOTIFY sortChanged) + Q_PROPERTY(QAbstractItemModel *itemsModel READ itemsModel CONSTANT) + +public: + explicit CategoryViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QString title() const { return m_title; } + QString statsText() const { return m_statsText; } + QString errorText() const { return m_errorText; } + bool loading() const { return m_loading; } + bool loadingMore() const { return m_loadingMore; } + bool tileView() const { return m_tileView; } + bool castView() const; + bool viewSwitchVisible() const { return !castView(); } + bool refreshVisible() const; + bool sortEnabled() const { return m_sortEnabled; } + int sortIndex() const { return m_sortIndex; } + bool descending() const { return m_descending; } + QAbstractItemModel *itemsModel() { return &m_items; } + + void setTileView(bool tileView); + void setSortIndex(int sortIndex); + void setDescending(bool descending); + void setActive(bool active); + QCoro::Task loadCategory(QString categoryType, QString title); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + + Q_INVOKABLE void activate(int row); + Q_INVOKABLE void requestPlay(int row); + Q_INVOKABLE void requestFavorite(int row); + Q_INVOKABLE void requestMore(int row); + Q_INVOKABLE void retry(); + Q_INVOKABLE void refresh(); + +signals: + void stateChanged(); + void viewChanged(); + void sortChanged(); + void itemActivated(const MediaItem &item); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item); + +private: + struct PageResult { + QList items; + QString fingerprint; + int rawCount = 0; + int totalCount = 0; + bool hasTotal = false; + }; + + bool isProgressiveCategory() const; + int requestLimit() const; + QString sortBy() const; + void configureCategory(); + void restoreViewPreference(); + void saveViewPreference(); + void launchReload(bool clearRecommendCache = false); + QCoro::Task reload(bool clearRecommendCache = false); + QCoro::Task loadProgressively(quint64 generation); + QCoro::Task fetchPage(int startIndex, int limit, + quint64 generation); + void updateStats(); + + QPointer m_core; + CategoryMediaModel m_items; + QString m_category; + QString m_title; + QString m_statsText; + QString m_errorText; + bool m_loading = false; + bool m_loadingMore = false; + bool m_tileView = false; + bool m_sortEnabled = true; + bool m_descending = true; + bool m_active = true; + bool m_reloadOnActivate = false; + int m_sortIndex = 0; + quint64 m_generation = 0; +}; + +#endif // CATEGORYVIEWMODEL_H diff --git a/src/qEmbyApp/views/user/dashboardview.cpp b/src/qEmbyApp/views/user/dashboardview.cpp index 6bcfea4..ec1f56f 100644 --- a/src/qEmbyApp/views/user/dashboardview.cpp +++ b/src/qEmbyApp/views/user/dashboardview.cpp @@ -1,1534 +1,249 @@ #include "dashboardview.h" -#include "../../tokens/AnimationTokens.h" +#include "dashboardviewmodel.h" -#include "../../components/horizontallistviewgallery.h" #include "../../components/libraryactionmenu.h" #include "../../components/mediaimageeditdialog.h" #include "../../components/moderntoast.h" -#include "../../components/mediasectionwidget.h" -#include "../../utils/dashboardrequestlimitutils.h" -#include "../../utils/dashboardsectionorderutils.h" -#include "../../utils/mediaitemutils.h" -#include "../../utils/smoothscrollcontroller.h" -#include "../../utils/textwraputils.h" -#include "../media/mediacarddelegate.h" -#include "../media/medialistmodel.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" + #include #include #include #include -namespace { +#include +#include +#include +#include -bool isLibraryNavigationItem(const MediaItem& item) -{ - return item.type == "BoxSet" || item.type == "Playlist" || - item.type == "Folder"; +#include + +DashboardView::DashboardView(QEmbyCore *core, QWidget *parent) + : BaseView(core, parent) { + setObjectName(QStringLiteral("dashboard-view")); + setProperty("showGlobalSearch", true); + setAttribute(Qt::WA_StyledBackground, true); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_quickHost = new QuickPageHost(this); + m_viewModel = new UserDashboardViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("dashboardPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/DashboardPage.qml"))); + layout->addWidget(m_quickHost); + + connect(m_quickHost, &QuickPageHost::pageShown, this, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, this, + [this]() { m_viewModel->setActive(false); }); + + connect(m_viewModel, &UserDashboardViewModel::itemActivated, this, + [this](const MediaItem &item) { + emit navigateToDetail(item.id, item.name, item); + }); + connect(m_viewModel, &UserDashboardViewModel::libraryActivated, this, + &DashboardView::navigateToLibrary); + connect(m_viewModel, &UserDashboardViewModel::categoryRequested, this, + &DashboardView::navigateToCategory); + connect(m_viewModel, &UserDashboardViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_viewModel, &UserDashboardViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &UserDashboardViewModel::moreRequested, this, + [this](const MediaItem &item, const QPoint &localPos) { + handleMoreMenuRequested(item, m_quickHost->mapToGlobal(localPos)); + }); } -MediaCardDelegate::CardStyle dashboardGalleryStyle() -{ - const bool isTile = - ConfigStore::instance()->get(ConfigKeys::DefaultLibraryView, - "poster") == "tile"; - return isTile ? MediaCardDelegate::LibraryTile - : MediaCardDelegate::Poster; +void DashboardView::launchDashboardTask(QCoro::Task &&task) { + QCoro::connect(std::move(task), this, []() {}); } -int dashboardGalleryHeight() -{ - return dashboardGalleryStyle() == MediaCardDelegate::LibraryTile ? 230 : 300; +QCoro::Task DashboardView::loadDashboardData() { + co_await m_viewModel->loadDashboardData(); } -} +void DashboardView::scrollToTop() { m_viewModel->scrollToTop(); } -DashboardView::DashboardView(QEmbyCore* core, QWidget* parent) - : BaseView(core, parent) -{ - setObjectName("dashboard-view"); - - m_libraryDelegate = - new MediaCardDelegate(MediaCardDelegate::LibraryTile, this); - m_libraryDelegate->setShowMoreButtonForNonPlayableTiles(true); - - connect(m_libraryDelegate, &MediaCardDelegate::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_libraryDelegate, &MediaCardDelegate::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_libraryDelegate, &MediaCardDelegate::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - - setupUi(); - - connect(m_libraryListView, &QListView::clicked, this, - [this](const QModelIndex& index) { - const MediaItem item = m_libraryModel->getItem(index); - Q_EMIT navigateToLibrary(item.id, item.name); - }); - - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [this](const QString& key, const QVariant&) { - const QString sid = currentServerId(); - const QString customOrderEnabledKey = ConfigKeys::forServer( - sid, ConfigKeys::CustomHomeSectionOrderEnabled); - const QString homeSectionOrderKey = - ConfigKeys::forServer(sid, ConfigKeys::HomeSectionOrder); - - if (key == customOrderEnabledKey || key == homeSectionOrderKey) { - applyDashboardSectionOrder(); - if (isVisible()) { - launchDashboardTask(loadDashboardData()); - } - return; - } - - if (key == ConfigKeys::DefaultLibraryView || - key == ConfigKeys::forServer( - sid, ConfigKeys::ShowContinueWatching) || - key == - ConfigKeys::forServer(sid, ConfigKeys::ShowLatestAdded) || - key == - ConfigKeys::forServer(sid, ConfigKeys::ShowRecommended) || - key == ConfigKeys::forServer( - sid, ConfigKeys::ShowCompletedWatching) || - key == ConfigKeys::forServer( - sid, ConfigKeys::ContinueWatchingRequestLimit) || - key == ConfigKeys::forServer( - sid, ConfigKeys::LatestMediaRequestLimit) || - key == ConfigKeys::forServer( - sid, ConfigKeys::RecommendedRequestLimit) || - key == ConfigKeys::forServer( - sid, ConfigKeys::CompletedWatchingRequestLimit) || - key == ConfigKeys::forServer( - sid, ConfigKeys::ShowMediaLibraries) || - key == - ConfigKeys::forServer(sid, ConfigKeys::ShowEachLibrary)) { - if (isVisible()) { - launchDashboardTask(loadDashboardData()); - } - } - }); - - applyDashboardSectionOrder(); - m_dashboardContextKey = currentDashboardContextKey(); - - if (m_core && m_core->serverManager()) { - connect(m_core->serverManager(), &ServerManager::activeServerChanged, - this, [this](const ServerProfile&) { - const QString nextContextKey = currentDashboardContextKey(); - if (nextContextKey == m_dashboardContextKey) { - return; - } - - qDebug() << "[DashboardView] Dashboard context changed, " - "clearing stale home content" - << "| hasActiveSession=" << !nextContextKey.isEmpty() - << "| previousHadActiveSession=" - << !m_dashboardContextKey.isEmpty(); - - m_dashboardContextKey = nextContextKey; - ++m_loadGeneration; - clearDashboardState(true); - applyDashboardSectionOrder(); - - if (!nextContextKey.isEmpty() && isVisible()) { - launchDashboardTask(loadDashboardData()); - } - }); - } +void DashboardView::onMediaItemUpdated(const MediaItem &item) { + m_viewModel->updateItem(item); } -void DashboardView::launchDashboardTask(QCoro::Task&& task) -{ - QCoro::connect(std::move(task), this, []() {}); +void DashboardView::onMediaItemRemoved(const QString &itemId) { + m_viewModel->removeItem(itemId); } -void DashboardView::setupUi() -{ - auto* dashLayout = new QVBoxLayout(this); - dashLayout->setContentsMargins(0, 0, 0, 0); - - m_mainScrollArea = new QScrollArea(this); - m_mainScrollArea->setObjectName("dashboard-scroll"); - m_mainScrollArea->setWidgetResizable(true); - m_mainScrollArea->setFrameShape(QFrame::NoFrame); - m_mainScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - auto* container = new QWidget(m_mainScrollArea); - container->setObjectName("dashboard-container"); - - m_containerLayout = new QVBoxLayout(container); - m_containerLayout->setContentsMargins(20, 20, 20, 20); - m_containerLayout->setSpacing(0); - - const MediaCardDelegate::CardStyle galleryStyle = dashboardGalleryStyle(); - const int galleryHeight = dashboardGalleryHeight(); - - auto connectGallerySignals = [this](HorizontalListViewGallery* gallery) { - gallery->listView()->setProperty("isHorizontalListView", true); - gallery->listView()->viewport()->installEventFilter(this); - - connect(gallery, &HorizontalListViewGallery::itemClicked, this, - [this](const MediaItem& item) { - if (isLibraryNavigationItem(item)) { - Q_EMIT navigateToLibrary(item.id, item.name); - } else { - Q_EMIT navigateToDetail(item.id, item.name, item); - } - }); - connect(gallery, &HorizontalListViewGallery::playRequested, this, - &BaseView::handlePlayRequested); - connect(gallery, &HorizontalListViewGallery::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(gallery, &HorizontalListViewGallery::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - }; - - auto createGallerySection = - [this, container, galleryHeight, &connectGallerySignals]( - QWidget* header, HorizontalListViewGallery** outGallery, - MediaCardDelegate::CardStyle style) { - auto* section = new QWidget(container); - auto* layout = new QVBoxLayout(section); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - layout->addWidget(header); - - *outGallery = new HorizontalListViewGallery(m_core, section); - (*outGallery)->setFixedHeight(galleryHeight); - (*outGallery)->setCardStyle(style); - connectGallerySignals(*outGallery); - - layout->addWidget(*outGallery); - return section; - }; - - m_resumeHeader = createSectionHeader(tr("Continue Watching"), "resume"); - m_resumeSection = - createGallerySection(m_resumeHeader, &m_resumeGallery, galleryStyle); - - m_latestHeader = createSectionHeader(tr("Latest Media"), "latest"); - m_latestSection = - createGallerySection(m_latestHeader, &m_latestGallery, galleryStyle); - - m_recommendHeader = createSectionHeader(tr("Recommended"), "recommended"); - m_recommendSection = createGallerySection(m_recommendHeader, - &m_recommendGallery, galleryStyle); - - m_completedHeader = - createSectionHeader(tr("Completed Watching"), "played"); - m_completedSection = createGallerySection(m_completedHeader, - &m_completedGallery, galleryStyle); - - m_libraryGridSection = new QWidget(container); - auto* libraryGridLayout = new QVBoxLayout(m_libraryGridSection); - libraryGridLayout->setContentsMargins(0, 0, 0, 0); - libraryGridLayout->setSpacing(0); - - m_libraryTitle = new QLabel(tr("All Libraries"), m_libraryGridSection); - m_libraryTitle->setObjectName("dashboard-section-title"); - m_libraryListView = createGridListView(&m_libraryModel); - - libraryGridLayout->addWidget(m_libraryTitle); - libraryGridLayout->addWidget(m_libraryListView); - - m_librarySectionsContainer = new QWidget(container); - m_librarySectionsLayout = new QVBoxLayout(m_librarySectionsContainer); - m_librarySectionsLayout->setContentsMargins(0, 0, 0, 0); - m_librarySectionsLayout->setSpacing(0); - - m_containerLayout->addWidget(m_resumeSection); - m_containerLayout->addWidget(m_latestSection); - m_containerLayout->addWidget(m_recommendSection); - m_containerLayout->addWidget(m_completedSection); - m_containerLayout->addWidget(m_libraryGridSection); - m_containerLayout->addWidget(m_librarySectionsContainer); - m_containerLayout->addStretch(); - - m_resumeSection->hide(); - m_latestSection->hide(); - m_recommendSection->hide(); - m_completedSection->hide(); - m_libraryGridSection->hide(); - m_librarySectionsContainer->hide(); - - m_mainScrollArea->setWidget(container); - dashLayout->addWidget(m_mainScrollArea); - - m_vScrollController = - new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this); - m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast); - - m_mainScrollArea->viewport()->installEventFilter(this); -} - -void DashboardView::applyDashboardSectionOrder() -{ - if (!m_containerLayout) { - return; - } - - const QString sid = currentServerId(); - if (!sid.isEmpty()) { - auto* store = ConfigStore::instance(); - const QString customOrderEnabledKey = ConfigKeys::forServer( - sid, ConfigKeys::CustomHomeSectionOrderEnabled); - const QString homeSectionOrderKey = - ConfigKeys::forServer(sid, ConfigKeys::HomeSectionOrder); - - if (store->get(customOrderEnabledKey, false)) { - const QStringList rawOrder = - store->get(homeSectionOrderKey, {}); - if (!rawOrder.isEmpty()) { - const QStringList normalizedOrder = - DashboardSectionOrderUtils::normalizeSectionOrder(rawOrder); - if (normalizedOrder != rawOrder) { - store->set(homeSectionOrderKey, normalizedOrder); - } - } - } - } - - int insertIndex = 0; - for (const QString& sectionId : dashboardSectionOrder()) { - QWidget* widget = sectionWidgetForId(sectionId); - if (widget) { - m_containerLayout->insertWidget(insertIndex++, widget); - } - } -} - -QStringList DashboardView::dashboardSectionOrder() const -{ - const QString sid = currentServerId(); - if (sid.isEmpty()) { - return DashboardSectionOrderUtils::defaultSectionOrder(); - } - - auto* store = ConfigStore::instance(); - const QString customOrderEnabledKey = ConfigKeys::forServer( - sid, ConfigKeys::CustomHomeSectionOrderEnabled); - if (!store->get(customOrderEnabledKey, false)) { - return DashboardSectionOrderUtils::defaultSectionOrder(); - } - - const QString homeSectionOrderKey = - ConfigKeys::forServer(sid, ConfigKeys::HomeSectionOrder); - return DashboardSectionOrderUtils::normalizeSectionOrder( - store->get(homeSectionOrderKey, - DashboardSectionOrderUtils::defaultSectionOrder())); -} - -QString DashboardView::currentServerId() const -{ - if (!m_core || !m_core->serverManager()) { - return {}; - } - - const ServerProfile profile = m_core->serverManager()->activeProfile(); - return profile.isValid() ? profile.id : QString(); -} - -QString DashboardView::currentDashboardContextKey() const -{ - if (!m_core || !m_core->serverManager()) { - return {}; - } - - const ServerProfile profile = m_core->serverManager()->activeProfile(); - if (!profile.isValid() || profile.id.trimmed().isEmpty()) { - return {}; - } - - return profile.id + QLatin1Char('|') + profile.userId; -} - -QWidget* DashboardView::sectionWidgetForId(const QString& sectionId) const -{ - if (sectionId == QLatin1String( - DashboardSectionOrderUtils::ContinueWatchingSectionId)) { - return m_resumeSection; - } - if (sectionId == - QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) { - return m_latestSection; - } - if (sectionId == - QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) { - return m_recommendSection; - } - if (sectionId == QLatin1String( - DashboardSectionOrderUtils::CompletedWatchingSectionId)) { - return m_completedSection; - } - if (sectionId == - QLatin1String(DashboardSectionOrderUtils::AllLibrariesSectionId)) { - return m_libraryGridSection; - } - if (sectionId == QLatin1String( - DashboardSectionOrderUtils:: - EachLibrarySectionsSectionId)) { - return m_librarySectionsContainer; - } - - return nullptr; -} - -void DashboardView::clearLibraryGallerySections() -{ - if (!m_librarySectionsLayout) { - return; - } - - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (!section) { - continue; - } - - m_librarySectionsLayout->removeWidget(section); - section->deleteLater(); - } - - m_libraryGalleries.clear(); -} - -void DashboardView::clearDashboardState(bool resetScrollPositions) -{ - clearDashboardGallery(m_resumeGallery); - clearDashboardGallery(m_latestGallery); - clearDashboardGallery(m_recommendGallery); - clearDashboardGallery(m_completedGallery); - - if (m_resumeSection) { - m_resumeSection->setVisible(false); - } - if (m_latestSection) { - m_latestSection->setVisible(false); - } - if (m_recommendSection) { - m_recommendSection->setVisible(false); - } - if (m_completedSection) { - m_completedSection->setVisible(false); - } - - if (m_libraryModel) { - m_libraryModel->setItems(QList {}); - m_libraryModel->clearImageCache(); - } - resetListViewScrollPosition(m_libraryListView); - if (m_libraryGridSection) { - m_libraryGridSection->setVisible(false); - } - - clearLibraryGallerySections(); - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(false); - } - - adjustLibraryGridHeight(); - - if (resetScrollPositions) { - resetDashboardScrollPositions(); - } -} - -void DashboardView::resetDashboardScrollPositions() -{ - if (m_vScrollController) { - m_vScrollController->scrollTo(0, false); - } else if (m_mainScrollArea && m_mainScrollArea->verticalScrollBar()) { - m_mainScrollArea->verticalScrollBar()->setValue( - m_mainScrollArea->verticalScrollBar()->minimum()); - } - - resetDashboardGalleryScrollPosition(m_resumeGallery); - resetDashboardGalleryScrollPosition(m_latestGallery); - resetDashboardGalleryScrollPosition(m_recommendGallery); - resetDashboardGalleryScrollPosition(m_completedGallery); - resetListViewScrollPosition(m_libraryListView); - - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (section && section->gallery()) { - resetDashboardGalleryScrollPosition(section->gallery()); - } - } -} - -void DashboardView::clearDashboardGallery(HorizontalListViewGallery* gallery) -{ - if (!gallery) { - return; - } - - gallery->setLoading(false); - gallery->setItems(QList {}); - gallery->clearImageCache(); - resetListViewScrollPosition(gallery->listView()); -} - -void DashboardView::resetDashboardGalleryScrollPosition( - HorizontalListViewGallery* gallery) const -{ - if (!gallery) { - return; - } - - resetListViewScrollPosition(gallery->listView()); -} - -void DashboardView::resetListViewScrollPosition(QListView* listView) const -{ - if (!listView) { - return; - } - - if (QScrollBar* horizontalBar = listView->horizontalScrollBar()) { - horizontalBar->setValue(horizontalBar->minimum()); - } - if (QScrollBar* verticalBar = listView->verticalScrollBar()) { - verticalBar->setValue(verticalBar->minimum()); - } -} - -void DashboardView::scrollToTop() -{ - resetDashboardScrollPositions(); -} - -QWidget* DashboardView::createSectionHeader(const QString& title, - const QString& type) -{ - auto* w = new QWidget(this); - auto* l = new QHBoxLayout(w); - l->setContentsMargins(0, 0, 0, 0); - - auto* label = new QLabel(title, w); - label->setObjectName("dashboard-section-title"); - - auto* btn = new QPushButton(tr("See All >"), w); - btn->setObjectName("section-more-btn"); - btn->setCursor(Qt::PointingHandCursor); - - connect(btn, &QPushButton::clicked, this, - [this, type, title]() { Q_EMIT navigateToCategory(type, title); }); - - l->addWidget(label); - l->addStretch(); - l->addWidget(btn); - - return w; -} - -QListView* DashboardView::createGridListView(MediaListModel** outModel) -{ - auto* listView = new QListView(this); - listView->setSelectionMode(QAbstractItemView::NoSelection); - listView->setViewMode(QListView::IconMode); - listView->setFlow(QListView::LeftToRight); - listView->setWrapping(true); - listView->setSpacing(0); - listView->setMovement(QListView::Static); - listView->setResizeMode(QListView::Adjust); - listView->setUniformItemSizes(true); - listView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - listView->setFrameShape(QFrame::NoFrame); - listView->setMouseTracking(true); - listView->viewport()->setAttribute(Qt::WA_Hover); - listView->viewport()->installEventFilter(this); - listView->setStyleSheet( - "QListView { background: transparent; outline: none; }"); - - *outModel = new MediaListModel(500, m_core, this); - listView->setModel(*outModel); - listView->setItemDelegate(m_libraryDelegate); - return listView; -} - -bool DashboardView::eventFilter(QObject* obj, QEvent* event) -{ - if (m_libraryListView && obj == m_libraryListView->viewport() && - event->type() == QEvent::ToolTip) { - return TextWrapUtils::showWrappedMediaItemToolTip(m_libraryListView, - event); - } - - if (event->type() == QEvent::Wheel) { - const bool isHorizontalViewport = - obj->parent() && - obj->parent()->property("isHorizontalListView").toBool(); - const bool isMainViewport = (obj == m_mainScrollArea->viewport()); - - if (isHorizontalViewport || isMainViewport) { - auto* we = static_cast(event); - if (m_vScrollController) { - m_vScrollController->scrollByWheelEvent(we, Qt::Vertical); - } - return true; - } - } - - return QWidget::eventFilter(obj, event); -} - -void DashboardView::adjustLibraryGridHeight() -{ - if (!m_libraryListView || !m_libraryModel) { - return; - } - - const int count = m_libraryModel->rowCount(); - if (count == 0) { - m_libraryListView->setFixedHeight(0); - return; - } - - m_libraryListView->setSpacing(0); - - const int padding = 40; - const int scrollBarWidth = - qApp->style()->pixelMetric(QStyle::PM_ScrollBarExtent); - int availableWidth = width() - padding - scrollBarWidth; - if (availableWidth < 100) { - availableWidth = 100; - } - - const int defaultCellWidth = 196; - const int tolerance = 10; - - int cols = (availableWidth + tolerance) / defaultCellWidth; - if (cols < 1) { - cols = 1; - } - - const int cellWidth = availableWidth / cols; - const int remainder = availableWidth - (cols * cellWidth); - const int leftPad = remainder / 2; - const int rightPad = remainder - leftPad; - - m_libraryListView->setStyleSheet( - QString("QListView { background: transparent; outline: none; " - "padding-left: %1px; padding-right: %2px; }") - .arg(leftPad) - .arg(rightPad)); - - const int imgWidth = cellWidth - 16; - const int imgHeight = qRound(imgWidth * 9.0 / 16.0); - const int cellHeight = imgHeight + 16 + 26; - - m_libraryDelegate->setTileSize(QSize(cellWidth, cellHeight)); - m_libraryListView->doItemsLayout(); - - const int rows = (count + cols - 1) / cols; - m_libraryListView->setFixedHeight(rows * cellHeight); -} - -void DashboardView::resizeEvent(QResizeEvent* event) -{ - QWidget::resizeEvent(event); - adjustLibraryGridHeight(); -} - -void DashboardView::showEvent(QShowEvent* event) -{ - QWidget::showEvent(event); - launchDashboardTask(loadDashboardData()); -} - -bool DashboardView::isManageableDashboardLibraryCard(const MediaItem& item) const -{ - if (!m_libraryModel || item.id.trimmed().isEmpty()) { - return false; - } - - const QString normalizedCollectionType = item.collectionType.trimmed().toLower(); - if (normalizedCollectionType == "playlists" || - normalizedCollectionType == "boxsets") { - return false; - } - - const QString normalizedType = item.type.trimmed().toLower(); - if (normalizedType == "playlist" || normalizedType == "boxset") { - return false; - } - - for (int row = 0; row < m_libraryModel->rowCount(); ++row) { - const QModelIndex index = m_libraryModel->index(row, 0); - if (!index.isValid()) { - continue; - } - - if (m_libraryModel->getItem(index).id == item.id) { - return true; - } - } - +bool DashboardView::isManageableDashboardLibraryCard( + const MediaItem &item) const { + if (!m_viewModel || item.id.trimmed().isEmpty() || + !m_viewModel->isLibraryItem(item)) { return false; + } + const QString collectionType = item.collectionType.trimmed().toLower(); + const QString type = item.type.trimmed().toLower(); + return collectionType != QLatin1String("playlists") && + collectionType != QLatin1String("boxsets") && + type != QLatin1String("playlist") && type != QLatin1String("boxset"); } -CardContextMenuRequest DashboardView::showCardContextMenu( - const MediaItem& item, const QPoint& globalPos) -{ - const bool canShowLibraryMenu = - isManageableDashboardLibraryCard(item) && m_core && - m_core->serverManager() && - m_core->serverManager()->activeProfile().isValid() && - m_core->serverManager()->activeProfile().isAdmin; - if (!canShowLibraryMenu) { - return BaseView::showCardContextMenu(item, globalPos); - } +CardContextMenuRequest +DashboardView::showCardContextMenu(const MediaItem &item, + const QPoint &globalPos) { + const bool canShowLibraryMenu = + isManageableDashboardLibraryCard(item) && m_core && + m_core->serverManager() && + m_core->serverManager()->activeProfile().isValid() && + m_core->serverManager()->activeProfile().isAdmin; + if (!canShowLibraryMenu) { + return BaseView::showCardContextMenu(item, globalPos); + } - VirtualFolder folder; - folder.itemId = item.id; - folder.name = item.name; + VirtualFolder folder; + folder.itemId = item.id; + folder.name = item.name; + LibraryActionMenu menu(folder, false, this); + const LibraryContextMenuRequest libraryRequest = menu.execRequest(globalPos); - LibraryActionMenu menu(folder, false, this); - const LibraryContextMenuRequest libraryRequest = menu.execRequest(globalPos); - - CardContextMenuRequest request; - switch (libraryRequest.action) { - case LibraryContextMenuAction::None: - return request; - case LibraryContextMenuAction::EditImages: - request.action = CardContextMenuAction::EditImages; - return request; - case LibraryContextMenuAction::RefreshMetadata: - request.action = CardContextMenuAction::RefreshMetadata; - return request; - case LibraryContextMenuAction::ScanLibraryFiles: - request.action = CardContextMenuAction::ScanLibraryFiles; - return request; - } - - return request; + CardContextMenuRequest request; + switch (libraryRequest.action) { + case LibraryContextMenuAction::EditImages: + request.action = CardContextMenuAction::EditImages; + break; + case LibraryContextMenuAction::RefreshMetadata: + request.action = CardContextMenuAction::RefreshMetadata; + break; + case LibraryContextMenuAction::ScanLibraryFiles: + request.action = CardContextMenuAction::ScanLibraryFiles; + break; + case LibraryContextMenuAction::None: + break; + } + return request; } void DashboardView::dispatchCardContextMenuRequest( - const MediaItem& item, const CardContextMenuRequest& request) -{ - if (!request.isValid()) { - return; + const MediaItem &item, const CardContextMenuRequest &request) { + if (!request.isValid()) { + return; + } + if (isManageableDashboardLibraryCard(item)) { + switch (request.action) { + case CardContextMenuAction::EditImages: + openDashboardLibraryImageEditor(item); + return; + case CardContextMenuAction::RefreshMetadata: + launchDashboardTask( + executeDashboardLibraryRefresh(item, true, true, true)); + return; + case CardContextMenuAction::ScanLibraryFiles: + launchDashboardTask( + executeDashboardLibraryRefresh(item, false, false, false)); + return; + default: + break; } - - if (isManageableDashboardLibraryCard(item)) { - switch (request.action) { - case CardContextMenuAction::EditImages: - openDashboardLibraryImageEditor(item); - return; - case CardContextMenuAction::RefreshMetadata: - launchDashboardTask( - executeDashboardLibraryRefresh(item, true, true, true)); - return; - case CardContextMenuAction::ScanLibraryFiles: - launchDashboardTask( - executeDashboardLibraryRefresh(item, false, false, false)); - return; - default: - break; - } - } - - BaseView::dispatchCardContextMenuRequest(item, request); + } + BaseView::dispatchCardContextMenuRequest(item, request); } -void DashboardView::openDashboardLibraryImageEditor(const MediaItem& item) -{ - if (!m_core || !m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) { - ModernToast::showMessage( - BaseView::tr("This action requires administrator privileges")); - return; - } +void DashboardView::openDashboardLibraryImageEditor(const MediaItem &item) { + if (!m_core || !m_core->serverManager() || + !m_core->serverManager()->activeProfile().isValid() || + !m_core->serverManager()->activeProfile().isAdmin) { + ModernToast::showMessage( + tr("This action requires administrator privileges")); + return; + } - MediaImageEditTarget target; - target.itemId = item.id; - target.imageItemId = - item.images.primaryImageItemId.isEmpty() ? item.id - : item.images.primaryImageItemId; - target.displayName = item.name; - target.itemType = item.type; - target.mediaType = item.mediaType; - target.collectionType = item.collectionType; - target.isLibrary = true; + MediaImageEditTarget target; + target.itemId = item.id; + target.imageItemId = item.images.primaryImageItemId.isEmpty() + ? item.id + : item.images.primaryImageItemId; + target.displayName = item.name; + target.itemType = item.type; + target.mediaType = item.mediaType; + target.collectionType = item.collectionType; + target.isLibrary = true; - qDebug() << "[DashboardView] Opening dashboard library image editor" - << "| itemId=" << item.id - << "| imageItemId=" << target.imageItemId - << "| itemType=" << item.type - << "| collectionType=" << item.collectionType; - - MediaImageEditDialog dialog(m_core, target, this); - dialog.exec(); - if (!dialog.hasChanges()) { - return; - } - - if (m_core->mediaService()) { - m_core->mediaService()->clearUserViewsCache(); - } - if (m_libraryModel) { - m_libraryModel->clearImageCache(); - } - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (section && section->gallery()) { - section->gallery()->clearImageCache(); - } - } - - qDebug() << "[DashboardView] Library image editor applied changes" - << "| itemId=" << item.id - << "| imageItemId=" << target.imageItemId; - launchDashboardTask(loadDashboardData()); -} - -QCoro::Task DashboardView::loadDashboardData() -{ - const int generation = ++m_loadGeneration; - const QString contextKey = currentDashboardContextKey(); - - if (contextKey != m_dashboardContextKey) { - qDebug() << "[DashboardView] Dashboard context changed before reload, " - "resetting stale state" - << "| hasActiveSession=" << !contextKey.isEmpty() - << "| previousHadActiveSession=" - << !m_dashboardContextKey.isEmpty(); - m_dashboardContextKey = contextKey; - clearDashboardState(true); - } - - if (contextKey.isEmpty() || !m_core || !m_core->mediaService()) { - clearDashboardState(true); - co_return; - } - - auto* store = ConfigStore::instance(); - const QString sid = currentServerId(); - const bool showResume = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowContinueWatching), true); - const bool showLatest = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowLatestAdded), true); - const bool showRecommended = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowRecommended), true); - const bool showCompleted = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowCompletedWatching), false); - const bool showLibraries = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowMediaLibraries), true); - const bool showEachLibrary = store->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowEachLibrary), true); - - qDebug() << "[DashboardView] loadDashboardData" - << "| generation=" << generation << "| serverId=" << sid - << "| showResume=" << showResume - << "| showLatest=" << showLatest - << "| showRecommended=" << showRecommended - << "| showCompleted=" << showCompleted - << "| showLibraries=" << showLibraries - << "| showEachLibrary=" << showEachLibrary; - - applyDashboardSectionOrder(); - - const MediaCardDelegate::CardStyle style = dashboardGalleryStyle(); - const int galleryHeight = dashboardGalleryHeight(); - - if (m_resumeGallery) { - m_resumeGallery->setCardStyle(style); - m_resumeGallery->setFixedHeight(galleryHeight); - } - if (m_latestGallery) { - m_latestGallery->setCardStyle(style); - m_latestGallery->setFixedHeight(galleryHeight); - } - if (m_recommendGallery) { - m_recommendGallery->setCardStyle(style); - m_recommendGallery->setFixedHeight(galleryHeight); - } - if (m_completedGallery) { - m_completedGallery->setCardStyle(style); - m_completedGallery->setFixedHeight(galleryHeight); - } - - - - - - - const bool shimmerEnabled = - store->get(ConfigKeys::ShimmerAnimation, false); - - if (m_resumeSection) { - m_resumeSection->setVisible(showResume); - if (showResume && shimmerEnabled && m_resumeGallery) { - m_resumeGallery->setLoading(true); - } - } - if (m_latestSection) { - m_latestSection->setVisible(showLatest); - if (showLatest && shimmerEnabled && m_latestGallery) { - m_latestGallery->setLoading(true); - } - } - if (m_recommendSection) { - m_recommendSection->setVisible(showRecommended); - if (showRecommended && shimmerEnabled && m_recommendGallery) { - m_recommendGallery->setLoading(true); - } - } - if (m_completedSection) { - m_completedSection->setVisible(showCompleted); - if (showCompleted && shimmerEnabled && m_completedGallery) { - m_completedGallery->setLoading(true); - } - } - if (m_libraryGridSection) { - m_libraryGridSection->setVisible(showLibraries); - } - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(showEachLibrary); - } - - loadResumeSection(showResume, generation); - loadLatestSection(showLatest, generation); - loadRecommendedSection(showRecommended, generation); - loadCompletedSection(showCompleted, generation); - loadLibrarySections(showLibraries, showEachLibrary, generation); - co_return; + MediaImageEditDialog dialog(m_core, target, this); + dialog.exec(); + if (!dialog.hasChanges()) { + return; + } + if (m_core->mediaService()) { + m_core->mediaService()->clearUserViewsCache(); + } + m_viewModel->refresh(); } QCoro::Task DashboardView::executeDashboardLibraryRefresh( MediaItem item, bool replaceAllMetadata, bool replaceAllImages, - bool isMetadataRefresh) -{ - const QString itemId = item.id.trimmed(); - const QString itemName = - item.name.trimmed().isEmpty() ? BaseView::tr("this item") - : item.name.trimmed(); - if (itemId.isEmpty() || !m_core || !m_core->adminService() || - !m_core->mediaService()) { - co_return; + bool isMetadataRefresh) { + const QString itemId = item.id.trimmed(); + const QString itemName = + item.name.trimmed().isEmpty() ? tr("this item") : item.name.trimmed(); + if (itemId.isEmpty() || !m_core || !m_core->adminService() || + !m_core->mediaService()) { + co_return; + } + if (!m_core->serverManager() || + !m_core->serverManager()->activeProfile().isValid() || + !m_core->serverManager()->activeProfile().isAdmin) { + ModernToast::showMessage( + tr("This action requires administrator privileges")); + co_return; + } + + QPointer guard(this); + try { + co_await m_core->adminService()->refreshItemMetadata( + itemId, replaceAllMetadata, replaceAllImages); + if (!guard) { + co_return; } - - if (!m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) { - ModernToast::showMessage( - BaseView::tr("This action requires administrator privileges")); - co_return; + ModernToast::showMessage( + isMetadataRefresh + ? tr("Refreshing metadata for \"%1\"...").arg(itemName) + : tr("Scanning \"%1\"...").arg(itemName), + 2000); + } catch (const std::exception &error) { + if (guard) { + ModernToast::showMessage( + isMetadataRefresh + ? tr("Metadata refresh failed: %1") + .arg(QString::fromUtf8(error.what())) + : tr("Scan failed: %1").arg(QString::fromUtf8(error.what())), + 3000); } + co_return; + } - QPointer safeThis(this); - QPointer adminService(m_core->adminService()); - QPointer mediaService(m_core->mediaService()); - const char* actionName = - isMetadataRefresh ? "RefreshMetadata" : "ScanLibraryFiles"; - - try { - qDebug() << "[DashboardView] Starting dashboard library action" - << "| action=" << actionName - << "| itemId=" << itemId - << "| itemName=" << item.name - << "| replaceAllMetadata=" << replaceAllMetadata - << "| replaceAllImages=" << replaceAllImages; - co_await adminService->refreshItemMetadata(itemId, replaceAllMetadata, - replaceAllImages); - if (!safeThis) { - co_return; - } - - ModernToast::showMessage( - isMetadataRefresh - ? BaseView::tr("Refreshing metadata for \"%1\"...") - .arg(itemName) - : tr("Scanning \"%1\"...").arg(itemName), - 2000); - } catch (const std::exception& e) { - if (!safeThis) { - co_return; - } - - qWarning() << "[DashboardView] Dashboard library action failed" - << "| action=" << actionName - << "| itemId=" << itemId - << "| itemName=" << item.name - << "| error=" << e.what(); - ModernToast::showMessage( - isMetadataRefresh - ? BaseView::tr("Metadata refresh failed: %1") - .arg(QString::fromUtf8(e.what())) - : tr("Scan failed: %1").arg(QString::fromUtf8(e.what())), - 3000); - co_return; + try { + const MediaItem latest = + co_await m_core->mediaService()->getItemDetail(itemId); + if (guard) { + m_viewModel->updateItem(latest); } + } catch (const std::exception &error) { + qWarning() << "[DashboardView] failed to refresh library card" << itemId + << error.what(); + } - try { - if (!mediaService) { - co_return; - } - - const MediaItem latestItem = co_await mediaService->getItemDetail(itemId); - if (safeThis) { - safeThis->onMediaItemUpdated(latestItem); - } - } catch (const std::exception& e) { - qWarning() << "[DashboardView] Failed to refresh dashboard library card" - << "| action=" << actionName - << "| itemId=" << itemId - << "| error=" << e.what(); - } - - QTimer::singleShot(2000, this, [this, itemId]() { - if (!m_core || !m_core->mediaService()) { - return; - } - - qDebug() << "[DashboardView] Running delayed dashboard library reload" - << "| itemId=" << itemId; + if (guard) { + QTimer::singleShot(2000, this, [this]() { + if (m_core && m_core->mediaService()) { m_core->mediaService()->clearUserViewsCache(); - launchDashboardTask(loadDashboardData()); + m_viewModel->refresh(); + } }); -} - -QCoro::Task DashboardView::loadResumeSection(bool show, int generation) -{ - if (!show) { - if (m_resumeSection) { - m_resumeSection->setVisible(false); - } - co_return; - } - - QPointer guard(this); - auto* mediaService = m_core->mediaService(); - const int requestLimit = - DashboardRequestLimitUtils::homeSectionRequestLimit( - currentServerId(), ConfigKeys::ContinueWatchingRequestLimit, 0); - - try { - QList rawResumeItems = - co_await mediaService->getResumeItems(requestLimit); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - QList resumeItems; - QSet seenSeriesIds; - QStringList seriesIdsToFetch; - QList insertIndices; - QList resumeContextItems; - - for (const MediaItem& item : rawResumeItems) { - if (item.type == "Episode" && !item.seriesId.isEmpty()) { - if (seenSeriesIds.contains(item.seriesId)) { - continue; - } - - seenSeriesIds.insert(item.seriesId); - seriesIdsToFetch.append(item.seriesId); - insertIndices.append(resumeItems.size()); - resumeContextItems.append(item); - resumeItems.append(MediaItem {}); - } else { - resumeItems.append(MediaItemUtils::withResumeContext(item, item)); - } - } - - std::vector> detailTasks; - detailTasks.reserve(seriesIdsToFetch.size()); - for (const QString& seriesId : seriesIdsToFetch) { - detailTasks.push_back(mediaService->getItemDetail(seriesId)); - } - - for (int i = 0; i < static_cast(detailTasks.size()); ++i) { - try { - MediaItem seriesItem = co_await std::move(detailTasks[i]); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - resumeItems[insertIndices[i]] = - MediaItemUtils::withResumeContext(seriesItem, - resumeContextItems[i]); - } catch (...) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - } - } - - for (int i = resumeItems.size() - 1; i >= 0; --i) { - if (resumeItems[i].id.isEmpty()) { - resumeItems.removeAt(i); - } - } - - m_resumeGallery->setItems(resumeItems); - if (m_resumeSection) { - m_resumeSection->setVisible(!resumeItems.isEmpty()); - } - } catch (const std::exception& e) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "Dashboard failed to fetch resume items:" << e.what(); - if (m_resumeGallery) { - m_resumeGallery->setLoading(false); - } - if (m_resumeSection) { - m_resumeSection->setVisible(false); - } - } -} - -QCoro::Task DashboardView::loadLatestSection(bool show, int generation) -{ - if (!show) { - if (m_latestSection) { - m_latestSection->setVisible(false); - } - co_return; - } - - QPointer guard(this); - auto* mediaService = m_core->mediaService(); - const int requestLimit = - DashboardRequestLimitUtils::homeSectionRequestLimit( - currentServerId(), ConfigKeys::LatestMediaRequestLimit, 1000); - - try { - QList latestItems = - co_await mediaService->getLatestItems(requestLimit); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - m_latestGallery->setItems(latestItems); - if (m_latestSection) { - m_latestSection->setVisible(!latestItems.isEmpty()); - } - } catch (const std::exception& e) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "Dashboard failed to fetch latest items:" << e.what(); - if (m_latestGallery) { - m_latestGallery->setLoading(false); - } - if (m_latestSection) { - m_latestSection->setVisible(false); - } - } -} - -QCoro::Task DashboardView::loadRecommendedSection(bool show, - int generation) -{ - if (!show) { - if (m_recommendSection) { - m_recommendSection->setVisible(false); - } - co_return; - } - - QPointer guard(this); - auto* mediaService = m_core->mediaService(); - const int requestLimit = - DashboardRequestLimitUtils::homeSectionRequestLimit( - currentServerId(), ConfigKeys::RecommendedRequestLimit, 1000); - - try { - QList recommendedItems = - co_await mediaService->getRecommendedMovies(requestLimit); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - m_recommendGallery->setItems(recommendedItems); - if (m_recommendSection) { - m_recommendSection->setVisible(!recommendedItems.isEmpty()); - } - } catch (const std::exception& e) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "Dashboard failed to fetch recommended items:" << e.what(); - if (m_recommendGallery) { - m_recommendGallery->setLoading(false); - } - if (m_recommendSection) { - m_recommendSection->setVisible(false); - } - } -} - -QCoro::Task DashboardView::loadCompletedSection(bool show, - int generation) -{ - if (!show) { - if (m_completedSection) { - m_completedSection->setVisible(false); - } - co_return; - } - - QPointer guard(this); - auto* mediaService = m_core->mediaService(); - const int requestLimit = - DashboardRequestLimitUtils::homeSectionRequestLimit( - currentServerId(), ConfigKeys::CompletedWatchingRequestLimit, 0); - - try { - QList completedItems = - co_await mediaService->getPlayedItems(requestLimit); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - m_completedGallery->setItems(completedItems); - if (m_completedSection) { - m_completedSection->setVisible(!completedItems.isEmpty()); - } - } catch (const std::exception& e) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "Dashboard failed to fetch completed items:" << e.what(); - if (m_completedGallery) { - m_completedGallery->setLoading(false); - } - if (m_completedSection) { - m_completedSection->setVisible(false); - } - } -} - -QCoro::Task DashboardView::loadLibrarySections(bool showLibraries, - bool showEachLibrary, - int generation) -{ - if (!showLibraries && !showEachLibrary) { - if (m_libraryGridSection) { - m_libraryGridSection->setVisible(false); - } - if (m_libraryModel) { - m_libraryModel->setItems(QList {}); - } - clearLibraryGallerySections(); - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(false); - } - adjustLibraryGridHeight(); - co_return; - } - - QPointer guard(this); - auto* mediaService = m_core->mediaService(); - const QString sid = currentServerId(); - const int previousLibraryCount = - m_libraryModel ? m_libraryModel->rowCount() : 0; - const int previousEachLibrarySectionCount = m_libraryGalleries.size(); - bool hasReusableEachLibrarySections = - previousEachLibrarySectionCount > 0; - if (hasReusableEachLibrarySections) { - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (!section || section->property("serverId").toString() != sid) { - hasReusableEachLibrarySections = false; - break; - } - } - } - - try { - const QList userViews = co_await mediaService->getUserViews(); - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "[DashboardView] loadLibrarySections fetched" - << "| generation=" << generation - << "| showLibraries=" << showLibraries - << "| showEachLibrary=" << showEachLibrary - << "| previousLibraryCount=" << previousLibraryCount - << "| previousEachLibrarySectionCount=" - << previousEachLibrarySectionCount - << "| userViewCount=" << userViews.size(); - - if (!showLibraries) { - if (m_libraryGridSection) { - m_libraryGridSection->setVisible(false); - } - if (m_libraryModel) { - m_libraryModel->setItems(QList {}); - } - adjustLibraryGridHeight(); - } else { - if (!userViews.isEmpty() || previousLibraryCount == 0) { - m_libraryModel->setItems(userViews); - } else { - qDebug() - << "[DashboardView] keeping previous library grid items " - "because fetched views are unexpectedly empty"; - } - if (m_libraryGridSection) { - m_libraryGridSection->setVisible(!userViews.isEmpty() || - previousLibraryCount > 0); - } - QTimer::singleShot(0, this, &DashboardView::adjustLibraryGridHeight); - } - - if (!showEachLibrary) { - clearLibraryGallerySections(); - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(false); - } - co_return; - } - - if (userViews.isEmpty()) { - if (hasReusableEachLibrarySections) { - qDebug() - << "[DashboardView] keeping previous each-library sections " - "because fetched views are unexpectedly empty"; - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(true); - } - } else { - clearLibraryGallerySections(); - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(false); - } - } - co_return; - } - - const MediaCardDelegate::CardStyle libGalleryStyle = - dashboardGalleryStyle(); - const int libGalleryHeight = dashboardGalleryHeight(); - - bool canReuse = (m_libraryGalleries.size() == userViews.size()); - if (canReuse) { - for (int i = 0; i < userViews.size(); ++i) { - if (!m_libraryGalleries[i] || - m_libraryGalleries[i]->property("libraryId").toString() != - userViews[i].id || - m_libraryGalleries[i]->property("libraryName").toString() != - userViews[i].name) { - canReuse = false; - break; - } - } - } - - if (!canReuse) { - clearLibraryGallerySections(); - - for (const MediaItem& view : userViews) { - auto* section = new MediaSectionWidget(view.name, m_core, - m_librarySectionsContainer); - section->setProperty("serverId", sid); - section->setProperty("libraryId", view.id); - section->setProperty("libraryName", view.name); - - if (section->layout()) { - section->layout()->setContentsMargins(0, 20, 0, 0); - } - - auto* seeAllBtn = new QPushButton(tr("See All >"), section); - seeAllBtn->setObjectName("section-more-btn"); - seeAllBtn->setCursor(Qt::PointingHandCursor); - - const QString libraryId = view.id; - const QString libraryName = view.name; - connect(seeAllBtn, &QPushButton::clicked, this, - [this, libraryId, libraryName]() { - Q_EMIT navigateToLibrary(libraryId, libraryName); - }); - - QWidget* headerContainer = - section->findChild("section-header"); - if (headerContainer && headerContainer->layout()) { - headerContainer->layout()->addWidget(seeAllBtn); - } - - if (section->gallery() && section->gallery()->listView()) { - section->gallery()->listView()->setProperty( - "isHorizontalListView", true); - section->gallery()->listView()->viewport()->installEventFilter( - this); - } - - connect(section, &MediaSectionWidget::itemClicked, this, - [this](const MediaItem& item) { - if (isLibraryNavigationItem(item)) { - Q_EMIT navigateToLibrary(item.id, item.name); - } else { - Q_EMIT navigateToDetail(item.id, item.name, item); - } - }); - connect(section, &MediaSectionWidget::playRequested, this, - &BaseView::handlePlayRequested); - connect(section, &MediaSectionWidget::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(section, &MediaSectionWidget::moreMenuRequested, this, - &BaseView::handleMoreMenuRequested); - - m_librarySectionsLayout->addWidget(section); - m_libraryGalleries.append(section); - } - } - - for (int i = 0; i < userViews.size(); ++i) { - MediaSectionWidget* section = m_libraryGalleries.value(i, nullptr); - if (!section) { - continue; - } - - const MediaItem& view = userViews[i]; - section->setProperty("serverId", sid); - section->setProperty("libraryId", view.id); - section->setProperty("libraryName", view.name); - section->setTitle(view.name); - section->setCardStyle(libGalleryStyle); - section->setGalleryHeight(libGalleryHeight); - - const QString libraryId = view.id; - section->loadAsync( - [mediaService, libraryId]() -> QCoro::Task> { - co_return co_await mediaService->getLibraryItems( - libraryId, "DateCreated", "Descending", "", - "Movie,Series", 0, 20, true); - }); - } - - if (m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(true); - } - } catch (const std::exception& e) { - if (!guard || m_loadGeneration != generation) { - co_return; - } - - qDebug() << "[DashboardView] loadLibrarySections failed" - << "| generation=" << generation - << "| showLibraries=" << showLibraries - << "| showEachLibrary=" << showEachLibrary - << "| previousLibraryCount=" << previousLibraryCount - << "| previousEachLibrarySectionCount=" - << previousEachLibrarySectionCount - << "| error=" << e.what(); - - if (showLibraries && previousLibraryCount > 0 && m_libraryGridSection) { - m_libraryGridSection->setVisible(true); - QTimer::singleShot(0, this, &DashboardView::adjustLibraryGridHeight); - } - if (showEachLibrary && hasReusableEachLibrarySections && - m_librarySectionsContainer) { - m_librarySectionsContainer->setVisible(true); - } - } -} - -void DashboardView::onMediaItemUpdated(const MediaItem& item) -{ - if (m_resumeGallery) { - const bool canRemoveFromResume = - MediaItemUtils::canRemoveFromResume(item); - - if (item.hasResumeContext && !canRemoveFromResume) { - m_resumeGallery->removeItem(item.id); - } else { - m_resumeGallery->updateItem(item); - } - } - - if (m_latestGallery) { - m_latestGallery->updateItem(item); - } - if (m_recommendGallery) { - m_recommendGallery->updateItem(item); - } - if (m_completedGallery) { - const QString sid = currentServerId(); - const bool showCompleted = - !sid.isEmpty() && - ConfigStore::instance()->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowCompletedWatching), - false); - if (showCompleted && MediaItemUtils::isCompletedWatchingItem(item)) { - const int completedMaxItems = - DashboardRequestLimitUtils::homeSectionRequestLimit( - sid, ConfigKeys::CompletedWatchingRequestLimit, 0); - m_completedGallery->prependOrUpdateItem(item, completedMaxItems); - if (m_completedSection) { - m_completedSection->setVisible(true); - } - } else { - m_completedGallery->removeItem(item.id); - } - } - if (m_libraryModel) { - m_libraryModel->updateItem(item); - } - - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (section) { - section->updateItem(item); - } - } -} - -void DashboardView::onMediaItemRemoved(const QString& itemId) -{ - if (m_resumeGallery) { - m_resumeGallery->removeItem(itemId); - } - if (m_latestGallery) { - m_latestGallery->removeItem(itemId); - } - if (m_recommendGallery) { - m_recommendGallery->removeItem(itemId); - } - if (m_completedGallery) { - m_completedGallery->removeItem(itemId); - } - if (m_libraryModel) { - m_libraryModel->removeItem(itemId); - } - - for (MediaSectionWidget* section : std::as_const(m_libraryGalleries)) { - if (section && section->gallery()) { - section->gallery()->removeItem(itemId); - } - } - - if (m_recommendSection && m_recommendGallery) { - m_recommendSection->setVisible(m_recommendGallery->itemCount() > 0); - } - if (m_completedSection && m_completedGallery) { - m_completedSection->setVisible(m_completedGallery->itemCount() > 0); - } + } } diff --git a/src/qEmbyApp/views/user/dashboardview.h b/src/qEmbyApp/views/user/dashboardview.h index 8f02869..d74a212 100644 --- a/src/qEmbyApp/views/user/dashboardview.h +++ b/src/qEmbyApp/views/user/dashboardview.h @@ -2,128 +2,45 @@ #define DASHBOARDVIEW_H #include "../baseview.h" -#include + #include -class QListView; -class MediaListModel; -class MediaCardDelegate; -class QLabel; -class QWidget; -class QVBoxLayout; -class QScrollArea; -class HorizontalListViewGallery; -class MediaSectionWidget; -class SmoothScrollController; +class QuickPageHost; +class UserDashboardViewModel; + +class DashboardView : public BaseView { + Q_OBJECT -class DashboardView : public BaseView -{ - Q_OBJECT public: - explicit DashboardView(QEmbyCore* core, QWidget *parent = nullptr); - - - QCoro::Task loadDashboardData(); + explicit DashboardView(QEmbyCore *core, QWidget *parent = nullptr); -public Q_SLOTS: - void scrollToTop() override; + QCoro::Task loadDashboardData(); -Q_SIGNALS: - - void navigateToLibrary(const QString& libraryId, const QString& libraryName); +public slots: + void scrollToTop() override; + +signals: + void navigateToLibrary(const QString &libraryId, const QString &libraryName); protected: - - void onMediaItemUpdated(const MediaItem& item) override; - - - void onMediaItemRemoved(const QString& itemId) override; - CardContextMenuRequest showCardContextMenu(const MediaItem& item, - const QPoint& globalPos) override; - void dispatchCardContextMenuRequest( - const MediaItem& item, const CardContextMenuRequest& request) override; - - void showEvent(QShowEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - - bool eventFilter(QObject* obj, QEvent* event) override; + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; + CardContextMenuRequest showCardContextMenu(const MediaItem &item, + const QPoint &globalPos) override; + void dispatchCardContextMenuRequest( + const MediaItem &item, const CardContextMenuRequest &request) override; private: - void launchDashboardTask(QCoro::Task&& task); - void setupUi(); - void applyDashboardSectionOrder(); - QStringList dashboardSectionOrder() const; - QString currentServerId() const; - QString currentDashboardContextKey() const; - QWidget* sectionWidgetForId(const QString& sectionId) const; - void clearLibraryGallerySections(); - void clearDashboardState(bool resetScrollPositions); - void resetDashboardScrollPositions(); - void clearDashboardGallery(HorizontalListViewGallery* gallery); - void resetDashboardGalleryScrollPosition( - HorizontalListViewGallery* gallery) const; - void resetListViewScrollPosition(QListView* listView) const; - bool isManageableDashboardLibraryCard(const MediaItem& item) const; - void openDashboardLibraryImageEditor(const MediaItem& item); + void launchDashboardTask(QCoro::Task &&task); + bool isManageableDashboardLibraryCard(const MediaItem &item) const; + void openDashboardLibraryImageEditor(const MediaItem &item); + QCoro::Task executeDashboardLibraryRefresh(MediaItem item, + bool replaceAllMetadata, + bool replaceAllImages, + bool isMetadataRefresh); - - QWidget* createSectionHeader(const QString& title, const QString& type); - - QListView* createGridListView(MediaListModel** outModel); - void adjustLibraryGridHeight(); - - - QCoro::Task loadResumeSection(bool show, int generation); - QCoro::Task loadLatestSection(bool show, int generation); - QCoro::Task loadRecommendedSection(bool show, int generation); - QCoro::Task loadCompletedSection(bool show, int generation); - QCoro::Task loadLibrarySections(bool showLibraries, bool showEachLibrary, int generation); - QCoro::Task executeDashboardLibraryRefresh( - MediaItem item, bool replaceAllMetadata, bool replaceAllImages, - bool isMetadataRefresh); - - QScrollArea* m_mainScrollArea = nullptr; - - SmoothScrollController* m_vScrollController = nullptr; - - - QWidget* m_resumeSection = nullptr; - QWidget* m_resumeHeader = nullptr; - HorizontalListViewGallery* m_resumeGallery = nullptr; - - - QWidget* m_latestSection = nullptr; - QWidget* m_latestHeader = nullptr; - HorizontalListViewGallery* m_latestGallery = nullptr; - - - QWidget* m_recommendSection = nullptr; - QWidget* m_recommendHeader = nullptr; - HorizontalListViewGallery* m_recommendGallery = nullptr; - - - QWidget* m_completedSection = nullptr; - QWidget* m_completedHeader = nullptr; - HorizontalListViewGallery* m_completedGallery = nullptr; - - - QWidget* m_libraryGridSection = nullptr; - QWidget* m_librarySectionsContainer = nullptr; - QLabel* m_libraryTitle = nullptr; - QListView* m_libraryListView = nullptr; - MediaListModel* m_libraryModel = nullptr; - - - QList m_libraryGalleries; - QVBoxLayout* m_containerLayout = nullptr; - QVBoxLayout* m_librarySectionsLayout = nullptr; - - - MediaCardDelegate* m_libraryDelegate = nullptr; - - - int m_loadGeneration = 0; - QString m_dashboardContextKey; + QuickPageHost *m_quickHost = nullptr; + UserDashboardViewModel *m_viewModel = nullptr; }; -#endif +#endif // DASHBOARDVIEW_H diff --git a/src/qEmbyApp/views/user/dashboardviewmodel.cpp b/src/qEmbyApp/views/user/dashboardviewmodel.cpp new file mode 100644 index 0000000..3a96d57 --- /dev/null +++ b/src/qEmbyApp/views/user/dashboardviewmodel.cpp @@ -0,0 +1,998 @@ +#include "dashboardviewmodel.h" + +#include "../../utils/dashboardrequestlimitutils.h" +#include "../../utils/dashboardsectionorderutils.h" +#include "../../utils/mediaitemutils.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace { + +QString imageSource(const MediaItem &item, bool thumb) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + const QString tag = best.first; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + if (item.id.isEmpty()) { + return {}; + } + + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(tag) && + !item.images.parentImageItemId.isEmpty()) { + imageId = item.images.parentImageItemId; + } + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, tag); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) { + parts.append(item.seriesName); + } + if (item.productionYear > 0) { + parts.append(QString::number(item.productionYear)); + } + if (parts.isEmpty() && !item.collectionType.isEmpty()) { + parts.append(item.collectionType); + } + if (parts.isEmpty()) { + parts.append(item.type); + } + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.mediaType == QLatin1String("Video") || + item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series"); +} + +bool isLibraryNavigationItem(const MediaItem &item) { + return item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder"); +} + +double progressFor(const MediaItem &item) { + const MediaUserDataInfo &data = + item.hasResumeContext ? item.resumeUserData : item.userData; + if (data.playedPercentage > 0.0) { + return data.playedPercentage / 100.0; + } + if (item.runTimeTicks > 0 && data.playbackPositionTicks > 0) { + return qBound(0.0, + static_cast(data.playbackPositionTicks) / + static_cast(item.runTimeTicks), + 1.0); + } + return 0.0; +} + +QString sectionTitle(const QString §ionId) { + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::ContinueWatchingSectionId)) { + return UserDashboardViewModel::tr("Continue Watching"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) { + return UserDashboardViewModel::tr("Latest Media"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) { + return UserDashboardViewModel::tr("Recommended"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::CompletedWatchingSectionId)) { + return UserDashboardViewModel::tr("Completed Watching"); + } + return {}; +} + +QString categoryId(const QString §ionId) { + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::ContinueWatchingSectionId)) { + return QStringLiteral("resume"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) { + return QStringLiteral("latest"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) { + return QStringLiteral("recommended"); + } + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::CompletedWatchingSectionId)) { + return QStringLiteral("played"); + } + return {}; +} + +} // namespace + +DashboardMediaListModel::DashboardMediaListModel(QObject *parent) + : QAbstractListModel(parent) {} + +int DashboardMediaListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant DashboardMediaListModel::data(const QModelIndex &index, + int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) { + return {}; + } + + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return subtitleFor(*item); + case ItemTypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item, false); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayedRole: + return item->userData.played; + case PlayableRole: + return isPlayable(*item); + case ProgressRole: + return progressFor(*item); + default: + return {}; + } +} + +QHash DashboardMediaListModel::roleNames() const { + return { + {ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {ItemTypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayedRole, "played"}, + {PlayableRole, "playable"}, + {ProgressRole, "progress"}, + }; +} + +const MediaItem *DashboardMediaListModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +bool DashboardMediaListModel::contains(const QString &itemId) const { + for (const MediaItem &item : m_items) { + if (item.id == itemId) { + return true; + } + } + return false; +} + +void DashboardMediaListModel::setItems(QList items) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); +} + +void DashboardMediaListModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) { + continue; + } + m_items[row] = item; + emit dataChanged(index(row), index(row)); + } +} + +void DashboardMediaListModel::prependOrUpdateItem(const MediaItem &item, + int maximumItems) { + removeItem(item.id); + beginInsertRows({}, 0, 0); + m_items.prepend(item); + endInsertRows(); + if (maximumItems > 0 && m_items.size() > maximumItems) { + const int first = maximumItems; + const int last = m_items.size() - 1; + beginRemoveRows({}, first, last); + m_items.erase(m_items.begin() + first, m_items.end()); + endRemoveRows(); + } +} + +void DashboardMediaListModel::removeItem(const QString &itemId) { + for (int row = m_items.size() - 1; row >= 0; --row) { + if (m_items.at(row).id != itemId) { + continue; + } + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + } +} + +DashboardLibrarySection::DashboardLibrarySection(QString libraryId, + QString title, QObject *parent) + : QObject(parent), m_libraryId(std::move(libraryId)), + m_title(std::move(title)), m_mediaModel(this) {} + +void DashboardLibrarySection::setLoading(bool loading) { + if (m_loading == loading) { + return; + } + m_loading = loading; + emit stateChanged(); +} + +void DashboardLibrarySection::setErrorText(const QString &errorText) { + if (m_errorText == errorText) { + return; + } + m_errorText = errorText; + emit stateChanged(); +} + +DashboardLibrarySectionsModel::DashboardLibrarySectionsModel(QObject *parent) + : QAbstractListModel(parent) {} + +int DashboardLibrarySectionsModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_sections.size(); +} + +QVariant DashboardLibrarySectionsModel::data(const QModelIndex &index, + int role) const { + DashboardLibrarySection *section = sectionAt(index.row()); + if (!index.isValid() || !section) { + return {}; + } + switch (role) { + case LibraryIdRole: + return section->libraryId(); + case TitleRole: + return section->title(); + case MediaModelRole: + return QVariant::fromValue(section->mediaModel()); + case LoadingRole: + return section->loading(); + case ErrorTextRole: + return section->errorText(); + case SectionObjectRole: + return QVariant::fromValue(section); + default: + return {}; + } +} + +QHash DashboardLibrarySectionsModel::roleNames() const { + return {{LibraryIdRole, "libraryId"}, {TitleRole, "libraryTitle"}, + {MediaModelRole, "mediaModel"}, {LoadingRole, "loading"}, + {ErrorTextRole, "errorText"}, {SectionObjectRole, "sectionObject"}}; +} + +DashboardLibrarySection * +DashboardLibrarySectionsModel::sectionAt(int row) const { + return row >= 0 && row < m_sections.size() ? m_sections.at(row) : nullptr; +} + +DashboardLibrarySection *DashboardLibrarySectionsModel::sectionForLibrary( + const QString &libraryId) const { + for (DashboardLibrarySection *section : m_sections) { + if (section->libraryId() == libraryId) { + return section; + } + } + return nullptr; +} + +void DashboardLibrarySectionsModel::rebuild(const QList &libraries) { + QHash reusable; + for (DashboardLibrarySection *section : std::as_const(m_sections)) { + reusable.insert(section->libraryId(), section); + } + + QList next; + next.reserve(libraries.size()); + for (const MediaItem &library : libraries) { + DashboardLibrarySection *section = reusable.take(library.id); + if (!section || section->title() != library.name) { + if (section) { + section->deleteLater(); + } + section = new DashboardLibrarySection(library.id, library.name, this); + connect(section, &DashboardLibrarySection::stateChanged, this, + [this, section]() { + const int row = m_sections.indexOf(section); + if (row >= 0) { + emit dataChanged(index(row), index(row)); + } + }); + } + next.append(section); + } + + beginResetModel(); + m_sections = next; + endResetModel(); + for (DashboardLibrarySection *section : std::as_const(reusable)) { + section->deleteLater(); + } +} + +void DashboardLibrarySectionsModel::clear() { + beginResetModel(); + const auto old = m_sections; + m_sections.clear(); + endResetModel(); + for (DashboardLibrarySection *section : old) { + section->deleteLater(); + } +} + +void DashboardLibrarySectionsModel::updateItem(const MediaItem &item) { + for (DashboardLibrarySection *section : std::as_const(m_sections)) { + section->model()->updateItem(item); + } +} + +void DashboardLibrarySectionsModel::removeItem(const QString &itemId) { + for (DashboardLibrarySection *section : std::as_const(m_sections)) { + section->model()->removeItem(itemId); + } +} + +UserDashboardViewModel::UserDashboardViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_resumeModel(this), m_latestModel(this), + m_recommendedModel(this), m_completedModel(this), m_librariesModel(this), + m_librarySectionsModel(this) { + m_contextKey = currentContextKey(); + readPresentation(); + + connect( + ConfigStore::instance(), &ConfigStore::valueChanged, this, + [this](const QString &key, const QVariant &) { + const QString serverId = currentServerId(); + const QString orderEnabled = ConfigKeys::forServer( + serverId, ConfigKeys::CustomHomeSectionOrderEnabled); + const QString order = + ConfigKeys::forServer(serverId, ConfigKeys::HomeSectionOrder); + if (key == orderEnabled || key == order) { + const QStringList next = configuredSectionOrder(); + if (m_sectionOrder != next) { + m_sectionOrder = next; + emit sectionOrderChanged(); + } + return; + } + + const QStringList reloadKeys = { + QString::fromLatin1(ConfigKeys::DefaultLibraryView), + ConfigKeys::forServer(serverId, ConfigKeys::ShowContinueWatching), + ConfigKeys::forServer(serverId, ConfigKeys::ShowLatestAdded), + ConfigKeys::forServer(serverId, ConfigKeys::ShowRecommended), + ConfigKeys::forServer(serverId, ConfigKeys::ShowCompletedWatching), + ConfigKeys::forServer(serverId, ConfigKeys::ShowMediaLibraries), + ConfigKeys::forServer(serverId, ConfigKeys::ShowEachLibrary), + ConfigKeys::forServer(serverId, + ConfigKeys::ContinueWatchingRequestLimit), + ConfigKeys::forServer(serverId, + ConfigKeys::LatestMediaRequestLimit), + ConfigKeys::forServer(serverId, + ConfigKeys::RecommendedRequestLimit), + ConfigKeys::forServer(serverId, + ConfigKeys::CompletedWatchingRequestLimit), + }; + if (reloadKeys.contains(key)) { + readPresentation(); + if (m_active) { + refresh(); + } else { + ++m_generation; + } + } + }); + + if (m_core && m_core->serverManager()) { + connect(m_core->serverManager(), &ServerManager::activeServerChanged, this, + [this](const ServerProfile &) { + const QString next = currentContextKey(); + if (next == m_contextKey) { + return; + } + m_contextKey = next; + ++m_generation; + clearState(true); + readPresentation(); + if (m_active && !next.isEmpty()) { + refresh(); + } + }); + } +} + +bool UserDashboardViewModel::showResume() const { + return m_presentation.resume && m_resumeModel.rowCount() > 0; +} + +bool UserDashboardViewModel::showLatest() const { + return m_presentation.latest && m_latestModel.rowCount() > 0; +} + +bool UserDashboardViewModel::showRecommended() const { + return m_presentation.recommended && m_recommendedModel.rowCount() > 0; +} + +bool UserDashboardViewModel::showCompleted() const { + return m_presentation.completed && m_completedModel.rowCount() > 0; +} + +bool UserDashboardViewModel::showLibraries() const { + return m_presentation.libraries && m_librariesModel.rowCount() > 0; +} + +bool UserDashboardViewModel::showEachLibrary() const { + return m_presentation.eachLibrary && m_librarySectionsModel.rowCount() > 0; +} + +bool UserDashboardViewModel::hasContent() const { + return m_resumeModel.rowCount() > 0 || m_latestModel.rowCount() > 0 || + m_recommendedModel.rowCount() > 0 || m_completedModel.rowCount() > 0 || + m_librariesModel.rowCount() > 0 || + m_librarySectionsModel.rowCount() > 0; +} + +void UserDashboardViewModel::setActive(bool active) { + if (m_active == active) { + return; + } + m_active = active; + if (!active) { + ++m_generation; + m_pendingRequests = 0; + m_loading = false; + emit stateChanged(); + return; + } + refresh(); +} + +void UserDashboardViewModel::refresh() { + QCoro::connect(loadDashboardData(), this, []() {}); +} + +QCoro::Task UserDashboardViewModel::loadDashboardData() { + const QString context = currentContextKey(); + if (context != m_contextKey) { + m_contextKey = context; + clearState(true); + } + + const quint64 generation = ++m_generation; + readPresentation(); + if (!m_active || context.isEmpty() || !m_core || !m_core->mediaService()) { + m_loading = false; + emit stateChanged(); + co_return; + } + + m_loading = true; + m_errorText.clear(); + m_failedRequests = 0; + m_pendingRequests = 0; + const QString serverId = currentServerId(); + + auto launch = [this](QCoro::Task task) { + ++m_pendingRequests; + QCoro::connect(std::move(task), this, []() {}); + }; + if (m_presentation.resume) { + launch(loadResume(generation, serverId)); + } else { + m_resumeModel.setItems({}); + } + if (m_presentation.latest) { + launch(loadLatest(generation, serverId)); + } else { + m_latestModel.setItems({}); + } + if (m_presentation.recommended) { + launch(loadRecommended(generation, serverId)); + } else { + m_recommendedModel.setItems({}); + } + if (m_presentation.completed) { + launch(loadCompleted(generation, serverId)); + } else { + m_completedModel.setItems({}); + } + if (m_presentation.libraries || m_presentation.eachLibrary) { + launch(loadLibraries(generation, serverId)); + } else { + m_librariesModel.setItems({}); + m_librarySectionsModel.clear(); + } + + if (m_pendingRequests == 0) { + m_loading = false; + } + emit presentationChanged(); + emit stateChanged(); + co_return; +} + +void UserDashboardViewModel::updateItem(const MediaItem &item) { + if (MediaItemUtils::canRemoveFromResume(item)) { + m_resumeModel.removeItem(item.id); + } else { + m_resumeModel.updateItem(item); + } + m_latestModel.updateItem(item); + m_recommendedModel.updateItem(item); + + const QString serverId = currentServerId(); + if (!serverId.isEmpty() && m_presentation.completed && + MediaItemUtils::isCompletedWatchingItem(item)) { + const int maximum = DashboardRequestLimitUtils::homeSectionRequestLimit( + serverId, ConfigKeys::CompletedWatchingRequestLimit, 0); + m_completedModel.prependOrUpdateItem(item, maximum); + } else { + m_completedModel.removeItem(item.id); + } + m_librariesModel.updateItem(item); + m_librarySectionsModel.updateItem(item); + emit presentationChanged(); + emit stateChanged(); +} + +void UserDashboardViewModel::removeItem(const QString &itemId) { + m_resumeModel.removeItem(itemId); + m_latestModel.removeItem(itemId); + m_recommendedModel.removeItem(itemId); + m_completedModel.removeItem(itemId); + m_librariesModel.removeItem(itemId); + m_librarySectionsModel.removeItem(itemId); + emit presentationChanged(); + emit stateChanged(); +} + +bool UserDashboardViewModel::isLibraryItem(const MediaItem &item) const { + return m_librariesModel.contains(item.id); +} + +void UserDashboardViewModel::activate(const QString §ionId, int row) { + if (const MediaItem *item = itemForSection(sectionId, row)) { + emitActivation(*item); + } +} + +void UserDashboardViewModel::activateLibrary(int row) { + if (const MediaItem *item = m_librariesModel.itemAt(row)) { + emit libraryActivated(item->id, item->name); + } +} + +void UserDashboardViewModel::activateLibrarySection(int sectionRow, + int itemRow) { + if (DashboardLibrarySection *section = + m_librarySectionsModel.sectionAt(sectionRow)) { + if (const MediaItem *item = section->model()->itemAt(itemRow)) { + emitActivation(*item); + } + } +} + +void UserDashboardViewModel::requestPlay(const QString §ionId, int row) { + if (const MediaItem *item = itemForSection(sectionId, row)) { + emit playRequested(*item); + } +} + +void UserDashboardViewModel::requestFavorite(const QString §ionId, + int row) { + if (const MediaItem *item = itemForSection(sectionId, row)) { + emit favoriteRequested(*item); + } +} + +void UserDashboardViewModel::requestMore(const QString §ionId, int row, + qreal x, qreal y) { + emitMore(itemForSection(sectionId, row), x, y); +} + +void UserDashboardViewModel::requestLibraryMore(int row, qreal x, qreal y) { + emitMore(m_librariesModel.itemAt(row), x, y); +} + +void UserDashboardViewModel::requestLibrarySectionPlay(int sectionRow, + int itemRow) { + if (DashboardLibrarySection *section = + m_librarySectionsModel.sectionAt(sectionRow)) { + if (const MediaItem *item = section->model()->itemAt(itemRow)) { + emit playRequested(*item); + } + } +} + +void UserDashboardViewModel::requestLibrarySectionFavorite(int sectionRow, + int itemRow) { + if (DashboardLibrarySection *section = + m_librarySectionsModel.sectionAt(sectionRow)) { + if (const MediaItem *item = section->model()->itemAt(itemRow)) { + emit favoriteRequested(*item); + } + } +} + +void UserDashboardViewModel::requestLibrarySectionMore(int sectionRow, + int itemRow, qreal x, + qreal y) { + if (DashboardLibrarySection *section = + m_librarySectionsModel.sectionAt(sectionRow)) { + emitMore(section->model()->itemAt(itemRow), x, y); + } +} + +void UserDashboardViewModel::requestCategory(const QString §ionId) { + const QString id = categoryId(sectionId); + if (!id.isEmpty()) { + emit categoryRequested(id, sectionTitle(sectionId)); + } +} + +void UserDashboardViewModel::requestLibrary(const QString &libraryId, + const QString &libraryName) { + if (!libraryId.isEmpty()) { + emit libraryActivated(libraryId, libraryName); + } +} + +void UserDashboardViewModel::retry() { + if (m_active) { + refresh(); + } +} + +void UserDashboardViewModel::scrollToTop() { + ++m_scrollResetSerial; + emit scrollResetRequested(); +} + +QString UserDashboardViewModel::currentServerId() const { + if (!m_core || !m_core->serverManager()) { + return {}; + } + const ServerProfile profile = m_core->serverManager()->activeProfile(); + return profile.isValid() ? profile.id : QString(); +} + +QString UserDashboardViewModel::currentContextKey() const { + if (!m_core || !m_core->serverManager()) { + return {}; + } + const ServerProfile profile = m_core->serverManager()->activeProfile(); + if (!profile.isValid() || profile.id.trimmed().isEmpty()) { + return {}; + } + return profile.id + QLatin1Char('|') + profile.userId; +} + +QStringList UserDashboardViewModel::configuredSectionOrder() const { + const QString serverId = currentServerId(); + if (serverId.isEmpty()) { + return DashboardSectionOrderUtils::defaultSectionOrder(); + } + ConfigStore *store = ConfigStore::instance(); + if (!store->get( + ConfigKeys::forServer(serverId, + ConfigKeys::CustomHomeSectionOrderEnabled), + false)) { + return DashboardSectionOrderUtils::defaultSectionOrder(); + } + return DashboardSectionOrderUtils::normalizeSectionOrder( + store->get( + ConfigKeys::forServer(serverId, ConfigKeys::HomeSectionOrder), + DashboardSectionOrderUtils::defaultSectionOrder())); +} + +void UserDashboardViewModel::readPresentation() { + const QString serverId = currentServerId(); + ConfigStore *store = ConfigStore::instance(); + Presentation next; + if (!serverId.isEmpty()) { + next.resume = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowContinueWatching), + true); + next.latest = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowLatestAdded), true); + next.recommended = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowRecommended), true); + next.completed = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowCompletedWatching), + false); + next.libraries = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowMediaLibraries), true); + next.eachLibrary = store->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowEachLibrary), true); + } + m_presentation = next; + m_tileView = + store->get(ConfigKeys::DefaultLibraryView, + QStringLiteral("poster")) == QLatin1String("tile"); + const QStringList nextOrder = configuredSectionOrder(); + if (m_sectionOrder != nextOrder) { + m_sectionOrder = nextOrder; + emit sectionOrderChanged(); + } + emit presentationChanged(); +} + +void UserDashboardViewModel::clearState(bool resetScroll) { + m_resumeModel.setItems({}); + m_latestModel.setItems({}); + m_recommendedModel.setItems({}); + m_completedModel.setItems({}); + m_librariesModel.setItems({}); + m_librarySectionsModel.clear(); + m_errorText.clear(); + m_loading = false; + m_pendingRequests = 0; + if (resetScroll) { + scrollToTop(); + } + emit presentationChanged(); + emit stateChanged(); +} + +void UserDashboardViewModel::finishRequest(quint64 generation, + const QString &error) { + if (generation != m_generation) { + return; + } + if (!error.isEmpty()) { + ++m_failedRequests; + qDebug() << "[UserDashboardViewModel] section request failed:" << error; + } + m_pendingRequests = qMax(0, m_pendingRequests - 1); + if (m_pendingRequests == 0) { + m_loading = false; + if (m_failedRequests > 0 && !hasContent()) { + m_errorText = tr("Unable to load the home page"); + } + } + emit presentationChanged(); + emit stateChanged(); +} + +const MediaItem * +UserDashboardViewModel::itemForSection(const QString §ionId, + int row) const { + const DashboardMediaListModel *model = nullptr; + if (sectionId == + QLatin1String(DashboardSectionOrderUtils::ContinueWatchingSectionId)) { + model = &m_resumeModel; + } else if (sectionId == + QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) { + model = &m_latestModel; + } else if (sectionId == + QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) { + model = &m_recommendedModel; + } else if (sectionId == + QLatin1String( + DashboardSectionOrderUtils::CompletedWatchingSectionId)) { + model = &m_completedModel; + } + return model ? model->itemAt(row) : nullptr; +} + +void UserDashboardViewModel::emitActivation(const MediaItem &item) { + if (isLibraryNavigationItem(item)) { + emit libraryActivated(item.id, item.name); + } else { + emit itemActivated(item); + } +} + +void UserDashboardViewModel::emitMore(const MediaItem *item, qreal x, qreal y) { + if (item) { + emit moreRequested(*item, QPoint(qRound(x), qRound(y))); + } +} + +QCoro::Task UserDashboardViewModel::loadResume(quint64 generation, + QString serverId) { + QPointer guard(this); + try { + const int limit = DashboardRequestLimitUtils::homeSectionRequestLimit( + serverId, ConfigKeys::ContinueWatchingRequestLimit, 0); + QList raw = + co_await m_core->mediaService()->getResumeItems(limit); + if (!guard || generation != m_generation || !m_active) { + co_return; + } + + QList items; + QSet seenSeries; + for (const MediaItem &item : std::as_const(raw)) { + if (item.type != QLatin1String("Episode") || item.seriesId.isEmpty()) { + items.append(MediaItemUtils::withResumeContext(item, item)); + continue; + } + if (seenSeries.contains(item.seriesId)) { + continue; + } + seenSeries.insert(item.seriesId); + try { + MediaItem series = + co_await m_core->mediaService()->getItemDetail(item.seriesId); + if (!guard || generation != m_generation || !m_active) { + co_return; + } + items.append(MediaItemUtils::withResumeContext(series, item)); + } catch (...) { + if (!guard || generation != m_generation || !m_active) { + co_return; + } + } + } + m_resumeModel.setItems(std::move(items)); + finishRequest(generation); + } catch (const std::exception &error) { + if (guard && generation == m_generation && m_active) { + finishRequest(generation, QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task UserDashboardViewModel::loadLatest(quint64 generation, + QString serverId) { + QPointer guard(this); + try { + const int limit = DashboardRequestLimitUtils::homeSectionRequestLimit( + serverId, ConfigKeys::LatestMediaRequestLimit, 1000); + QList items = + co_await m_core->mediaService()->getLatestItems(limit); + if (!guard || generation != m_generation || !m_active) { + co_return; + } + m_latestModel.setItems(std::move(items)); + finishRequest(generation); + } catch (const std::exception &error) { + if (guard && generation == m_generation && m_active) { + finishRequest(generation, QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task UserDashboardViewModel::loadRecommended(quint64 generation, + QString serverId) { + QPointer guard(this); + try { + const int limit = DashboardRequestLimitUtils::homeSectionRequestLimit( + serverId, ConfigKeys::RecommendedRequestLimit, 1000); + QList items = + co_await m_core->mediaService()->getRecommendedMovies(limit); + if (!guard || generation != m_generation || !m_active) { + co_return; + } + m_recommendedModel.setItems(std::move(items)); + finishRequest(generation); + } catch (const std::exception &error) { + if (guard && generation == m_generation && m_active) { + finishRequest(generation, QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task UserDashboardViewModel::loadCompleted(quint64 generation, + QString serverId) { + QPointer guard(this); + try { + const int limit = DashboardRequestLimitUtils::homeSectionRequestLimit( + serverId, ConfigKeys::CompletedWatchingRequestLimit, 0); + QList items = + co_await m_core->mediaService()->getPlayedItems(limit); + if (!guard || generation != m_generation || !m_active) { + co_return; + } + m_completedModel.setItems(std::move(items)); + finishRequest(generation); + } catch (const std::exception &error) { + if (guard && generation == m_generation && m_active) { + finishRequest(generation, QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task UserDashboardViewModel::loadLibraries(quint64 generation, + QString serverId) { + QPointer guard(this); + try { + const QList libraries = + co_await m_core->mediaService()->getUserViews(); + if (!guard || generation != m_generation || !m_active || + serverId != currentServerId()) { + co_return; + } + + // A transient empty response must not erase already rendered user data. + if (!libraries.isEmpty() || m_librariesModel.rowCount() == 0) { + if (m_presentation.libraries) { + m_librariesModel.setItems(libraries); + } + } + if (!m_presentation.libraries) { + m_librariesModel.setItems({}); + } + + if (!m_presentation.eachLibrary) { + m_librarySectionsModel.clear(); + } else if (!libraries.isEmpty()) { + m_librarySectionsModel.rebuild(libraries); + for (int row = 0; row < m_librarySectionsModel.rowCount(); ++row) { + DashboardLibrarySection *section = + m_librarySectionsModel.sectionAt(row); + section->setErrorText({}); + section->setLoading(true); + QCoro::connect(loadLibrarySection(section, generation), this, []() {}); + } + } + finishRequest(generation); + } catch (const std::exception &error) { + if (guard && generation == m_generation && m_active) { + finishRequest(generation, QString::fromUtf8(error.what())); + } + } +} + +QCoro::Task UserDashboardViewModel::loadLibrarySection( + QPointer section, quint64 generation) { + QPointer guard(this); + if (!section) { + co_return; + } + const QString libraryId = section->libraryId(); + try { + QList items = co_await m_core->mediaService()->getLibraryItems( + libraryId, QStringLiteral("DateCreated"), QStringLiteral("Descending"), + {}, QStringLiteral("Movie,Series"), 0, 20, true); + if (!guard || !section || generation != m_generation || !m_active) { + co_return; + } + // Keep the previous section on an unexpected empty refresh. + if (!items.isEmpty() || section->model()->rowCount() == 0) { + section->model()->setItems(std::move(items)); + } + section->setLoading(false); + } catch (const std::exception &error) { + if (!guard || !section || generation != m_generation || !m_active) { + co_return; + } + section->setLoading(false); + if (section->model()->rowCount() == 0) { + section->setErrorText(tr("Unable to load this library")); + } + qDebug() << "[UserDashboardViewModel] library section failed" << libraryId + << error.what(); + } +} diff --git a/src/qEmbyApp/views/user/dashboardviewmodel.h b/src/qEmbyApp/views/user/dashboardviewmodel.h new file mode 100644 index 0000000..dbbf68e --- /dev/null +++ b/src/qEmbyApp/views/user/dashboardviewmodel.h @@ -0,0 +1,248 @@ +#ifndef USERDASHBOARDVIEWMODEL_H +#define USERDASHBOARDVIEWMODEL_H + +#include +#include +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class DashboardMediaListModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + ItemTypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayedRole, + PlayableRole, + ProgressRole, + }; + + explicit DashboardMediaListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + bool contains(const QString &itemId) const; + void setItems(QList items); + void updateItem(const MediaItem &item); + void prependOrUpdateItem(const MediaItem &item, int maximumItems); + void removeItem(const QString &itemId); + +private: + QList m_items; +}; + +class DashboardLibrarySection final : public QObject { + Q_OBJECT + Q_PROPERTY(QString libraryId READ libraryId CONSTANT) + Q_PROPERTY(QString title READ title CONSTANT) + Q_PROPERTY(QAbstractItemModel *mediaModel READ mediaModel CONSTANT) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + +public: + DashboardLibrarySection(QString libraryId, QString title, + QObject *parent = nullptr); + + QString libraryId() const { return m_libraryId; } + QString title() const { return m_title; } + QAbstractItemModel *mediaModel() { return &m_mediaModel; } + DashboardMediaListModel *model() { return &m_mediaModel; } + bool loading() const { return m_loading; } + QString errorText() const { return m_errorText; } + + void setLoading(bool loading); + void setErrorText(const QString &errorText); + +signals: + void stateChanged(); + +private: + QString m_libraryId; + QString m_title; + DashboardMediaListModel m_mediaModel; + bool m_loading = false; + QString m_errorText; +}; + +class DashboardLibrarySectionsModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + LibraryIdRole = Qt::UserRole + 1, + TitleRole, + MediaModelRole, + LoadingRole, + ErrorTextRole, + SectionObjectRole, + }; + + explicit DashboardLibrarySectionsModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + DashboardLibrarySection *sectionAt(int row) const; + DashboardLibrarySection *sectionForLibrary(const QString &libraryId) const; + void rebuild(const QList &libraries); + void clear(); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + +private: + QList m_sections; +}; + +class UserDashboardViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(QAbstractItemModel *resumeModel READ resumeModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *latestModel READ latestModel CONSTANT) + Q_PROPERTY( + QAbstractItemModel *recommendedModel READ recommendedModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *completedModel READ completedModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *librariesModel READ librariesModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *librarySectionsModel READ librarySectionsModel + CONSTANT) + Q_PROPERTY( + QStringList sectionOrder READ sectionOrder NOTIFY sectionOrderChanged) + Q_PROPERTY(bool showResume READ showResume NOTIFY presentationChanged) + Q_PROPERTY(bool showLatest READ showLatest NOTIFY presentationChanged) + Q_PROPERTY( + bool showRecommended READ showRecommended NOTIFY presentationChanged) + Q_PROPERTY(bool showCompleted READ showCompleted NOTIFY presentationChanged) + Q_PROPERTY(bool showLibraries READ showLibraries NOTIFY presentationChanged) + Q_PROPERTY( + bool showEachLibrary READ showEachLibrary NOTIFY presentationChanged) + Q_PROPERTY(bool tileView READ tileView NOTIFY presentationChanged) + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(int libraryCount READ libraryCount NOTIFY stateChanged) + Q_PROPERTY(bool hasContent READ hasContent NOTIFY stateChanged) + Q_PROPERTY( + int scrollResetSerial READ scrollResetSerial NOTIFY scrollResetRequested) + +public: + explicit UserDashboardViewModel(QEmbyCore *core, QObject *parent = nullptr); + + QAbstractItemModel *resumeModel() { return &m_resumeModel; } + QAbstractItemModel *latestModel() { return &m_latestModel; } + QAbstractItemModel *recommendedModel() { return &m_recommendedModel; } + QAbstractItemModel *completedModel() { return &m_completedModel; } + QAbstractItemModel *librariesModel() { return &m_librariesModel; } + QAbstractItemModel *librarySectionsModel() { return &m_librarySectionsModel; } + QStringList sectionOrder() const { return m_sectionOrder; } + bool showResume() const; + bool showLatest() const; + bool showRecommended() const; + bool showCompleted() const; + bool showLibraries() const; + bool showEachLibrary() const; + bool tileView() const { return m_tileView; } + bool loading() const { return m_loading; } + QString errorText() const { return m_errorText; } + int libraryCount() const { return m_librariesModel.rowCount(); } + bool hasContent() const; + int scrollResetSerial() const { return m_scrollResetSerial; } + + void setActive(bool active); + void refresh(); + QCoro::Task loadDashboardData(); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + bool isLibraryItem(const MediaItem &item) const; + + Q_INVOKABLE void activate(const QString §ionId, int row); + Q_INVOKABLE void activateLibrary(int row); + Q_INVOKABLE void activateLibrarySection(int sectionRow, int itemRow); + Q_INVOKABLE void requestPlay(const QString §ionId, int row); + Q_INVOKABLE void requestFavorite(const QString §ionId, int row); + Q_INVOKABLE void requestMore(const QString §ionId, int row, qreal x, + qreal y); + Q_INVOKABLE void requestLibraryMore(int row, qreal x, qreal y); + Q_INVOKABLE void requestLibrarySectionPlay(int sectionRow, int itemRow); + Q_INVOKABLE void requestLibrarySectionFavorite(int sectionRow, int itemRow); + Q_INVOKABLE void requestLibrarySectionMore(int sectionRow, int itemRow, + qreal x, qreal y); + Q_INVOKABLE void requestCategory(const QString §ionId); + Q_INVOKABLE void requestLibrary(const QString &libraryId, + const QString &libraryName); + Q_INVOKABLE void retry(); + Q_INVOKABLE void scrollToTop(); + +signals: + void stateChanged(); + void presentationChanged(); + void sectionOrderChanged(); + void scrollResetRequested(); + void itemActivated(const MediaItem &item); + void libraryActivated(const QString &libraryId, const QString &libraryName); + void categoryRequested(const QString &categoryId, const QString &title); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item, const QPoint &localPos); + +private: + struct Presentation { + bool resume = true; + bool latest = true; + bool recommended = true; + bool completed = false; + bool libraries = true; + bool eachLibrary = true; + }; + + QString currentServerId() const; + QString currentContextKey() const; + QStringList configuredSectionOrder() const; + void readPresentation(); + void clearState(bool resetScroll); + void finishRequest(quint64 generation, const QString &error = {}); + const MediaItem *itemForSection(const QString §ionId, int row) const; + void emitActivation(const MediaItem &item); + void emitMore(const MediaItem *item, qreal x, qreal y); + QCoro::Task loadResume(quint64 generation, QString serverId); + QCoro::Task loadLatest(quint64 generation, QString serverId); + QCoro::Task loadRecommended(quint64 generation, QString serverId); + QCoro::Task loadCompleted(quint64 generation, QString serverId); + QCoro::Task loadLibraries(quint64 generation, QString serverId); + QCoro::Task + loadLibrarySection(QPointer section, + quint64 generation); + + QPointer m_core; + DashboardMediaListModel m_resumeModel; + DashboardMediaListModel m_latestModel; + DashboardMediaListModel m_recommendedModel; + DashboardMediaListModel m_completedModel; + DashboardMediaListModel m_librariesModel; + DashboardLibrarySectionsModel m_librarySectionsModel; + Presentation m_presentation; + QStringList m_sectionOrder; + QString m_contextKey; + QString m_errorText; + bool m_tileView = false; + bool m_loading = false; + bool m_active = false; + int m_pendingRequests = 0; + int m_failedRequests = 0; + int m_scrollResetSerial = 0; + quint64 m_generation = 0; +}; + +#endif // USERDASHBOARDVIEWMODEL_H diff --git a/src/qEmbyApp/views/user/favoritesview.cpp b/src/qEmbyApp/views/user/favoritesview.cpp index 6aaceed..b89fabb 100644 --- a/src/qEmbyApp/views/user/favoritesview.cpp +++ b/src/qEmbyApp/views/user/favoritesview.cpp @@ -1,312 +1,72 @@ #include "favoritesview.h" -#include "../../tokens/AnimationTokens.h" -#include "../../utils/smoothscrollcontroller.h" -#include -#include -#include -#include -#include -#include "../../components/horizontallistviewgallery.h" + +#include "../../quick/embyimageprovider.h" +#include "../../quick/quickpagehost.h" +#include "favoritesviewmodel.h" + +#include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -FavoritesView::FavoritesView(QEmbyCore* core, QWidget *parent) - : BaseView(core, parent), m_vScrollController(nullptr) -{ - setObjectName("favorites-view"); - - - setupUi(); -} +FavoritesView::FavoritesView(QEmbyCore *core, QWidget *parent) + : BaseView(core, parent) { + setObjectName(QStringLiteral("favorites-view")); + setAttribute(Qt::WA_StyledBackground, true); -void FavoritesView::setupUi() -{ - auto *favLayout = new QVBoxLayout(this); - favLayout->setContentsMargins(0, 0, 0, 0); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); - m_mainScrollArea = new QScrollArea(this); - m_mainScrollArea->setObjectName("favorites-scroll"); - m_mainScrollArea->setWidgetResizable(true); - m_mainScrollArea->setFrameShape(QFrame::NoFrame); - m_mainScrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_quickHost = new QuickPageHost(this); + m_viewModel = new FavoritesViewModel(core, m_quickHost); + m_quickHost->setContextProperty(QStringLiteral("favoritesPage"), m_viewModel); + m_quickHost->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(core)); + m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + m_quickHost->load( + QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/FavoritesPage.qml"))); + layout->addWidget(m_quickHost); - QWidget *container = new QWidget(m_mainScrollArea); - container->setObjectName("favorites-container"); - auto *containerLayout = new QVBoxLayout(container); - containerLayout->setContentsMargins(20, 20, 20, 20); - containerLayout->setSpacing(0); + connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, + [this]() { m_viewModel->setActive(true); }); + connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, + [this]() { m_viewModel->setActive(false); }); - - bool isTile = (ConfigStore::instance()->get(ConfigKeys::DefaultLibraryView, "poster") == "tile"); - MediaCardDelegate::CardStyle defaultStyle = isTile ? MediaCardDelegate::LibraryTile : MediaCardDelegate::Poster; - int galleryHeight = isTile ? 230 : 300; - - - auto addGallerySection = [this, containerLayout, galleryHeight](QWidget* header, HorizontalListViewGallery** outGallery, MediaCardDelegate::CardStyle style) { - auto *section = new QWidget(); - auto *layout = new QVBoxLayout(section); - layout->setContentsMargins(0, 0, 0, 30); - layout->setSpacing(0); - layout->addWidget(header); - - - *outGallery = new HorizontalListViewGallery(m_core, section); - (*outGallery)->setFixedHeight(style == MediaCardDelegate::Cast ? 300 : galleryHeight); - (*outGallery)->setCardStyle(style); - - - (*outGallery)->listView()->setProperty("isHorizontalListView", true); - (*outGallery)->listView()->viewport()->installEventFilter(this); - - - - - connect(*outGallery, &HorizontalListViewGallery::itemClicked, this, [this](const MediaItem& item) { - if (item.type == "BoxSet" || item.type == "Playlist" || item.type == "Folder") { - Q_EMIT navigateToFolder(item.id, item.name); - } else if (item.type == "Person") { - Q_EMIT navigateToPerson(item.id, item.name); + connect(m_viewModel, &FavoritesViewModel::itemActivated, this, + [this](const MediaItem &item) { + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) { + Q_EMIT navigateToFolder(item.id, item.name); + } else if (item.type == QLatin1String("Person")) { + Q_EMIT navigateToPerson(item.id, item.name); } else { - Q_EMIT navigateToDetail(item.id, item.name, item); + Q_EMIT navigateToDetail(item.id, item.name, item); } - }); - - - connect(*outGallery, &HorizontalListViewGallery::playRequested, this, &BaseView::handlePlayRequested); - connect(*outGallery, &HorizontalListViewGallery::favoriteRequested, this, &BaseView::handleFavoriteRequested); - connect(*outGallery, &HorizontalListViewGallery::moreMenuRequested, this, &BaseView::handleMoreMenuRequested); - - layout->addWidget(*outGallery); - containerLayout->addWidget(section); - }; - - - m_moviesHeader = createSectionHeader(tr("Favorite Movies"), "Movie"); - addGallerySection(m_moviesHeader, &m_moviesGallery, defaultStyle); - - - m_seriesHeader = createSectionHeader(tr("Favorite Shows"), "Series"); - addGallerySection(m_seriesHeader, &m_seriesGallery, defaultStyle); - - - m_collectionsHeader = createSectionHeader(tr("Favorite Collections"), "BoxSet"); - addGallerySection(m_collectionsHeader, &m_collectionsGallery, defaultStyle); - - - m_playlistsHeader = createSectionHeader(tr("Favorite Playlists"), "Playlist"); - addGallerySection(m_playlistsHeader, &m_playlistsGallery, defaultStyle); - - - m_peopleHeader = createSectionHeader(tr("Favorite People"), "Person"); - addGallerySection(m_peopleHeader, &m_peopleGallery, MediaCardDelegate::Cast); - - - m_foldersHeader = createSectionHeader(tr("Favorite Folders"), "Folder"); - addGallerySection(m_foldersHeader, &m_foldersGallery, defaultStyle); - - containerLayout->addStretch(); - m_mainScrollArea->setWidget(container); - favLayout->addWidget(m_mainScrollArea); - - m_vScrollController = - new SmoothScrollController(m_mainScrollArea->verticalScrollBar(), this); - m_vScrollController->setDuration(AnimationTokens::DurationMs::kFast); - - m_mainScrollArea->viewport()->installEventFilter(this); + }); + connect(m_viewModel, &FavoritesViewModel::categoryRequested, this, + &BaseView::navigateToCategory); + connect(m_viewModel, &FavoritesViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_viewModel, &FavoritesViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_viewModel, &FavoritesViewModel::moreRequested, this, + [this](const MediaItem &item) { + handleMoreMenuRequested(item, QCursor::pos()); + }); } -QWidget* FavoritesView::createSectionHeader(const QString& title, const QString& itemType) -{ - QWidget* w = new QWidget(this); - auto* l = new QHBoxLayout(w); - l->setContentsMargins(0, 0, 0, 10); +void FavoritesView::scrollToTop() { m_viewModel->scrollToTop(); } - QLabel* label = new QLabel(title, w); - label->setObjectName("dashboard-section-title"); - - l->addWidget(label); - l->addStretch(); - - - if (!itemType.isEmpty()) { - QPushButton* moreBtn = new QPushButton(tr("See All >"), w); - moreBtn->setObjectName("section-more-btn"); - moreBtn->setCursor(Qt::PointingHandCursor); - - connect(moreBtn, &QPushButton::clicked, this, [this, title, itemType]() { - - Q_EMIT navigateToCategory(itemType, title); - }); - - l->addWidget(moreBtn); - } - - return w; +QCoro::Task FavoritesView::loadFavoritesData() { + co_await m_viewModel->refreshAsync(); } -bool FavoritesView::eventFilter(QObject* obj, QEvent* event) -{ - if (event->type() == QEvent::Wheel) { - bool isHorizontalViewport = obj->parent() && obj->parent()->property("isHorizontalListView").toBool(); - bool isMainViewport = (obj == m_mainScrollArea->viewport()); - - if (isHorizontalViewport || isMainViewport) { - QWheelEvent* we = static_cast(event); - if (m_vScrollController) { - m_vScrollController->scrollByWheelEvent(we, Qt::Vertical); - } - return true; - } - } - - return QWidget::eventFilter(obj, event); +void FavoritesView::onMediaItemUpdated(const MediaItem &item) { + m_viewModel->updateItem(item); } -void FavoritesView::showEvent(QShowEvent* event) -{ - QWidget::showEvent(event); - loadFavoritesData(); -} - - -QCoro::Task FavoritesView::loadFavoritesData() -{ - auto* mediaService = m_core->mediaService(); - - QPointer guard(this); - - - bool isTileFav = (ConfigStore::instance()->get(ConfigKeys::DefaultLibraryView, "poster") == "tile"); - MediaCardDelegate::CardStyle favStyle = isTileFav ? MediaCardDelegate::LibraryTile : MediaCardDelegate::Poster; - int favGalleryHeight = isTileFav ? 230 : 300; - auto applyStyle = [&](HorizontalListViewGallery* g) { - if (g) { g->setCardStyle(favStyle); g->setFixedHeight(favGalleryHeight); } - }; - applyStyle(m_moviesGallery); - applyStyle(m_seriesGallery); - applyStyle(m_collectionsGallery); - applyStyle(m_playlistsGallery); - applyStyle(m_foldersGallery); - - - - try { - QList movies = co_await mediaService->getFavoriteMovies(50); - if (!guard) co_return; - m_moviesGallery->setItems(movies); - if (m_moviesHeader && m_moviesHeader->parentWidget()) { - m_moviesHeader->parentWidget()->setVisible(!movies.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite movies: " << e.what(); - } - - - try { - QList series = co_await mediaService->getFavoriteSeries(50); - if (!guard) co_return; - m_seriesGallery->setItems(series); - if (m_seriesHeader && m_seriesHeader->parentWidget()) { - m_seriesHeader->parentWidget()->setVisible(!series.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite series: " << e.what(); - } - - - try { - QList collections = co_await mediaService->getFavoriteCollections(50); - if (!guard) co_return; - m_collectionsGallery->setItems(collections); - if (m_collectionsHeader && m_collectionsHeader->parentWidget()) { - m_collectionsHeader->parentWidget()->setVisible(!collections.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite collections: " << e.what(); - } - - - try { - QList playlists = co_await mediaService->getFavoritePlaylists(50); - if (!guard) co_return; - m_playlistsGallery->setItems(playlists); - if (m_playlistsHeader && m_playlistsHeader->parentWidget()) { - m_playlistsHeader->parentWidget()->setVisible(!playlists.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite playlists: " << e.what(); - } - - - try { - QList people = co_await mediaService->getFavoritePeople(50); - if (!guard) co_return; - m_peopleGallery->setItems(people); - if (m_peopleHeader && m_peopleHeader->parentWidget()) { - m_peopleHeader->parentWidget()->setVisible(!people.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite people: " << e.what(); - } - - - - QString sid = m_core->serverManager()->activeProfile().id; - bool showFavFolders = ConfigStore::instance()->get( - ConfigKeys::forServer(sid, ConfigKeys::ShowFavoriteFolders), false); - - if (!showFavFolders) { - if (m_foldersHeader && m_foldersHeader->parentWidget()) - m_foldersHeader->parentWidget()->setVisible(false); - } else { - try { - QList folders = co_await mediaService->getFavoriteFolders(50); - if (!guard) co_return; - m_foldersGallery->setItems(folders); - if (m_foldersHeader && m_foldersHeader->parentWidget()) { - m_foldersHeader->parentWidget()->setVisible(!folders.isEmpty()); - } - } catch (const std::exception& e) { - qDebug() << "Failed to fetch favorite folders: " << e.what(); - } - } -} - - - - -void FavoritesView::onMediaItemUpdated(const MediaItem& item) -{ - - - - - - if (m_moviesGallery) m_moviesGallery->updateItem(item); - if (m_seriesGallery) m_seriesGallery->updateItem(item); - if (m_collectionsGallery) m_collectionsGallery->updateItem(item); - if (m_playlistsGallery) m_playlistsGallery->updateItem(item); - if (m_peopleGallery) m_peopleGallery->updateItem(item); - if (m_foldersGallery) m_foldersGallery->updateItem(item); -} - -void FavoritesView::onMediaItemRemoved(const QString& itemId) -{ - if (!m_playlistsGallery) { - return; - } - - m_playlistsGallery->removeItem(itemId); - if (m_playlistsHeader && m_playlistsHeader->parentWidget()) { - m_playlistsHeader->parentWidget()->setVisible(m_playlistsGallery->itemCount() > 0); - } +void FavoritesView::onMediaItemRemoved(const QString &itemId) { + m_viewModel->removeItem(itemId); } diff --git a/src/qEmbyApp/views/user/favoritesview.h b/src/qEmbyApp/views/user/favoritesview.h index 9a88aa0..f936ea1 100644 --- a/src/qEmbyApp/views/user/favoritesview.h +++ b/src/qEmbyApp/views/user/favoritesview.h @@ -2,65 +2,29 @@ #define FAVORITESVIEW_H #include "../baseview.h" + #include -class QListView; -class QLabel; -class QScrollArea; -class HorizontalListViewGallery; -class SmoothScrollController; +class FavoritesViewModel; +class QuickPageHost; + +class FavoritesView final : public BaseView { + Q_OBJECT -class FavoritesView : public BaseView -{ - Q_OBJECT public: - explicit FavoritesView(QEmbyCore* core, QWidget *parent = nullptr); - - - QCoro::Task loadFavoritesData(); + explicit FavoritesView(QEmbyCore *core, QWidget *parent = nullptr); + QCoro::Task loadFavoritesData(); + +public Q_SLOTS: + void scrollToTop() override; protected: - - void onMediaItemUpdated(const MediaItem& item) override; - void onMediaItemRemoved(const QString& itemId) override; - - void showEvent(QShowEvent* event) override; - - bool eventFilter(QObject* obj, QEvent* event) override; + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; private: - void setupUi(); - - - QWidget* createSectionHeader(const QString& title, const QString& itemType = QString()); - - QScrollArea* m_mainScrollArea; - - SmoothScrollController* m_vScrollController; - - - QWidget* m_moviesHeader; - HorizontalListViewGallery* m_moviesGallery; - - - QWidget* m_seriesHeader; - HorizontalListViewGallery* m_seriesGallery; - - - QWidget* m_collectionsHeader; - HorizontalListViewGallery* m_collectionsGallery; - - - QWidget* m_playlistsHeader; - HorizontalListViewGallery* m_playlistsGallery; - - - QWidget* m_peopleHeader; - HorizontalListViewGallery* m_peopleGallery; - - - QWidget* m_foldersHeader; - HorizontalListViewGallery* m_foldersGallery; + QuickPageHost *m_quickHost = nullptr; + FavoritesViewModel *m_viewModel = nullptr; }; -#endif +#endif // FAVORITESVIEW_H diff --git a/src/qEmbyApp/views/user/favoritesviewmodel.cpp b/src/qEmbyApp/views/user/favoritesviewmodel.cpp new file mode 100644 index 0000000..5846ed4 --- /dev/null +++ b/src/qEmbyApp/views/user/favoritesviewmodel.cpp @@ -0,0 +1,399 @@ +#include "favoritesviewmodel.h" + +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +QString imageSource(const MediaItem &item, bool thumb) { + const auto best = thumb ? item.images.bestThumb() : item.images.bestPoster(); + const QString tag = best.first; + const QString type = + best.second.isEmpty() ? QStringLiteral("Primary") : best.second; + if (item.id.isEmpty()) + return {}; + + QString imageId = item.getPrimaryImageId(); + if (item.images.isParentTag(tag) && + !item.images.parentImageItemId.isEmpty()) { + imageId = item.images.parentImageItemId; + } + return QStringLiteral("image://emby/%1,%2,%3").arg(imageId, type, tag); +} + +QString subtitleFor(const MediaItem &item) { + QStringList parts; + if (!item.seriesName.isEmpty()) + parts.append(item.seriesName); + if (item.productionYear > 0) + parts.append(QString::number(item.productionYear)); + if (parts.isEmpty()) + parts.append(item.type); + return parts.join(QStringLiteral(" · ")); +} + +bool isPlayable(const MediaItem &item) { + return item.mediaType == QLatin1String("Video") || + item.type == QLatin1String("Movie") || + item.type == QLatin1String("Episode") || + item.type == QLatin1String("Video") || + item.type == QLatin1String("MusicVideo") || + item.type == QLatin1String("Series"); +} + +} // namespace + +FavoriteMediaListModel::FavoriteMediaListModel(QObject *parent) + : QAbstractListModel(parent) {} + +int FavoriteMediaListModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : m_items.size(); +} + +QVariant FavoriteMediaListModel::data(const QModelIndex &index, + int role) const { + const MediaItem *item = itemAt(index.row()); + if (!index.isValid() || !item) + return {}; + + switch (role) { + case ItemIdRole: + return item->id; + case NameRole: + return item->name; + case SubtitleRole: + return subtitleFor(*item); + case TypeRole: + return item->type; + case PosterSourceRole: + return imageSource(*item, false); + case ThumbSourceRole: + return imageSource(*item, true); + case FavoriteRole: + return item->isFavorite(); + case PlayableRole: + return isPlayable(*item); + case PlayedRole: + return item->userData.played; + case ProgressRole: + return item->userData.playedPercentage / 100.0; + default: + return {}; + } +} + +QHash FavoriteMediaListModel::roleNames() const { + return { + {ItemIdRole, "itemId"}, + {NameRole, "name"}, + {SubtitleRole, "subtitle"}, + {TypeRole, "itemType"}, + {PosterSourceRole, "posterSource"}, + {ThumbSourceRole, "thumbSource"}, + {FavoriteRole, "favorite"}, + {PlayableRole, "playable"}, + {PlayedRole, "played"}, + {ProgressRole, "progress"}, + }; +} + +const MediaItem *FavoriteMediaListModel::itemAt(int row) const { + return row >= 0 && row < m_items.size() ? &m_items.at(row) : nullptr; +} + +void FavoriteMediaListModel::setItems(QList items) { + beginResetModel(); + m_items = std::move(items); + endResetModel(); +} + +bool FavoriteMediaListModel::updateItem(const MediaItem &item) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != item.id) + continue; + if (!item.isFavorite()) { + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + } else { + m_items[row] = item; + const QModelIndex changed = index(row); + Q_EMIT dataChanged(changed, changed); + } + return true; + } + return false; +} + +bool FavoriteMediaListModel::removeItem(const QString &itemId) { + for (int row = 0; row < m_items.size(); ++row) { + if (m_items.at(row).id != itemId) + continue; + beginRemoveRows({}, row, row); + m_items.removeAt(row); + endRemoveRows(); + return true; + } + return false; +} + +FavoritesViewModel::FavoritesViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core), m_movies(this), m_series(this), + m_collections(this), m_playlists(this), m_people(this), m_folders(this) {} + +bool FavoritesViewModel::hasContent() const { + return m_movies.rowCount() + m_series.rowCount() + m_collections.rowCount() + + m_playlists.rowCount() + m_people.rowCount() + + m_folders.rowCount() > + 0; +} + +void FavoritesViewModel::setActive(bool active) { + if (m_active == active) + return; + m_active = active; + if (!active) { + ++m_loadGeneration; + if (m_loading || m_refreshing) { + m_loading = false; + m_refreshing = false; + Q_EMIT stateChanged(); + } + return; + } + QCoro::connect(load(false), this, []() {}); +} + +void FavoritesViewModel::refresh() { + if (!m_active || m_loading || m_refreshing) + return; + QCoro::connect(refreshAsync(), this, []() {}); +} + +void FavoritesViewModel::retry() { refresh(); } + +QCoro::Task FavoritesViewModel::refreshAsync() { + if (!m_active || m_loading || m_refreshing) + co_return; + co_await load(true); +} + +QCoro::Task FavoritesViewModel::load(bool manualRefresh) { + if (!m_core || !m_core->mediaService()) + co_return; + + const quint64 generation = ++m_loadGeneration; + m_loading = !manualRefresh && !m_loaded; + m_refreshing = manualRefresh || m_loaded; + m_errorText.clear(); + Q_EMIT stateChanged(); + + QList movies; + QList series; + QList collections; + QList playlists; + QList people; + QList folders; + QStringList failedSections; + bool moviesLoaded = false; + bool seriesLoaded = false; + bool collectionsLoaded = false; + bool playlistsLoaded = false; + bool peopleLoaded = false; + bool foldersLoaded = false; + auto *service = m_core->mediaService(); + QPointer guard(this); + + try { + movies = co_await service->getFavoriteMovies(50); + moviesLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("movies")); + qDebug() << "Failed to fetch favorite movies:" << error.what(); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + try { + series = co_await service->getFavoriteSeries(50); + seriesLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("shows")); + qDebug() << "Failed to fetch favorite shows:" << error.what(); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + try { + collections = co_await service->getFavoriteCollections(50); + collectionsLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("collections")); + qDebug() << "Failed to fetch favorite collections:" << error.what(); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + try { + playlists = co_await service->getFavoritePlaylists(50); + playlistsLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("playlists")); + qDebug() << "Failed to fetch favorite playlists:" << error.what(); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + try { + people = co_await service->getFavoritePeople(50); + peopleLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("people")); + qDebug() << "Failed to fetch favorite people:" << error.what(); + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + const QString serverId = m_core->serverManager()->activeProfile().id; + m_tileView = ConfigStore::instance()->get( + ConfigKeys::DefaultLibraryView, QStringLiteral("poster")) == + QLatin1String("tile"); + m_showFolders = ConfigStore::instance()->get( + ConfigKeys::forServer(serverId, ConfigKeys::ShowFavoriteFolders), false); + if (m_showFolders) { + try { + folders = co_await service->getFavoriteFolders(50); + foldersLoaded = true; + } catch (const std::exception &error) { + failedSections.append(tr("folders")); + qDebug() << "Failed to fetch favorite folders:" << error.what(); + } + } + if (!guard || !m_active || generation != m_loadGeneration) + co_return; + + if (moviesLoaded) + m_movies.setItems(std::move(movies)); + if (seriesLoaded) + m_series.setItems(std::move(series)); + if (collectionsLoaded) + m_collections.setItems(std::move(collections)); + if (playlistsLoaded) + m_playlists.setItems(std::move(playlists)); + if (peopleLoaded) + m_people.setItems(std::move(people)); + if (!m_showFolders) + m_folders.setItems({}); + else if (foldersLoaded) + m_folders.setItems(std::move(folders)); + m_errorText = failedSections.isEmpty() + ? QString() + : tr("Some favorites could not be loaded: %1") + .arg(failedSections.join(QStringLiteral(", "))); + m_loaded = true; + m_loading = false; + m_refreshing = false; + Q_EMIT stateChanged(); +} + +FavoriteMediaListModel *FavoritesViewModel::modelForSection(int section) { + switch (section) { + case Movies: + return &m_movies; + case Series: + return &m_series; + case Collections: + return &m_collections; + case Playlists: + return &m_playlists; + case People: + return &m_people; + case Folders: + return &m_folders; + default: + return nullptr; + } +} + +const MediaItem *FavoritesViewModel::itemAt(int section, int row) { + FavoriteMediaListModel *model = modelForSection(section); + return model ? model->itemAt(row) : nullptr; +} + +void FavoritesViewModel::activate(int section, int row) { + if (const MediaItem *item = itemAt(section, row)) + Q_EMIT itemActivated(*item); +} + +void FavoritesViewModel::requestPlay(int section, int row) { + if (const MediaItem *item = itemAt(section, row)) + Q_EMIT playRequested(*item); +} + +void FavoritesViewModel::requestFavorite(int section, int row) { + if (const MediaItem *item = itemAt(section, row)) + Q_EMIT favoriteRequested(*item); +} + +void FavoritesViewModel::requestMore(int section, int row) { + if (const MediaItem *item = itemAt(section, row)) + Q_EMIT moreRequested(*item); +} + +void FavoritesViewModel::requestCategory(int section) { + switch (section) { + case Movies: + Q_EMIT categoryRequested(QStringLiteral("Movie"), tr("Favorite Movies")); + break; + case Series: + Q_EMIT categoryRequested(QStringLiteral("Series"), tr("Favorite Shows")); + break; + case Collections: + Q_EMIT categoryRequested(QStringLiteral("BoxSet"), + tr("Favorite Collections")); + break; + case Playlists: + Q_EMIT categoryRequested(QStringLiteral("Playlist"), + tr("Favorite Playlists")); + break; + case People: + Q_EMIT categoryRequested(QStringLiteral("Person"), tr("Favorite People")); + break; + case Folders: + Q_EMIT categoryRequested(QStringLiteral("Folder"), tr("Favorite Folders")); + break; + } +} + +void FavoritesViewModel::updateItem(const MediaItem &item) { + bool changed = false; + changed |= m_movies.updateItem(item); + changed |= m_series.updateItem(item); + changed |= m_collections.updateItem(item); + changed |= m_playlists.updateItem(item); + changed |= m_people.updateItem(item); + changed |= m_folders.updateItem(item); + if (changed) + Q_EMIT stateChanged(); +} + +void FavoritesViewModel::removeItem(const QString &itemId) { + bool changed = false; + changed |= m_movies.removeItem(itemId); + changed |= m_series.removeItem(itemId); + changed |= m_collections.removeItem(itemId); + changed |= m_playlists.removeItem(itemId); + changed |= m_people.removeItem(itemId); + changed |= m_folders.removeItem(itemId); + if (changed) + Q_EMIT stateChanged(); +} + +void FavoritesViewModel::scrollToTop() { Q_EMIT scrollToTopRequested(); } diff --git a/src/qEmbyApp/views/user/favoritesviewmodel.h b/src/qEmbyApp/views/user/favoritesviewmodel.h new file mode 100644 index 0000000..6839f51 --- /dev/null +++ b/src/qEmbyApp/views/user/favoritesviewmodel.h @@ -0,0 +1,144 @@ +#ifndef FAVORITESVIEWMODEL_H +#define FAVORITESVIEWMODEL_H + +#include +#include +#include + +#include +#include + +class QEmbyCore; + +class FavoriteMediaListModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + ItemIdRole = Qt::UserRole + 1, + NameRole, + SubtitleRole, + TypeRole, + PosterSourceRole, + ThumbSourceRole, + FavoriteRole, + PlayableRole, + PlayedRole, + ProgressRole, + }; + + explicit FavoriteMediaListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + const MediaItem *itemAt(int row) const; + void setItems(QList items); + bool updateItem(const MediaItem &item); + bool removeItem(const QString &itemId); + +private: + QList m_items; +}; + +class FavoritesViewModel final : public QObject { + Q_OBJECT + Q_PROPERTY(bool loading READ loading NOTIFY stateChanged) + Q_PROPERTY(bool refreshing READ refreshing NOTIFY stateChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY stateChanged) + Q_PROPERTY(bool hasContent READ hasContent NOTIFY stateChanged) + Q_PROPERTY(bool showFolders READ showFolders NOTIFY stateChanged) + Q_PROPERTY(bool tileView READ tileView NOTIFY stateChanged) + Q_PROPERTY(int moviesCount READ moviesCount NOTIFY stateChanged) + Q_PROPERTY(int seriesCount READ seriesCount NOTIFY stateChanged) + Q_PROPERTY(int collectionsCount READ collectionsCount NOTIFY stateChanged) + Q_PROPERTY(int playlistsCount READ playlistsCount NOTIFY stateChanged) + Q_PROPERTY(int peopleCount READ peopleCount NOTIFY stateChanged) + Q_PROPERTY(int foldersCount READ foldersCount NOTIFY stateChanged) + Q_PROPERTY(QAbstractItemModel *moviesModel READ moviesModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *seriesModel READ seriesModel CONSTANT) + Q_PROPERTY( + QAbstractItemModel *collectionsModel READ collectionsModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *playlistsModel READ playlistsModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *peopleModel READ peopleModel CONSTANT) + Q_PROPERTY(QAbstractItemModel *foldersModel READ foldersModel CONSTANT) + +public: + enum Section { + Movies, + Series, + Collections, + Playlists, + People, + Folders, + }; + Q_ENUM(Section) + + explicit FavoritesViewModel(QEmbyCore *core, QObject *parent = nullptr); + + bool loading() const { return m_loading; } + bool refreshing() const { return m_refreshing; } + QString errorText() const { return m_errorText; } + bool hasContent() const; + bool showFolders() const { return m_showFolders; } + bool tileView() const { return m_tileView; } + int moviesCount() const { return m_movies.rowCount(); } + int seriesCount() const { return m_series.rowCount(); } + int collectionsCount() const { return m_collections.rowCount(); } + int playlistsCount() const { return m_playlists.rowCount(); } + int peopleCount() const { return m_people.rowCount(); } + int foldersCount() const { return m_folders.rowCount(); } + QAbstractItemModel *moviesModel() { return &m_movies; } + QAbstractItemModel *seriesModel() { return &m_series; } + QAbstractItemModel *collectionsModel() { return &m_collections; } + QAbstractItemModel *playlistsModel() { return &m_playlists; } + QAbstractItemModel *peopleModel() { return &m_people; } + QAbstractItemModel *foldersModel() { return &m_folders; } + + void setActive(bool active); + void updateItem(const MediaItem &item); + void removeItem(const QString &itemId); + QCoro::Task refreshAsync(); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void activate(int section, int row); + Q_INVOKABLE void requestPlay(int section, int row); + Q_INVOKABLE void requestFavorite(int section, int row); + Q_INVOKABLE void requestMore(int section, int row); + Q_INVOKABLE void requestCategory(int section); + Q_INVOKABLE void scrollToTop(); + +Q_SIGNALS: + void stateChanged(); + void scrollToTopRequested(); + void itemActivated(const MediaItem &item); + void categoryRequested(const QString &itemType, const QString &title); + void playRequested(const MediaItem &item); + void favoriteRequested(const MediaItem &item); + void moreRequested(const MediaItem &item); + +private: + QCoro::Task load(bool manualRefresh); + FavoriteMediaListModel *modelForSection(int section); + const MediaItem *itemAt(int section, int row); + + QPointer m_core; + FavoriteMediaListModel m_movies; + FavoriteMediaListModel m_series; + FavoriteMediaListModel m_collections; + FavoriteMediaListModel m_playlists; + FavoriteMediaListModel m_people; + FavoriteMediaListModel m_folders; + QString m_errorText; + quint64 m_loadGeneration = 0; + bool m_loading = false; + bool m_refreshing = false; + bool m_active = false; + bool m_loaded = false; + bool m_showFolders = false; + bool m_tileView = false; +}; + +#endif // FAVORITESVIEWMODEL_H diff --git a/src/qEmbyApp/views/user/homequickrouterview.cpp b/src/qEmbyApp/views/user/homequickrouterview.cpp new file mode 100644 index 0000000..c0e3c94 --- /dev/null +++ b/src/qEmbyApp/views/user/homequickrouterview.cpp @@ -0,0 +1,374 @@ +#include "homequickrouterview.h" + +#include "dashboardviewmodel.h" +#include "favoritesviewmodel.h" + +#include "../../components/libraryactionmenu.h" +#include "../../components/mediaimageeditdialog.h" +#include "../../components/moderntoast.h" +#include "../../quick/navigationcontroller.h" +#include "../../quick/quickrouterhost.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, + NavigationController *navigation, + QWidget *parent) + : BaseView(core, parent), m_navigation(navigation) +{ + setObjectName(QStringLiteral("home-quick-router-view")); + setAttribute(Qt::WA_StyledBackground, true); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_host = new QuickRouterHost(core, navigation, this); + m_dashboardViewModel = new UserDashboardViewModel(core, this); + m_favoritesViewModel = new FavoritesViewModel(core, this); + + m_host->setContextProperty(QStringLiteral("dashboardPage"), + m_dashboardViewModel); + m_host->setContextProperty(QStringLiteral("favoritesPage"), + m_favoritesViewModel); + m_host->loadRouter(); + layout->addWidget(m_host); + + connect(m_host, &QuickRouterHost::activated, this, + &HomeQuickRouterView::updateRouteActivity); + connect(m_host, &QuickRouterHost::deactivated, this, + &HomeQuickRouterView::deactivateRoutes); + if (m_navigation) + { + connect(m_navigation, &NavigationController::currentRouteChanged, + this, &HomeQuickRouterView::updateRouteActivity); + connect(m_navigation, &NavigationController::refreshRequested, this, + [this](const QString &key) + { + if (key == QLatin1String("home")) + refreshHome(); + else if (key == QLatin1String("favorites")) + refreshFavorites(); + }); + } + + connect(m_dashboardViewModel, &UserDashboardViewModel::itemActivated, + this, &HomeQuickRouterView::forwardItemActivation); + connect(m_dashboardViewModel, &UserDashboardViewModel::libraryActivated, + this, &HomeQuickRouterView::navigateToLibrary); + connect(m_dashboardViewModel, &UserDashboardViewModel::categoryRequested, + this, &BaseView::navigateToCategory); + connect(m_dashboardViewModel, &UserDashboardViewModel::playRequested, + this, &BaseView::handlePlayRequested); + connect(m_dashboardViewModel, &UserDashboardViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_dashboardViewModel, &UserDashboardViewModel::moreRequested, + this, [this](const MediaItem &item, const QPoint &localPos) + { + handleMoreMenuRequested(item, m_host->mapToGlobal(localPos)); + }); + + connect(m_favoritesViewModel, &FavoritesViewModel::itemActivated, + this, &HomeQuickRouterView::forwardItemActivation); + connect(m_favoritesViewModel, &FavoritesViewModel::categoryRequested, + this, [this](const QString &itemType, const QString &title) + { + emit navigateToCategory(QStringLiteral("Favorite_") + itemType, + title); + }); + connect(m_favoritesViewModel, &FavoritesViewModel::playRequested, + this, &BaseView::handlePlayRequested); + connect(m_favoritesViewModel, &FavoritesViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_favoritesViewModel, &FavoritesViewModel::moreRequested, + this, [this](const MediaItem &item) + { + handleMoreMenuRequested(item, QCursor::pos()); + }); +} + +void HomeQuickRouterView::refreshHome() +{ + m_dashboardViewModel->refresh(); +} + +void HomeQuickRouterView::refreshFavorites() +{ + m_favoritesViewModel->refresh(); +} + +void HomeQuickRouterView::scrollToTop() +{ + if (!m_navigation) + return; + + const QString routeName = + m_navigation->currentRoute().value(QStringLiteral("name")).toString(); + if (routeName == QLatin1String("home")) + m_dashboardViewModel->scrollToTop(); + else if (routeName == QLatin1String("favorites")) + m_favoritesViewModel->scrollToTop(); +} + +void HomeQuickRouterView::onMediaItemUpdated(const MediaItem &item) +{ + m_dashboardViewModel->updateItem(item); + m_favoritesViewModel->updateItem(item); +} + +void HomeQuickRouterView::onMediaItemRemoved(const QString &itemId) +{ + m_dashboardViewModel->removeItem(itemId); + m_favoritesViewModel->removeItem(itemId); +} + +CardContextMenuRequest HomeQuickRouterView::showCardContextMenu( + const MediaItem &item, const QPoint &globalPos) +{ + const bool canShowLibraryMenu = + isManageableDashboardLibraryCard(item) && m_core && + m_core->serverManager() && + m_core->serverManager()->activeProfile().isValid() && + m_core->serverManager()->activeProfile().isAdmin; + if (!canShowLibraryMenu) + return BaseView::showCardContextMenu(item, globalPos); + + VirtualFolder folder; + folder.itemId = item.id; + folder.name = item.name; + LibraryActionMenu menu(folder, false, this); + const LibraryContextMenuRequest libraryRequest = menu.execRequest(globalPos); + + CardContextMenuRequest request; + switch (libraryRequest.action) + { + case LibraryContextMenuAction::EditImages: + request.action = CardContextMenuAction::EditImages; + break; + case LibraryContextMenuAction::RefreshMetadata: + request.action = CardContextMenuAction::RefreshMetadata; + break; + case LibraryContextMenuAction::ScanLibraryFiles: + request.action = CardContextMenuAction::ScanLibraryFiles; + break; + case LibraryContextMenuAction::None: + break; + } + return request; +} + +void HomeQuickRouterView::dispatchCardContextMenuRequest( + const MediaItem &item, const CardContextMenuRequest &request) +{ + if (!request.isValid()) + return; + + if (isManageableDashboardLibraryCard(item)) + { + switch (request.action) + { + case CardContextMenuAction::EditImages: + openDashboardLibraryImageEditor(item); + return; + case CardContextMenuAction::RefreshMetadata: + launchDashboardTask( + executeDashboardLibraryRefresh(item, true, true, true)); + return; + case CardContextMenuAction::ScanLibraryFiles: + launchDashboardTask( + executeDashboardLibraryRefresh(item, false, false, false)); + return; + default: + break; + } + } + + BaseView::dispatchCardContextMenuRequest(item, request); +} + +void HomeQuickRouterView::updateRouteActivity() +{ + const QString routeName = m_navigation + ? m_navigation->currentRoute().value(QStringLiteral("name")).toString() + : QString(); + const bool hostActive = m_host && m_host->isActive(); + + m_dashboardViewModel->setActive(hostActive && + routeName == QLatin1String("home")); + m_favoritesViewModel->setActive(hostActive && + routeName == QLatin1String("favorites")); +} + +void HomeQuickRouterView::deactivateRoutes() +{ + m_dashboardViewModel->setActive(false); + m_favoritesViewModel->setActive(false); +} + +void HomeQuickRouterView::forwardItemActivation(const MediaItem &item) +{ + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) + { + emit navigateToFolder(item.id, item.name); + } + else if (item.type == QLatin1String("Person")) + { + emit navigateToPerson(item.id, item.name); + } + else + { + emit navigateToDetail(item.id, item.name, item); + } +} + +void HomeQuickRouterView::launchDashboardTask(QCoro::Task &&task) +{ + QCoro::connect(std::move(task), this, []() {}); +} + +bool HomeQuickRouterView::isManageableDashboardLibraryCard( + const MediaItem &item) const +{ + const QString routeName = m_navigation + ? m_navigation->currentRoute().value(QStringLiteral("name")).toString() + : QString(); + if (routeName != QLatin1String("home") || !m_dashboardViewModel || + item.id.trimmed().isEmpty() || + !m_dashboardViewModel->isLibraryItem(item)) + { + return false; + } + + const QString collectionType = item.collectionType.trimmed().toLower(); + const QString type = item.type.trimmed().toLower(); + return collectionType != QLatin1String("playlists") && + collectionType != QLatin1String("boxsets") && + type != QLatin1String("playlist") && + type != QLatin1String("boxset"); +} + +void HomeQuickRouterView::openDashboardLibraryImageEditor( + const MediaItem &item) +{ + if (!m_core || !m_core->serverManager() || + !m_core->serverManager()->activeProfile().isValid() || + !m_core->serverManager()->activeProfile().isAdmin) + { + ModernToast::showMessage( + tr("This action requires administrator privileges")); + return; + } + + MediaImageEditTarget target; + target.itemId = item.id; + target.imageItemId = item.images.primaryImageItemId.isEmpty() + ? item.id + : item.images.primaryImageItemId; + target.displayName = item.name; + target.itemType = item.type; + target.mediaType = item.mediaType; + target.collectionType = item.collectionType; + target.isLibrary = true; + + MediaImageEditDialog dialog(m_core, target, this); + dialog.exec(); + if (!dialog.hasChanges()) + return; + + if (m_core->mediaService()) + m_core->mediaService()->clearUserViewsCache(); + m_dashboardViewModel->refresh(); +} + +QCoro::Task HomeQuickRouterView::executeDashboardLibraryRefresh( + MediaItem item, bool replaceAllMetadata, bool replaceAllImages, + bool isMetadataRefresh) +{ + const QString itemId = item.id.trimmed(); + const QString itemName = item.name.trimmed().isEmpty() + ? tr("this item") + : item.name.trimmed(); + if (itemId.isEmpty() || !m_core || !m_core->adminService() || + !m_core->mediaService()) + { + co_return; + } + if (!m_core->serverManager() || + !m_core->serverManager()->activeProfile().isValid() || + !m_core->serverManager()->activeProfile().isAdmin) + { + ModernToast::showMessage( + tr("This action requires administrator privileges")); + co_return; + } + + QPointer guard(this); + try + { + co_await m_core->adminService()->refreshItemMetadata( + itemId, replaceAllMetadata, replaceAllImages); + if (!guard) + co_return; + + ModernToast::showMessage( + isMetadataRefresh + ? tr("Refreshing metadata for \"%1\"...").arg(itemName) + : tr("Scanning \"%1\"...").arg(itemName), + 2000); + } + catch (const std::exception &error) + { + if (guard) + { + ModernToast::showMessage( + isMetadataRefresh + ? tr("Metadata refresh failed: %1") + .arg(QString::fromUtf8(error.what())) + : tr("Scan failed: %1") + .arg(QString::fromUtf8(error.what())), + 3000); + } + co_return; + } + + try + { + const MediaItem latest = + co_await m_core->mediaService()->getItemDetail(itemId); + if (guard) + m_dashboardViewModel->updateItem(latest); + } + catch (const std::exception &error) + { + if (guard) + { + qWarning() << "[HomeQuickRouterView] failed to refresh library card" + << itemId << error.what(); + } + } + + if (guard) + { + QTimer::singleShot(2000, this, [this]() + { + if (m_core && m_core->mediaService()) + { + m_core->mediaService()->clearUserViewsCache(); + m_dashboardViewModel->refresh(); + } + }); + } +} diff --git a/src/qEmbyApp/views/user/homequickrouterview.h b/src/qEmbyApp/views/user/homequickrouterview.h new file mode 100644 index 0000000..335b6fc --- /dev/null +++ b/src/qEmbyApp/views/user/homequickrouterview.h @@ -0,0 +1,55 @@ +#pragma once + +#include "../baseview.h" + +class FavoritesViewModel; +class NavigationController; +class QuickRouterHost; +class UserDashboardViewModel; + +// Bridges the first application-level QML routes to the existing BaseView +// action and navigation contracts. Home and Favorites remain session-scoped +// ViewModels while Router.qml owns their visual lifetime. +class HomeQuickRouterView final : public BaseView +{ + Q_OBJECT + +public: + explicit HomeQuickRouterView(QEmbyCore *core, + NavigationController *navigation, + QWidget *parent = nullptr); + +public Q_SLOTS: + void refreshHome(); + void refreshFavorites(); + void scrollToTop() override; + +Q_SIGNALS: + void navigateToLibrary(const QString &libraryId, + const QString &libraryName); + +protected: + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; + CardContextMenuRequest showCardContextMenu( + const MediaItem &item, const QPoint &globalPos) override; + void dispatchCardContextMenuRequest( + const MediaItem &item, + const CardContextMenuRequest &request) override; + +private: + void updateRouteActivity(); + void deactivateRoutes(); + void forwardItemActivation(const MediaItem &item); + void launchDashboardTask(QCoro::Task &&task); + bool isManageableDashboardLibraryCard(const MediaItem &item) const; + void openDashboardLibraryImageEditor(const MediaItem &item); + QCoro::Task executeDashboardLibraryRefresh( + MediaItem item, bool replaceAllMetadata, bool replaceAllImages, + bool isMetadataRefresh); + + NavigationController *m_navigation = nullptr; + QuickRouterHost *m_host = nullptr; + UserDashboardViewModel *m_dashboardViewModel = nullptr; + FavoritesViewModel *m_favoritesViewModel = nullptr; +}; diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp index 7e8276f..ce5d50a 100644 --- a/src/qEmbyApp/views/user/homeview.cpp +++ b/src/qEmbyApp/views/user/homeview.cpp @@ -9,6 +9,7 @@ #include "../../managers/thememanager.h" #include "../../managers/searchhistorymanager.h" #include "../../managers/playbackmanager.h" +#include "../../quick/navigationcontroller.h" #include "../../utils/smoothscrollcontroller.h" #include "../../tokens/AnimationTokens.h" #include "../admin/manageview.h" @@ -24,6 +25,7 @@ #include "config/webdavprofilestore.h" #include "dashboardview.h" #include "favoritesview.h" +#include "homequickrouterview.h" #include #include #include @@ -63,6 +65,83 @@ constexpr int kSidebarHiddenOffset = 30; constexpr int kLeftEdgeTriggerWidth = 15; constexpr int kRightEdgeTriggerWidth = 20; constexpr int kSidebarLibraryNameRole = Qt::UserRole + 1; + +QVariantMap routeRequestForWidget(const QWidget *widget) +{ + if (!widget) + return {}; + + const QString legacyType = widget->property("routeType").toString(); + const QString id = widget->property("routeId").toString(); + const QString title = widget->property("routeTitle").toString(); + const QString extraId = widget->property("routeExtraId").toString(); + QVariantMap params; + QString name; + + if (legacyType == QLatin1String("DashboardView")) + name = QStringLiteral("home"); + else if (legacyType == QLatin1String("FavoritesView")) + name = QStringLiteral("favorites"); + else if (legacyType == QLatin1String("DetailView")) + { + name = QStringLiteral("detail"); + params.insert(QStringLiteral("itemId"), id); + } + else if (legacyType == QLatin1String("CategoryView")) + { + name = QStringLiteral("category"); + const bool favorite = id.startsWith(QLatin1String("Favorite_")); + params.insert(QStringLiteral("categoryId"), favorite ? id.mid(9) : id); + if (favorite) + params.insert(QStringLiteral("favorite"), true); + } + else if (legacyType == QLatin1String("LibraryView")) + { + name = QStringLiteral("library"); + params.insert(QStringLiteral("libraryId"), id); + } + else if (legacyType == QLatin1String("PersonView")) + { + name = QStringLiteral("person"); + params.insert(QStringLiteral("personId"), id); + } + else if (legacyType == QLatin1String("SearchView")) + { + name = QStringLiteral("search"); + params.insert(QStringLiteral("query"), id); + } + else if (legacyType == QLatin1String("FilteredView")) + { + name = QStringLiteral("filtered"); + params.insert(QStringLiteral("filterType"), extraId); + params.insert(QStringLiteral("filterValue"), title); + } + else if (legacyType == QLatin1String("SeasonView")) + { + name = QStringLiteral("season"); + params.insert(QStringLiteral("seriesId"), extraId); + params.insert(QStringLiteral("seasonId"), id); + } + else if (legacyType == QLatin1String("PlayerView")) + { + name = QStringLiteral("player"); + params.insert(QStringLiteral("mediaId"), id); + params.insert(QStringLiteral("startPositionTicks"), + widget->property("startPositionTicks")); + } + else if (legacyType == QLatin1String("SettingsView")) + name = QStringLiteral("settings"); + else if (legacyType == QLatin1String("ManageView")) + name = QStringLiteral("manage"); + + if (name.isEmpty()) + return {}; + if (!title.isEmpty()) + params.insert(QStringLiteral("title"), title); + return {{QStringLiteral("name"), name}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), params}}; +} } HomeView::HomeView(QEmbyCore *core, QWidget *parent) : QWidget(parent), m_core(core) @@ -75,6 +154,14 @@ HomeView::HomeView(QEmbyCore *core, QWidget *parent) : QWidget(parent), m_core(c m_sidebarOnRight = (pos == "right"); m_sidebarPinned = ConfigStore::instance()->get(ConfigKeys::SidebarPinned, false); + m_navigationController = new NavigationController(this); + connect(m_navigationController, &NavigationController::navigationRejected, this, + [](const QString &code, const QString &message, const QVariantMap &request) + { + qWarning() << "[NavigationController] rejected" << code << message + << request; + }); + setupUi(); if (!m_sidebarPinned) { @@ -135,6 +222,21 @@ PlayerView *HomeView::activePlayerView() const return nullptr; } +void HomeView::beginAuthenticatedSession() +{ + if (!m_navigationController) + return; + const bool administrator = m_core && m_core->serverManager() && + m_core->serverManager()->activeProfile().isAdmin; + m_navigationController->enterAuthenticatedSession(administrator); +} + +void HomeView::clearAuthenticatedSession() +{ + if (m_navigationController) + m_navigationController->leaveAuthenticatedSession(); +} + void HomeView::setupUi() { this->setProperty("showGlobalSearch", true); @@ -154,69 +256,90 @@ void HomeView::setupUi() m_contentSwitcher = new SlidingStackedWidget(this); m_contentSwitcher->setObjectName("home-content"); - - m_dashboardView = new DashboardView(m_core, this); - m_favoritesView = new FavoritesView(m_core, this); - - connect(m_dashboardView, &DashboardView::navigateToLibrary, this, - [this](const QString &id, const QString &name) - { - m_libraryList->clearSelection(); - - - for (int i = 0; i < m_libraryList->count(); ++i) - { - if (m_libraryList->item(i)->data(Qt::UserRole).toString() == id) - { - m_libraryList->item(i)->setSelected(true); - break; - } - } - pushView(createLibraryView(id, name)); - }); - - connect(m_dashboardView, &DashboardView::navigateToCategory, this, - [this](const QString &categoryId, const QString &title) - { - m_libraryList->clearSelection(); - pushView(createCategoryView(categoryId, title)); - }); - - connect(m_favoritesView, &FavoritesView::navigateToCategory, this, - [this](const QString &categoryId, const QString &title) - { - m_libraryList->clearSelection(); - pushView(createCategoryView("Favorite_" + categoryId, title)); - }); - auto navigateToDetailSlot = [this](const QString &itemId, const QString &itemName, const MediaItem &seedItem) { pushView(createDetailView(itemId, itemName, seedItem)); }; - - connect(m_dashboardView, &DashboardView::navigateToDetail, this, navigateToDetailSlot); - connect(m_favoritesView, &FavoritesView::navigateToDetail, this, navigateToDetailSlot); auto navigateToFolderSlot = [this](const QString &id, const QString &name) { pushView(createLibraryView(id, name)); }; - connect(m_favoritesView, &FavoritesView::navigateToFolder, this, navigateToFolderSlot); auto navigateToPersonSlot = [this](const QString &id, const QString &name) { pushView(createPersonView(id, name)); }; - connect(m_favoritesView, &FavoritesView::navigateToPerson, this, navigateToPersonSlot); - - - - auto navigateToPlayerSlot = [this](const QString &id, const QString &title, const QString &url, long long ticks, const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }; - connect(m_dashboardView, &BaseView::navigateToPlayer, this, navigateToPlayerSlot); - connect(m_favoritesView, &BaseView::navigateToPlayer, this, navigateToPlayerSlot); - - auto navigateToSeasonSlot = [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) { pushView(createSeasonView(seriesId, seasonId, seasonName)); }; - connect(m_dashboardView, &BaseView::navigateToSeason, this, navigateToSeasonSlot); - connect(m_favoritesView, &BaseView::navigateToSeason, this, navigateToSeasonSlot); - m_contentSwitcher->addWidget(m_dashboardView); - m_contentSwitcher->addWidget(m_favoritesView); + const auto openDashboardLibrary = [this](const QString &id, const QString &name) + { + m_libraryList->clearSelection(); + for (int i = 0; i < m_libraryList->count(); ++i) + { + if (m_libraryList->item(i)->data(Qt::UserRole).toString() == id) + { + m_libraryList->item(i)->setSelected(true); + break; + } + } + pushView(createLibraryView(id, name)); + }; + const auto openCategory = [this](const QString &categoryId, const QString &title) + { + m_libraryList->clearSelection(); + pushView(createCategoryView(categoryId, title)); + }; + + m_useQuickHomeRouter = qEnvironmentVariableIntValue("QEMBY_USE_LEGACY_HOME_PAGES") != 1; + if (m_useQuickHomeRouter) + { + m_homeQuickRouterView = new HomeQuickRouterView(m_core, m_navigationController, this); + m_homeQuickRouterView->setProperty("routeType", "HomeQuickRouterView"); + connect(m_homeQuickRouterView, &HomeQuickRouterView::navigateToLibrary, + this, openDashboardLibrary); + connect(m_homeQuickRouterView, &BaseView::navigateToCategory, + this, openCategory); + connect(m_homeQuickRouterView, &BaseView::navigateToDetail, + this, navigateToDetailSlot); + connect(m_homeQuickRouterView, &BaseView::navigateToFolder, + this, navigateToFolderSlot); + connect(m_homeQuickRouterView, &BaseView::navigateToPerson, + this, navigateToPersonSlot); + connect(m_homeQuickRouterView, &BaseView::navigateToPlayer, + this, navigateToPlayerSlot); + connect(m_homeQuickRouterView, &BaseView::navigateToSeason, + this, navigateToSeasonSlot); + m_contentSwitcher->addWidget(m_homeQuickRouterView); + } + else + { + m_dashboardView = new DashboardView(m_core, this); + m_favoritesView = new FavoritesView(m_core, this); + m_dashboardView->setProperty("routeType", "DashboardView"); + m_favoritesView->setProperty("routeType", "FavoritesView"); + + connect(m_dashboardView, &DashboardView::navigateToLibrary, + this, openDashboardLibrary); + connect(m_dashboardView, &DashboardView::navigateToCategory, + this, openCategory); + connect(m_favoritesView, &FavoritesView::navigateToCategory, this, + [openCategory](const QString &categoryId, const QString &title) + { openCategory(QStringLiteral("Favorite_") + categoryId, title); }); + connect(m_dashboardView, &DashboardView::navigateToDetail, + this, navigateToDetailSlot); + connect(m_favoritesView, &FavoritesView::navigateToDetail, + this, navigateToDetailSlot); + connect(m_favoritesView, &FavoritesView::navigateToFolder, + this, navigateToFolderSlot); + connect(m_favoritesView, &FavoritesView::navigateToPerson, + this, navigateToPersonSlot); + connect(m_dashboardView, &BaseView::navigateToPlayer, + this, navigateToPlayerSlot); + connect(m_favoritesView, &BaseView::navigateToPlayer, + this, navigateToPlayerSlot); + connect(m_dashboardView, &BaseView::navigateToSeason, + this, navigateToSeasonSlot); + connect(m_favoritesView, &BaseView::navigateToSeason, + this, navigateToSeasonSlot); + m_contentSwitcher->addWidget(m_dashboardView); + m_contentSwitcher->addWidget(m_favoritesView); + } m_lastRouteType = m_contentSwitcher->currentWidget() ? m_contentSwitcher->currentWidget()->property("routeType").toString() : QString(); @@ -519,6 +642,7 @@ QWidget *HomeView::createPlayerView(const QString &mediaId, const QString &title view->setProperty("routeType", "PlayerView"); view->setProperty("routeId", mediaId); view->setProperty("routeTitle", title); + view->setProperty("startPositionTicks", startPositionTicks); connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); @@ -781,9 +905,9 @@ void HomeView::setupSidebar() [this]() { - if (m_contentSwitcher->currentWidget() != m_dashboardView) + if (m_contentSwitcher->currentWidget() != homeRootWidget()) { - resetToView(m_dashboardView); + resetToView(homeRootWidget(), QStringLiteral("home")); } Q_EMIT logoutRequested(); }); @@ -1067,10 +1191,23 @@ QString HomeView::currentSearchServerId() const void HomeView::pushView(QWidget *view) { + if (!view) + return; + QWidget *current = m_contentSwitcher->currentWidget(); if (current == view) return; + const QVariantMap routeRequest = routeRequestForWidget(view); + if (routeRequest.isEmpty() || !m_navigationController->push(routeRequest)) + { + qWarning() << "[HomeView] Refusing legacy view without a valid route" + << view->property("routeType"); + if (m_contentSwitcher->indexOf(view) == -1 && view->property("isDynamic").toBool()) + view->deleteLater(); + return; + } + if (m_contentSwitcher->indexOf(view) == -1) { m_contentSwitcher->addWidget(view); @@ -1151,6 +1288,13 @@ void HomeView::navigateBack() if (!m_navStack.isEmpty()) { + if (!m_navigationController->back()) + { + qWarning() << "[HomeView] Navigation stacks drifted before back" + << "legacyDepth=" << m_navStack.size() + 1 + << "controllerDepth=" << m_navigationController->depth(); + return; + } RouteInfo prevInfo = m_navStack.pop(); QWidget *targetView = prevInfo.widget; @@ -1203,7 +1347,7 @@ void HomeView::navigateBack() if (!targetView) - targetView = m_dashboardView; + targetView = homeRootWidget(); @@ -1233,33 +1377,53 @@ void HomeView::navigateBack() void HomeView::goHome() { - if (m_contentSwitcher->currentWidget() == m_dashboardView) + const QString currentRoute = m_navigationController->currentRoute() + .value(QStringLiteral("name")).toString(); + if (m_contentSwitcher->currentWidget() == homeRootWidget() && + currentRoute == QLatin1String("home")) { - - m_dashboardView->loadDashboardData(); + m_navigationController->home(); + if (m_dashboardView) + m_dashboardView->loadDashboardData(); return; } m_libraryList->clearSelection(); - resetToView(m_dashboardView); + resetToView(homeRootWidget(), QStringLiteral("home")); } void HomeView::goFav() { - if (m_contentSwitcher->currentWidget() == m_favoritesView) + const QString currentRoute = m_navigationController->currentRoute() + .value(QStringLiteral("name")).toString(); + if (m_contentSwitcher->currentWidget() == homeRootWidget() && + currentRoute == QLatin1String("favorites")) { - - m_favoritesView->loadFavoritesData(); + if (m_homeQuickRouterView) + m_homeQuickRouterView->refreshFavorites(); + else if (m_favoritesView) + m_favoritesView->loadFavoritesData(); return; } m_libraryList->clearSelection(); - resetToView(m_favoritesView); + resetToView(m_useQuickHomeRouter ? homeRootWidget() : m_favoritesView, + QStringLiteral("favorites")); } -void HomeView::resetToView(QWidget *view) +void HomeView::resetToView(QWidget *view, const QString &rootRoute) { - if (m_contentSwitcher->currentWidget() == view) + if (!view) return; + const QVariantMap rootRequest = routeRequestForWidget(view); + const QString rootName = rootRoute.isEmpty() + ? rootRequest.value(QStringLiteral("name")).toString() + : rootRoute; + if (!m_navigationController->resetToRoot(rootName)) + { + qWarning() << "[HomeView] Refusing invalid legacy navigation root" << rootName; + return; + } + QList> widgetsToDelete; while (!m_navStack.isEmpty()) @@ -1272,6 +1436,13 @@ void HomeView::resetToView(QWidget *view) } } + if (m_contentSwitcher->currentWidget() == view) + { + QMetaObject::invokeMethod(view, "scrollToTop", Qt::QueuedConnection); + Q_EMIT canNavigateBackChanged(false); + return; + } + QWidget *current = m_contentSwitcher->currentWidget(); if (auto *baseView = qobject_cast(current)) { @@ -1317,16 +1488,30 @@ bool HomeView::canNavigateBack() const bool HomeView::canGoHome() const { - if (!m_contentSwitcher || !m_dashboardView) + if (!m_contentSwitcher || !homeRootWidget() || !m_navigationController) return false; - return m_contentSwitcher->currentWidget() != m_dashboardView; + return m_contentSwitcher->currentWidget() != homeRootWidget() || + m_navigationController->currentRoute().value(QStringLiteral("name")) != + QLatin1String("home"); } bool HomeView::canGoFav() const { - if (!m_contentSwitcher || !m_favoritesView) + if (!m_contentSwitcher || !homeRootWidget() || !m_navigationController) return false; - return m_contentSwitcher->currentWidget() != m_favoritesView; + const QWidget *favoritesRoot = m_useQuickHomeRouter + ? homeRootWidget() + : static_cast(m_favoritesView); + return m_contentSwitcher->currentWidget() != favoritesRoot || + m_navigationController->currentRoute().value(QStringLiteral("name")) != + QLatin1String("favorites"); +} + +QWidget *HomeView::homeRootWidget() const +{ + return m_useQuickHomeRouter + ? static_cast(m_homeQuickRouterView) + : static_cast(m_dashboardView); } void HomeView::scheduleProfileRefresh() @@ -1384,6 +1569,8 @@ QCoro::Task HomeView::refreshProfile() m_btnManage->setVisible(activeProfile.isAdmin); + if (m_navigationController) + m_navigationController->setAdministrator(activeProfile.isAdmin); m_btnDownloads->setVisible(activeProfile.canDownloadMedia); const QString userRoleText = activeProfile.isAdmin ? tr("Administrator") : tr("User"); diff --git a/src/qEmbyApp/views/user/homeview.h b/src/qEmbyApp/views/user/homeview.h index c9e8ac7..01aaeb5 100644 --- a/src/qEmbyApp/views/user/homeview.h +++ b/src/qEmbyApp/views/user/homeview.h @@ -26,6 +26,7 @@ class QBoxLayout; class QVBoxLayout; class DashboardView; class FavoritesView; +class HomeQuickRouterView; class SettingsView; class SmoothScrollController; class SeasonView; @@ -33,6 +34,7 @@ class PlaybackManager; class PlayerView; class ManageView; class WebdavProfileStore; +class NavigationController; struct RouteInfo { @@ -67,6 +69,9 @@ public: PlayerView* activePlayerView() const; + void beginAuthenticatedSession(); + void clearAuthenticatedSession(); + public Q_SLOTS: void navigateBack(); @@ -111,7 +116,8 @@ private: void pushView(QWidget* view); - void resetToView(QWidget* view); + void resetToView(QWidget* view, const QString &rootRoute = QString()); + QWidget* homeRootWidget() const; QWidget* createDetailView(const QString& itemId, const QString& itemName = "", const MediaItem& seedItem = {}); QWidget* createCategoryView(const QString& categoryId, const QString& title = ""); @@ -130,6 +136,7 @@ private: void launchPlayer(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData); QEmbyCore* m_core; + NavigationController* m_navigationController = nullptr; SlidingStackedWidget* m_contentSwitcher = nullptr; bool m_isDestroying = false; @@ -137,6 +144,8 @@ private: DashboardView* m_dashboardView = nullptr; FavoritesView* m_favoritesView = nullptr; + HomeQuickRouterView* m_homeQuickRouterView = nullptr; + bool m_useQuickHomeRouter = true; QWidget* m_sidebar = nullptr; ShadowOverlay* m_sidebarShadow = nullptr;