feat: checkpoint QML view migration

This commit is contained in:
dela
2026-07-15 11:57:34 +08:00
parent 3002adb7bf
commit b117bbf99e
133 changed files with 23964 additions and 17074 deletions
+1 -1
View File
@@ -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()
+100
View File
@@ -0,0 +1,100 @@
# qEmby QML 迁移状态
更新时间:2026-07-15
## 结论与口径
当前架构是 **Widgets 应用壳 + `QQuickWidget` 局部页面**,还不是纯 QML 应用:`MainWindow`、登录/主页导航、侧栏、标题栏和页面栈仍由 Widgets 负责;`QuickPageHost` 把 QML 页面嵌入 `QStackedWidget`/`SlidingStackedWidget`。迁移完成的判定必须同时满足“QML 页面存在、由实际导航入口加载、核心交互可用”,不能只以 `.qml` 文件存在为准。
代码依据:
- `src/qEmbyApp/CMakeLists.txt` 的 `qt_add_qml_module(qEmbyApp ...)` 是当前 QML 模块清单。
- `src/qEmbyApp/quick/quickpagehost.cpp` 使用 `QQuickWidget`,并处理主题底色、context property、image provider 与加载错误。
- `src/qEmbyApp/views/settings/settingsview.cpp` 实际加载 5 个设置 QML 页面。
- `src/qEmbyApp/views/admin/manageview.cpp` 保留 Widgets 管理壳,但仪表盘和六个管理页的页面主体均已由各自的 `QuickPageHost` 加载 QML。
- `src/qEmbyApp/views/user/homeview.cpp` 仍负责主路由,并创建 Dashboard/Favorites/Category/Library/Detail/Season/Search/Player/Settings/Manage 等 QWidget 页面。
- `src/qEmbyApp/quick/navigationcontroller.*` 已实现规范 route、参数/权限校验、会话清理和 UI state;`HomeView` 已旁路接入并与旧 `m_navStack` 并行运行。
状态口径:`done` = 已接入实际入口;`in-progress` = 已分配并正在迁移;`ready` = 前置基本具备,可领取;`blocked` = 应先完成列出的依赖;`legacy` = 迁移期间刻意保留的原生边界。
## 已迁移并接入
| 子系统 | 页面 | QML / 数据层 | owner | status | 验收命令 |
|---|---|---|---|---|---|
| 设置 | 通用 | `SettingsGeneralPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 外观 | `SettingsAppearancePage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 媒体库 | `SettingsLibraryPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 播放器 | `SettingsPlayerPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 设置 | 关于 | `SettingsAboutPage.qml` / `AboutViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` |
| 管理 | 仪表盘 | `ManageDashboardPage.qml` / `DashboardViewModel`、`EmbyImageProvider` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;登录管理员账号后检查 2 秒刷新、重启/关机确认框 |
说明:设置与管理的左侧导航容器仍是 Widgets;About 的许可证窗口以及管理仪表盘的确认框/Toast 仍通过 Widgets 打开,因此这里的 `done` 指页面主体,不代表其父壳和所有弹窗都已迁移。
## 待迁移清单
以下 owner 是任务归属,不是 C++ 类所有者。`login-agent` 与 `search-agent` 已按本轮协作安排标为进行中。
| 阶段 | 子系统 | 页面/范围(现有 Widgets 事实) | owner | status | 依赖 | 验收命令 |
|---|---|---|---|---|---|---|
| 1 | 公共入口 | 登录 `views/public/LoginView` | login-agent | done | 主题输入框、按钮、忙碌/错误状态;认证 ViewModel | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;冷启动后完成服务器选择、登录成功/失败各一次 |
| 1 | 搜索 | 搜索结果 `views/search/SearchView` | search-agent | done | 媒体列表模型、MediaCard、分页/空态 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;提交关键词并验证结果、空结果、进入详情、返回 |
| 1 | 公共基础 | QML 弹窗/Toast/确认框、菜单、空态/错误态 | feedback-agent | done | `Theme`、现有 `ModernDialog.qml` | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;键盘 Esc/Enter、焦点回收、明暗主题各冒烟一次 |
| 1 | 公共基础 | MediaCard + 可复用 List/Grid + 图片加载/占位/错误 | media-primitives-agent | done | `EmbyImageProvider` 泛化、QAbstractListModel 角色契约 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;100+ 项滚动、图片失败、键鼠激活检查 |
| 2 | 用户首页 | 首页 `views/user/DashboardView` | dashboard-agent | done | MediaCard/List、首页 section 模型、请求取消 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;继续观看/最新/推荐/媒体库分区加载与跳转 |
| 2 | 用户首页 | 收藏 `views/user/FavoritesView` | favorites-agent | done | MediaCard/List、收藏模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类、详情、文件夹、人物跳转及返回 |
| 2 | 用户首页 | 分类 `views/user/CategoryView` | category-agent | done | MediaCard/List、筛选/分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类切换、分页、空态、返回状态保持 |
| 2 | 媒体浏览 | 媒体库/人物/过滤结果(共用 `views/media/LibraryView`) | library-agent | done | MediaCard/Grid、排序筛选菜单、分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;海报/平铺视图、排序筛选、滚动续载、人物页 |
| 2 | 应用壳 | `MainWindow` 标题栏、全局搜索、HomeView 侧栏与路由栈 | core | in-progress | `NavigationController`、单引擎 `Router.qml`、Home/Favorites singleton 已接入;待 Search/浏览 route、侧栏与标题导航 | `cmake --build cmake-build-debug --target qEmbyNavigationTest qEmbyApp_qmllint qEmbyApp -j2 && ./cmake-build-debug/bin/qEmbyNavigationTest`;登录→首页→收藏→详情→返回→设置→退出全链路,检查窗口按钮与托盘 |
| 3 | 媒体详情 | 详情 `views/media/DetailView` | detail-agent | done | MediaCard、Flow/Tag、动作菜单、编辑/识别弹窗桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;电影/剧集/人物各一项,播放、收藏、版本选择、编辑入口 |
| 3 | 媒体详情 | 季 `views/media/SeasonView` | season-agent | done | Episode 列表模型、MediaCard、播放动作 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;切季、剧集续播/播放、已播状态、返回 |
| 3 | 管理 | 媒体库 `PageLibraries` | admin-libraries-agent | done | 管理卡片/Grid、拖拽排序、Library CRUD 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增/编辑/删除/扫描/取消扫描/排序 |
| 3 | 管理 | 合集/播放列表 `PageCollections` | admin-collections-agent | done | 管理卡片/Grid、拖拽排序、文本/确认弹窗 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;创建/重命名/删除/批量删除/排序 |
| 3 | 管理 | 转码 `PageTranscoding` | admin-transcoding-agent | done | 表单组件、折叠区、编解码器模型与复杂校验 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;加载、修改、保存、校验失败、编解码器参数弹窗 |
| 3 | 管理 | 用户 `PageUsers` | admin-users-agent | done | 用户卡片模型、Add/Edit User 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增、编辑、启停、删除及权限错误 |
| 3 | 管理 | 任务 `PageTasks` | admin-tasks-agent | done | 任务列表模型、TaskEdit 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;启动、停止、编辑任务及轮询刷新 |
| 3 | 管理 | 日志 `PageLogs` | admin-logs-agent | done | 日志树/列表、文件保存桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;日志列表、内容加载、失败态、另存为 |
| 4 | 播放 | 播放页面/OSD `views/media/PlayerView` 与 `components/player*` | unassigned | blocked | QML 视频承载方案、PlaybackManager 契约、所有 OSD/字幕/弹幕组件 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;libmpv 播放/暂停/seek/音量/字幕/弹幕/全屏/退出,重复 10 次无崩溃 |
| 4 | 对话框 | 媒体编辑/识别/图片、播放列表、下载、WebDAV、代理、许可证等 `components/*dialog*` | unassigned | blocked | QML Overlay/Dialog 框架;文件选择器与原生能力桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;按对话框建立交互矩阵,覆盖接受/取消/错误/窗口关闭 |
| 4 | 平台集成 | 系统托盘、无边框窗口、文件选择器、外部播放器/IPC | platform | legacy | 纯 QML 壳稳定后再决定是否包装;不要求强行 QML 重写 | `cmake --build cmake-build-debug --target qEmbyApp -j2`;Linux/Windows/macOS 各执行关闭到托盘、恢复、退出和窗口按钮冒烟 |
## 建议阶段与依赖顺序
1. **固定 QML 契约**:先完成登录、搜索,以及弹窗/Toast、MediaCard/List/Grid;统一 ViewModel 的 loading/error/empty/cancelled 生命周期。没有这些基础件,后续页面会复制临时控件和异步处理。
2. **迁移高复用浏览链路**:Dashboard、Favorites、Category、Library/Person/Filtered。它们共享卡片、图片、分页和导航,是验证模型契约与性能的最佳批次。
3. **替换应用壳**:浏览链路稳定后,再把 MainWindow/HomeView 的标题栏、侧栏、路由栈换成 QML;期间 `QuickPageHost` 继续充当兼容层。壳迁移必须保留托盘、窗口按钮、主题切换和返回栈行为。
4. **迁移复杂业务页**:详情/季与六个剩余管理页。先提取 C++ ViewModel/`QAbstractListModel`,QML 不直接持有 service 协程或业务缓存。
5. **最后处理播放与长尾弹窗**:播放器涉及 libmpv/OpenGL/原生窗口和输入焦点,是最高风险项。平台集成可以保留 C++,目标应是“QML 表现层 + C++ 原生能力”,而不是消灭所有 QWidget/C++。
当前开发批次(2026-07-15):导航 Phase 0/1 已落地,Phase 2 首批完成。新增强类型 `NavigationRoute`、`NavigationController` 和轻量契约测试;Widgets 页面统一先转换为规范 route,再进入旧页面栈。`QuickRouterHost` 以单个 QML engine 承载 `Router.qml`,Home/Favorites 共享该 host 与图片 provider,并由 `HomeQuickRouterView` 协调 ViewModel 生命周期和 BaseView 媒体动作。其他 route 暂时保持旧 Widgets host,切入时 Router 保留底层页面且暂停生命周期,避免过渡闪屏。可用 `QEMBY_USE_LEGACY_HOME_PAGES=1` 临时回退旧 Home/Favorites 双 host。下一批先接 Search,再处理 Category/Library 的参数加载与 LRU ViewModel 所有权。
阶段门禁:每阶段至少通过 QML 静态检查、Debug 构建、明暗主题、窗口缩放、键盘焦点、页面反复进入退出、网络失败/空数据检查;依赖真实服务器的行为必须用 Emby/Jellyfin 各冒烟一次。
## 公共组件与基础设施缺口
- **导航**:声明式 route、参数、返回栈、页面缓存/销毁策略;当前契约散落在 `HomeView::create*View()` 与动态 `routeType` 属性中。
- **异步状态**:统一 `loading/error/empty/refreshing`、请求去重、页面销毁后的取消/弱引用;当前 Dashboard VM 是可参考样例,但尚无通用基类。
- **媒体模型**:统一 `QAbstractListModel` roles、分页、排序、筛选、收藏/已播状态增量更新,避免 QML 读取复杂 Core DTO。
- **视觉组件**:MediaCard、Section、IconButton、Menu/ContextMenu、Dialog/MessageBox、Toast、Busy/Skeleton、Error/Empty、Tabs、Tag/Chip、拖拽排序、文件/目录选择入口。
- **图片**:现有 `EmbyImageProvider`/`AdaptiveIconProvider` 只在管理仪表盘 host 中显式注册;全局 QML 引擎需要统一注册、缓存策略、取消和失败占位。
- **可访问性与输入**:焦点环、Tab 顺序、快捷键、右键菜单、触屏/高 DPI、屏幕阅读器语义。
- **测试**:仓库当前没有 qEmbyApp 页面级自动测试;应增加 Qt Quick Test(组件/VM)与最小导航冒烟,避免只靠截图判断。
- **国际化**:新 QML 文案需进入现有 `qEmby_zh_CN.ts`/`qEmby_fr_FR.ts` 流程,并核验运行时语言切换/重启语义。
## 高风险边界
1. **QQuickWidget 只是过渡层**:它使用离屏 FBO;仓库已经为透明合成黑底加了不透明 clear color。嵌入页越多,渲染、焦点、弹窗层级和显存成本越高,应避免把每个小组件都拆成独立 `QQuickWidget`。
2. **播放器**:`PlayerView`、`MpvWidget`、OpenGL 与大量 `player*` 控件耦合,且有全屏、长按、字幕、弹幕、媒体源切换。先做单一播放器原型和压力冒烟,再决定 `QQuickFramebufferObject`/原生纹理/保留 QWidget 视频面方案。
3. **生命周期与协程**:页面快速返回、切服务器或退出时,QCoro 任务、WebSocket 与定时刷新不能回调已销毁的 QML 对象。管理仪表盘已有 active/hidden 控制,但尚未形成全局规则。
4. **业务逻辑回流 QML**:现有 Widgets 页面体量大(尤其详情、转码、媒体库管理);迁移应先抽取 ViewModel/模型,不能把 API 编排、配置序列化和缓存策略复制进 JavaScript。
5. **导航状态兼容**:`HomeView` 当前重建历史页面并依赖 routeId/routeTitle/routeExtraId;切换为 QML 路由时必须保留人物、过滤、季、管理、设置等返回参数和滚动状态。
6. **原生能力**:托盘、无边框窗口、文件保存、外部播放器 IPC 适合继续放在 C++;“全面迁移到 QML”应限定为 UI/交互层迁移。
7. **主题双栈**:Widgets 仍走 QSS/`ThemeManager`,QML 走 `QuickTheme`/Themed controls。过渡期必须以同一 token 为源,验证实时明暗切换,避免两套颜色和尺寸继续分叉。
## 每个迁移任务的完成定义
- 实际导航已切到 QML,旧 Widgets 页面不再是默认路径;需要回退时必须有明确 feature flag。
- C++ 只暴露稳定属性、模型、信号和 invokable;QML 不直接编排网络 service 或持有 Core DTO。
- `qEmbyApp_qmllint` 与 `qEmbyApp` 构建通过,无新增 QML runtime error/warning。
- loading/error/empty/disabled、键鼠焦点、明暗主题、窗口缩放和中英文至少完成一次验证。
- 页面进入/退出及刷新至少重复 10 次;涉及异步或播放器的任务额外验证退出、切服务器和应用关闭。
- 删除旧实现应单独提交,在 QML 路径稳定后进行,便于回退与差异核对。
+404
View File
@@ -0,0 +1,404 @@
# QML 导航契约与兼容迁移规格
状态:Draft(Phase 0/1 兼容基础已实现,尚未形成已接受 ADR)
更新时间:2026-07-15
范围:应用登录/主页切换、`HomeView` 业务页面栈、设置/管理子页面导航,以及迁移到 QML 时的最小 C++/QML 边界。
非目标:本规格不决定播放器渲染技术、不迁移任何页面、不替换平台窗口/托盘代码,也不规定业务 ViewModel 的数据接口。
## 1. 证据口径
本文严格区分:
- **Observed fact(已观察事实)**:直接来自当前源码。
- **Proposed decision(建议决策)**:为 QML 迁移定义的目标契约,只有在评审接受后才是实现约束。
- **Needs confirmation(待确认)**:产品或架构选择,当前源码不足以给出唯一答案。
主要证据文件:
- `src/qEmbyApp/mainwindow.{h,cpp}`
- `src/qEmbyApp/views/user/homeview.{h,cpp}`
- `src/qEmbyApp/views/baseview.{h,cpp}`
- `src/qEmbyApp/views/settings/settingsview.cpp`
- `src/qEmbyApp/views/admin/manageview.cpp`
- `src/qEmbyApp/quick/quickpagehost.{h,cpp}`
## 2. 当前导航模型(Observed facts)
### 2.1 两层页面切换
1. `MainWindow` 持有 `QStackedWidget m_viewStack`,其中长期存在一个 `LoginView` 和一个 `HomeView`。
2. `LoginView::loginCompleted` 调用 `MainWindow::navigateToHome()`;`HomeView::logoutRequested` 调用 `MainWindow::navigateToLogin()`。
3. 登录/主页之间使用淡入动画;配置 `UiAnimations` 命中的现有分支会直接切换。
4. `navigateToLogin()` 在切到登录页后调用 `AuthService::logout()`。退出应用也会调用该方法。
5. `HomeView` 内部使用 `SlidingStackedWidget m_contentSwitcher` 和 `QStack<RouteInfo> m_navStack` 管业务页面。
6. `DashboardView` 与 `FavoritesView` 在 `HomeView::setupUi()` 中创建并长期保留;当前没有给这两个 QWidget 设置 `routeType/routeId/routeTitle/isDynamic`。
### 2.2 当前 RouteInfo 与动态属性
`RouteInfo` 当前字段为:
```cpp
QPointer<QWidget> widget;
bool isDynamic;
QString routeType;
QString routeId;
QString routeTitle;
QString routeExtraId;
```
`pushView()` 在切换前从当前 QWidget 的同名动态属性复制元数据到历史栈。页面实例可能被删除,因此返回逻辑也依赖这些字符串重建页面。
| create/静态实例 | 实际 QWidget | `isDynamic` | `routeType` | `routeId` | `routeTitle` | `routeExtraId` | 初始化事实 |
|---|---|---:|---|---|---|---|---|
| `m_dashboardView` | `DashboardView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goHome()` 复用;已在首页时再次调用会刷新数据 |
| `m_favoritesView` | `FavoritesView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goFav()` 复用;已在收藏时再次调用会刷新数据 |
| `createDetailView(itemId,itemName,seedItem)` | `DetailView` | true | `DetailView` | `itemId` | `itemName` | 未设置 | `loadItem(itemId, seedItem)`;历史重建时没有 `seedItem` |
| `createCategoryView(categoryId,title)` | `CategoryView` | true | `CategoryView` | `categoryId` | `title` | 未设置 | 异步排队调用 `loadCategory(categoryId,title)`;收藏分类入口给 id 加 `Favorite_` 前缀 |
| `createLibraryView(libraryId,title)` | `LibraryView` | true | `LibraryView` | `libraryId` | `title` | 未设置 | `loadLibrary(libraryId,title)` |
| `createPersonView(personId,personName)` | `LibraryView` | true | `PersonView` | `personId` | `personName` | 未设置 | 复用 `LibraryView`,调用 `loadPerson(personId,personName)` |
| `createSearchView(query)` | `SearchView` | true | `SearchView` | `query` | 未设置 | 未设置 | `performSearch(query)` |
| `createFilteredView(filterType,filterValue)` | `LibraryView` | true | `FilteredView` | `filterType + ":" + filterValue` | `filterValue` | `filterType` | 复用 `LibraryView`,调用 `loadFiltered(filterType,filterValue)` |
| `createSeasonView(seriesId,seasonId,seasonName)` | `SeasonView` | true | `SeasonView` | `seasonId` | `seasonName` | `seriesId` | `loadSeason(seriesId,seasonId,seasonName)` |
| `createPlayerView(mediaId,title,streamUrl,ticks,extraData)` | `PlayerView` | true | `PlayerView` | `mediaId` | `title` | 未设置 | 等切页动画结束后 `playMedia(...)`;进入/退出播放器采用瞬切 |
| `createSettingsView()` | `SettingsView` | true | `SettingsView` | `settings_global` | 翻译后的 Settings | 未设置 | 侧栏仅避免连续打开当前 `SettingsView` |
| `createManageView()` | `ManageView` | true | `ManageView` | `manage_global` | 翻译后的 Server Management | 未设置 | 侧栏仅避免连续打开当前 `ManageView` |
注意:当前 `navigateBack()` 能按元数据重建 Detail、Library、Category、Search、Settings、Person、Season、Manage、Filtered;没有 `PlayerView` 重建分支。找不到实例且无法重建时回退到 Dashboard。
### 2.3 create 方法和导航信号的事实映射
`BaseView` 暴露以下导航信号:
| 现有信号 | 参数 | `HomeView` 中的实际目标 |
|---|---|---|
| `navigateToDetail` | `itemId,itemName,seedItem` | `pushView(createDetailView(...))` |
| `navigateToFolder` | `folderId,folderName` | `pushView(createLibraryView(...))` |
| `navigateToPerson` | `personId,personName` | `pushView(createPersonView(...))` |
| `triggerSearch` | `query` | trim、记录服务器维度搜索历史、`pushView(createSearchView(query))` |
| `navigateToFilteredView` | `filterType,filterValue` | `pushView(createFilteredView(...))` |
| `navigateBack` | 无 | `HomeView::navigateBack()`(只在部分页面显式连接) |
| `navigateToCategory` | `categoryId,title` | Dashboard/Favorites 的专用连接进入 Category;Favorites 会加 `Favorite_` 前缀 |
| `navigateToPlayer` | `mediaId,title,streamUrl,ticks,extraData` | `launchPlayer()` → `PlaybackManager::startPlayback()`;嵌入播放请求最终 `pushView(createPlayerView(...))` |
| `navigateToSeason` | `seriesId,seasonId,seasonName` | `pushView(createSeasonView(...))` |
页面连接并不完全一致:Detail 额外连接过滤和搜索;Season 只连接详情、播放和返回;Search/Category/Library 连接其各自支持的详情/文件夹/人物/播放/季入口。目标导航控制器不应依赖每个页面重复手写这些连接。
### 2.4 push/back/home 的现有生命周期
- `pushView(view)`:把当前页面压栈;把目标加入 switcher;排队调用目标的 `scrollToTop()`;非 Player 向左滑入,Player 瞬切。
- 活跃历史中最多保留 12 个仍有 QWidget 指针的动态页面。超过限制的旧动态页面从 switcher 移除并 `deleteLater()`,但历史元数据仍保留以便重建。
- `navigateBack()`:先让当前 `BaseView::handleBackNavigation()` 尝试消费返回;未消费时调用 `prepareForStackLeave()`,弹出历史;必要时重建;完成切换后销毁离开的动态页。
- `resetToView(Dashboard/Favorites)`:清空整个历史栈、调用当前页 `prepareForStackLeave()`、滚动目标到顶并销毁相关动态页面。
- `goHome()/goFav()` 在已经位于目标静态页面时刷新,而不是产生新历史。
- `MainWindow` 返回按钮顺序是:业务栈 back → 回 Dashboard → 2 秒内再次返回触发退出登录提示/动作。
- `BaseView::scrollToTop()` 默认为空;当前检索到 Dashboard 和 Detail 有 override。没有通用的滚动位置序列化/恢复契约。
- `PlayerView` override `prepareForStackLeave()`;Manage 的 show/hide 连接/断开 WebSocket;离开 Manage 后 `HomeView` 还会刷新侧栏媒体库。
### 2.5 设置与管理子导航
- 设置是一个全局 `SettingsView` route,内部 5 个索引依次是 `general`、`appearance`、`library`、`player`、`about`。页面惰性实例化;当前外层 route 不记录选中索引或滚动状态。
- 管理是一个全局 `ManageView` route,内部 7 个索引依次是 `dashboard`、`libraries`、`collections/lists`、`transcoding`、`users`、`tasks`、`logs`。当前外层 route 不记录选中索引或滚动状态。
- 设置五个页面主体已由 `QuickPageHost` 加载 QML;管理仅 Dashboard 主体由该 host 加载。两者的左侧导航和父级页面栈仍是 Widgets。
- `QuickPageHost` 通过独立 `QQuickWidget` 加载 URL、注入 context property/image provider,并把 QWidget show/hide 转成 `pageShown/pageHidden`。
## 3. 目标职责边界(Proposed decision)
采用一个 C++ `NavigationController` 作为**唯一规范导航状态所有者**,QML `Router.qml` 作为声明式页面注册、实例化和视觉切换层:
```text
QML 页面/侧栏/标题栏
│ push/replace/back/home
▼
NavigationController (C++:校验、权限、规范栈、会话清理)
│ currentRoute/transition request
▼
Router.qml (QML:route→Component、Loader/StackView、过渡、UI state 回写)
│
├─ QML Page + C++ ViewModel
└─ 迁移期 LegacyWidgetRouteHost
```
约束:
1. 页面不得自行维护第二份全局历史,也不得直接实例化另一个顶级页面。
2. C++ Controller 不持有 QML `Item*` 作为历史真相;历史必须只靠可验证 route 数据恢复。
3. QML Router 不做认证、管理员权限或 deep-link 参数校验;这些属于 Controller。
4. 业务加载/错误属于页面 ViewModel;“未知 route、参数非法、权限拒绝、无法实例化”属于导航错误。
5. 迁移期间 Widgets 和 QML 入口都调用同一 Controller,旧 `HomeView` 栈仅作为尚未迁移页面的适配实现。
## 4. Route shape(Proposed decision)
C++/QML 边界使用 `QVariantMap`,内部 C++ 应使用强类型 `Route` 后再导出,避免核心逻辑依赖无类型 map。
```qml
{
name: "detail", // 稳定、非翻译的 route 名
key: "detail:item-123", // Controller 规范化生成的实例标识
title: "Movie title", // 展示提示;不得作为重建必需参数
params: { itemId: "item-123" },
presentation: "page", // page | immersive
cachePolicy: "lru", // singleton | lru | destroyOnPop
uiState: { scrollY: 0 }, // 可丢失的页面表现状态
version: 1
}
```
规则:
- `name + params` 是恢复页面的规范输入;`title` 只用于即时展示。
- `key` 由 Controller 根据 route 注册表生成,调用方不能伪造。
- `params` 只允许 JSON/QVariant 可安全复制的标量、list、map;不得把 `QObject*`、QWidget 指针、原始 `MediaItem` 或带凭据的流 URL写入历史。
- `uiState` 可更新、可淘汰,不参与 route 身份;第一阶段至少支持 `scrollY` 和嵌套页面 `section`。
- 播放的 `streamUrl/extraData` 目前可能包含瞬态或复杂数据。它们保留在 PlaybackManager 的启动上下文,不进入可持久化 deep link;Player route 历史只保存 `mediaId/title/startPositionTicks`,且默认 `destroyOnPop`。
- `seedItem` 是详情加载优化,不是恢复必需数据;可作为一次性 Controller payload 传递,但不能成为 route schema 的必填项。
### 4.1 规范 route 注册表
| `name` | 必填 `params` | 可选 `params` | 兼容来源 | 默认缓存 |
|---|---|---|---|---|
| `login` | 无 | 无 | MainWindow LoginView | singleton(应用级) |
| `home` | 无 | 无 | DashboardView | singleton(会话级) |
| `favorites` | 无 | 无 | FavoritesView | singleton(会话级) |
| `detail` | `itemId` | `title` | DetailView | lru |
| `category` | `categoryId` | `title`, `favorite` | CategoryView;兼容期解析 `Favorite_` 前缀 | lru |
| `library` | `libraryId` | `title` | LibraryView | lru |
| `person` | `personId` | `title` | PersonView/LibraryView | lru |
| `search` | `query` | 无 | SearchView | lru |
| `filtered` | `filterType`,`filterValue` | `title` | FilteredView | lru |
| `season` | `seriesId`,`seasonId` | `title` | SeasonView | lru |
| `player` | `mediaId` | `title`,`startPositionTicks` | PlayerView | destroyOnPop |
| `settings` | 无 | `section` | SettingsView | lru |
| `manage` | 无 | `section` | ManageView | lru;admin guard |
| `navigationError` | `code` | `message`,`failedRoute` | 当前无等价页 | destroyOnPop |
Settings `section` allowlist:`general|appearance|library|player|about`。
Manage `section` allowlist:`dashboard|libraries|collections|transcoding|users|tasks|logs`。
**Needs confirmation:** `collections` 是否作为面向用户的稳定 deep-link 名,还是沿用界面文字 `lists`;建议内部稳定名使用 `collections`。
## 5. NavigationController 最小契约(Proposed decision)
以下是语义契约,不强制具体头文件拼写:
```cpp
class NavigationController : public QObject {
Q_OBJECT
Q_PROPERTY(QVariantMap currentRoute READ currentRoute NOTIFY currentRouteChanged)
Q_PROPERTY(bool canGoBack READ canGoBack NOTIFY stackChanged)
Q_PROPERTY(int depth READ depth NOTIFY stackChanged)
public:
Q_INVOKABLE bool push(const QVariantMap &request);
Q_INVOKABLE bool replace(const QVariantMap &request);
Q_INVOKABLE bool back();
Q_INVOKABLE void home();
Q_INVOKABLE void updateUiState(const QVariantMap &patch);
// 仅认证/会话协调层调用,不暴露给任意内容页。
void enterAuthenticatedSession();
void leaveAuthenticatedSession(LogoutReason reason);
signals:
void currentRouteChanged();
void stackChanged();
void navigationRejected(QString code, QString message,
QVariantMap request);
void sessionRoutesCleared();
};
```
操作语义:
- `push`:校验并规范化 request;保存当前 `uiState`;压入新 route。新页面 `scrollY=0`。默认允许相同 route 重复入栈。
- `replace`:校验成功后用新 route 替换栈顶,不保留被替换项;适用于登录完成、参数规范化和不可返回的重定向。
- `back`:先请求当前页面处理局部返回(关闭 overlay、退出子模式);未消费才弹全局栈。目标恢复已保存 `uiState`。
- `home`:清空业务历史并切到 `home`;已经在 `home` 时发出 `refreshRequested(key)`,对齐现有刷新语义。
- 收藏入口提供等价的 `resetTo({name:"favorites"})` 内部 API;它和 `home()` 一样清业务栈。若不希望扩大公开 API,可由 `replace` 的受控 `clearHistory` option 实现,但普通页面不能传此 option。
- `updateUiState`:只合并 allowlist 字段,节流写入当前栈项;不产生页面切换。
- 所有失败操作都必须原子化:返回 false、栈不变、发 `navigationRejected`。
**Needs confirmation:** 当前“点收藏清栈”的行为是否继续保留。建议保留以匹配 `resetToView(m_favoritesView)`,并将 `favorites` 与 `home` 都视为会话根页面。
### 5.1 局部返回与离开钩子
为兼容 `BaseView::handleBackNavigation()` 和 `prepareForStackLeave()`,QML 页面统一可选实现:
```qml
function handleBackRequest() { return false }
function prepareForRouteLeave(reason) { }
function captureRouteUiState() { return { scrollY: flickable.contentY } }
function restoreRouteUiState(state) { ... }
```
Router 在 Controller `back/home/replace/sessionClear` 提交前调用前两个钩子。`handleBackRequest()` 返回 true 时,不调用 Controller `back()`。异步清理不能无限阻塞导航;耗时业务取消应由 ViewModel 的 active/generation/cancellation 机制完成。
## 6. QML Router 最小职责(Proposed decision)
1. 维护静态 route name → `Component` 注册表,不根据任意 URL 拼 QML 文件路径。
2. 监听 `currentRoute` 并实例化对应组件;把 `params`、一次性 payload、Controller/ViewModel 注入页面。
3. 根据 `cachePolicy` 执行:
- `singleton`:会话内唯一实例,离开时停用但不销毁;
- `lru`:最多 12 个**活页面实例**,与现有上限一致;被淘汰后保留 route/uiState,返回时重建;
- `destroyOnPop`:离开即销毁,Player 默认使用。
4. 页面 `visible`/active 变化必须传给 ViewModel,以替代 `QuickPageHost::pageShown/pageHidden`,停止轮询、WebSocket 或过期请求。
5. Fresh push 从顶部开始;back 恢复保存的滚动位置;home/favorites reset 后目标滚到顶部。滚动恢复要在模型/布局可用后 clamp 到合法范围。
6. Player 使用 immersive presentation 并禁用普通滑页动画;其余页面由统一 transition policy 决定,reduce-motion 时无动画。
7. 实例创建或 Component error 时报告 Controller,并展示 `navigationError` 或安全回退;禁止黑屏或静默回 home。
## 7. 深链与参数(Proposed decision)
第一阶段先支持内部 deep link(规范 route map),外部 URI 注册推迟到纯 QML 壳稳定后。建议 URI 形态:
```text
qemby://detail/<itemId>
qemby://library/<libraryId>
qemby://season/<seriesId>/<seasonId>
qemby://settings/<section>
qemby://manage/<section>
qemby://search?q=<percent-encoded-query>
```
处理规则:
- Controller 维护 route/参数 allowlist、长度限制和 percent-decoding;未知字段忽略还是拒绝必须逐 route 固定,建议拒绝必填字段冲突、忽略未知可选字段并记录日志。
- 未登录时收到业务 deep link:保存**一个经过校验的 pending route**,切到 login;登录成功后 replace 为 home,再 push pending route。
- `manage` 在未登录、非管理员或服务器不支持时拒绝,不实例化管理页。
- server-scoped item deep link 必须绑定当前 server;跨服务器解析/选择流程当前无事实依据。
- 不允许在 URI、route 或日志中携带 access token、密码、完整 stream URL。
**Needs confirmation:** 是否支持 OS 外部 deep link、跨服务器 deep link,以及登录后 pending link 的过期时间。建议首期只保证应用内 route map,外部 URI 单独立项。
## 8. 登录、退出与服务器切换(Proposed decision)
### 登录成功
1. Controller 建立新的 session generation。
2. 清除任何上一个会话的页面实例、历史和 uiState。
3. `replace(home)`,不允许 back 回 login。
4. 若存在合法 pending deep link,再 `push` 目标。
### 退出登录
1. 先让 Player/当前页执行 leave hook,并停止会话级任务。
2. 原子清空业务栈、LRU cache、一次性 payload、pending deep link、滚动状态和所有 session singleton。
3. 断开 Manage WebSocket/页面轮询。
4. 调用认证服务 logout,然后 `replace(login)`;不允许 back 回任何业务页。
### 切换服务器/用户
按“退出旧 session + 建立新 session”处理,不能复用旧 server 的 route 页面或滚动状态。搜索历史本身当前按 server id 隔离,这不代表页面栈可跨 server 复用。
当前 Widgets 行为在侧栏 logout 时会先 reset Dashboard,但 `MainWindow::navigateToLogin()` 本身不直接拥有 `HomeView` 清栈 API。目标契约必须把清栈变成 Controller 的会话不变量,而不是依赖退出入口恰好来自侧栏。
## 9. 设置与管理页(Proposed decision)
- 顶层保持一个 `settings` 和一个 `manage` route,不把每个 section 都变成全局历史项。
- `params.section` 支持侧栏/深链直达;同一页面内切 section 使用 `replace` 更新当前 route 参数,因此 back 返回离开 Settings/Manage 前的业务页面,而不是逐 tab 返回。
- section 自己保存独立 `scrollY`,建议 `uiState.sections[section].scrollY`;LRU 重建后恢复所选 section 和滚动。
- Manage route 必须由 Controller 做 admin guard;页面仍应处理服务端 401/403,因为登录后权限可能变化。
- 离开 Manage(包括 back/home/logout/server switch)必须停 WebSocket;进入时才连接。迁移期继续触发当前“离开管理后刷新侧栏媒体库”的兼容动作。
- 设置/管理内部 QML 页面不应各自创建 `QQuickWidget`。壳迁移后在同一 QML engine 中用 Component/Loader 承载;`QuickPageHost` 只保留给尚未迁移的 Widgets 壳阶段。
**Needs confirmation:** 切换设置/管理 section 是否应该进入浏览器式 back 历史。建议不进入,以保持当前全局栈行为。
## 10. 错误处理(Proposed decision)
| 错误 | Controller/Router 行为 | 用户可恢复动作 |
|---|---|---|
| 未知 route name | 拒绝、栈不变、日志 + `navigationRejected(unknownRoute)` | 保持当前页 |
| 缺失/非法参数 | 拒绝、栈不变,不启动 ViewModel 请求 | 修正入口;外部 link 显示不可打开提示 |
| 未认证 | 保存合法 pending route,replace login | 登录或取消 |
| 非管理员访问 manage | 拒绝;不得短暂显示管理内容 | 返回当前页/提示权限不足 |
| QML Component 加载失败 | 标记 `componentLoadFailed`,展示 navigationError;保留可 back/home 的壳 | 重试、back、home |
| 页面数据请求失败 | route 保持不变,由页面 ErrorState/ViewModel 处理 | 重试、back |
| LRU 页面已销毁 | 由 route params 重建并恢复可用 uiState | 无需用户干预 |
| 历史数据版本不兼容 | 丢弃该项并继续向前找安全 route;记录原因 | 最终回 home/login |
| leave hook/动画期间重复导航 | Controller 串行化或合并;不能出现双 pop/重复实例 | 操作完成后状态一致 |
不得使用“任何错误都静默回 Dashboard”的策略;现有 fallback 只作为兼容期最后保险。
## 11. 分阶段兼容方案(Proposed decision)
### Phase 0:固定契约与测试夹具
- 新增强类型 Route/注册表和 Controller 单元测试,不改变实际导航入口。
- 建立 legacy 属性 ↔ route 的双向转换;记录不完整历史项但仍走现有 HomeView。
- 为 login/home/favorites 补稳定 route 名(可先只在适配器中表达,不要求立刻改 QWidget 属性)。
### Phase 1:Controller 旁路接管
- 所有 Widgets 信号先转换成 route request,再由 legacy adapter 调用现有 `create*View/pushView/resetToView`。
- 对比 Controller 栈与 `m_navStack`,Debug 下发现漂移立即记录。
- 会话退出、服务器切换首先统一走 Controller clear-session 流程。
### Phase 2:单引擎 QML Router + 已迁移页面
- 在现有 Widgets MainWindow/HomeView 内放置**一个**根 Quick host,Router 接管 Login、Search、Settings 主体、Manage Dashboard 等已迁移页面。
- 未迁移 route 通过 `LegacyWidgetRouteHost`/明确的边界回到 Widgets;不要为每个 QML 子组件新增独立 QQuickWidget。
- Controller 成为唯一历史;停止让 QML 路径同时写 `m_navStack`。
### Phase 3:QML Home shell
- 迁移侧栏、全局搜索、标题导航和内容 StackView;保留 C++ 原生窗口 agent、托盘及必要平台能力。
- Dashboard/Favorites/Category/Library/Detail/Season 逐个切 route factory;每个入口保留 feature flag 回退一个发布周期。
- 设置/管理 section 和滚动状态切到本文契约。
### Phase 4:移除 legacy 业务栈
- 最后迁移 Player/长尾页面或明确其原生 host 边界。
- 删除 `HomeView::create*View()`、`RouteInfo`、重复 signal wiring 和 `QuickPageHost` 多实例路径前,必须完成全链路验收。
- 平台窗口、托盘、文件选择器等 C++ 能力无需为了“纯 QML”被删除。
## 12. 验收标准
### 契约与自动测试
- 每个注册 route 的合法/缺参/错参用例都有 Controller 测试;失败时栈严格不变。
- push → push → back、replace、home/favorites reset、空栈 back 均有确定结果。
- LRU 超过 12 个实例后,历史 route 仍能重建;Player 不被重建为带失效 stream context 的页面。
- uiState 不参与 key;fresh push 到顶,back 恢复滚动,超出新 contentHeight 时正确 clamp。
- logout、切 server/user 后 depth、cache、pending payload、scroll state 均不含旧 session 数据。
- 未登录 deep link、非法 deep link、非管理员 manage deep link 有测试。
### 兼容行为
- Dashboard、Favorites、Detail、Category、Library、Person、Search、Filtered、Season、Settings、Manage、Player 的所有现有入口都有 route 映射。
- `BaseView` 九类导航信号在兼容适配器中无遗漏;同一入口不会同时 push 两个栈。
- 返回优先关闭当前页面局部状态,再 pop;离开 Player 执行 stop/report 语义,离开 Manage 停 WebSocket。
- 标题栏 back/home/favorites、侧栏入口、全局搜索、登录成功、侧栏退出、双击返回退出都到达同一 Controller API。
- Settings/Manage 直达 section、返回到来源页、重建后恢复 section/滚动均可用。
### 运行与视觉
- `qEmbyApp_qmllint` 与 Debug 构建通过,无新增 runtime QML error/warning。
- 登录 → Home → Library → Detail → Season/Player → back → Settings → Manage → logout 完整链路重复 10 次无崩溃、无幽灵页面、无跨账号数据。
- reduce-motion 与普通动画两种配置下快速连续 back/push 不产生双 pop。
- Emby/Jellyfin、管理员/普通用户分别冒烟;窗口缩放、键盘 Back/Escape、鼠标侧键、托盘恢复后导航状态一致。
- QML Component 故障和网络错误分别验证:前者进入导航错误 UI,后者留在当前业务 route 的错误态。
## 13. 待评审决定汇总
1. Favorites 是否继续作为会话根并在进入时清空历史(建议:是,兼容现状)。
2. Settings/Manage section 切换是否写入全局 back 历史(建议:否)。
3. 管理合集 section 的稳定名使用 `collections` 还是 `lists`(建议:`collections`)。
4. 首期是否包含 OS 外部 deep link 和跨服务器解析(建议:不包含,只做内部 route map)。
5. pending deep link 的过期策略(建议:只存一个、仅当前登录流程有效,取消登录即清除)。
6. QML 壳过渡期采用何种 Legacy QWidget 承载技术,需要在 Player/原生复杂页原型后单独决策;本规格只要求单一规范栈,未把具体承载方案当作事实。
## 14. 实施记录
2026-07-15 完成 Phase 0/1 的首个兼容批次:
- 新增 `NavigationRoute` 与 `NavigationController`,支持规范 key、route 参数校验、认证/管理员门禁、push/replace/back、Home/Favorites reset、会话清理和 UI state 白名单。
- `HomeView` 将现有 legacy 动态属性统一转换为 route request;控制器校验成功后才操作 Widgets 页面栈。旧 `m_navStack` 暂时保留用于页面实例和动画兼容。
- `MainWindow` 的登录/退出切换已通知控制器建立或清理认证会话。
- 采用建议默认值:Favorites 是会话根;Settings/Manage section 不写全局历史;管理合集稳定名为 `collections`;当前不实现 OS/跨服务器 deep link。
- 新增 `qEmbyNavigationTest`,覆盖原子拒绝、栈操作、权限、UI state 与 logout 清理。下一批实现单引擎 `Router.qml`,届时控制器才从“旁路规范状态”升级为页面实例切换的直接驱动者。
2026-07-15 完成 Phase 2 首批 Home/Favorites:
- 新增应用级 `QuickRouterHost` 与 `Router.qml`;首批只静态注册 Home/Favorites,其他 route 不解析未注入的页面类型,也不会在 legacy Widgets 滑入期间闪现恢复页。
- 新增 `HomeQuickRouterView`,在同一 engine 中注入 Dashboard/Favorites 两个会话单例 ViewModel,并按规范 route 显式切换 active 生命周期。
- 播放、收藏、详情/媒体库/人物/分类导航、通用菜单及 Dashboard 管理媒体库专用菜单继续复用 `BaseView`/C++ action bridge,避免页面迁移造成功能回退。
- Home/Favorites 单引擎路径默认开启;设置环境变量 `QEMBY_USE_LEGACY_HOME_PAGES=1` 可回退旧双 `QuickPageHost`,用于一个发布周期内的兼容验证。
- 首批无服务器 offscreen 冒烟和 legacy fallback 冒烟均可稳定运行至测试超时,未出现 QML runtime error。期间发现并修复 `AdminTranscodingPage.DoubleField` 覆盖 `QQuickItem` final `bottom/top` 属性的问题。
+81
View File
@@ -10,6 +10,8 @@ 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
@@ -19,6 +21,81 @@ qt_add_executable(qEmbyApp
${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 生成的注册代码以 <quicktheme.h> 形式包含头文件
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
+2
View File
@@ -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();
+461
View File
@@ -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);
}
}
}
+540
View File
@@ -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)
}
}
}
+297
View File
@@ -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
}
}
+379
View File
@@ -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
}
}
}
+373
View File
@@ -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)
}
}
}
+421
View File
@@ -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
}
}
}
+194
View File
@@ -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
}
}
}
}
}
}
+310
View File
@@ -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);
}
}
}
}
}
}
+331
View File
@@ -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");
}
}
}
+53
View File
@@ -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
}
}
+306
View File
@@ -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
}
}
+353
View File
@@ -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()
}
}
}
}
+216
View File
@@ -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 }
}
}
+459
View File
@@ -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")
}
}
}
}
+274
View File
@@ -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()
}
}
}
}
}
}
+313
View File
@@ -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
}
}
}
}
}
+262
View File
@@ -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()
}
}
}
+214
View File
@@ -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: "<a href=\"https://github.com/AlanHJ/qEmby\">" + qsTr("GitHub Repository")
+ "</a> &nbsp;|&nbsp; <a href=\"https://github.com/AlanHJ/qEmby/issues\">"
+ qsTr("Report an Issue") + "</a>"
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 }
}
}
+211
View File
@@ -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
}
}
}
+123
View File
@@ -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
}
}
}
}
+475
View File
@@ -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
}
}
}
}
}
}
+918
View File
@@ -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: ~/<config>/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 }
}
}
@@ -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
}
}
}
@@ -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()
}
}
}
@@ -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
}
}
}
+289
View File
@@ -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()
}
}
}
+140
View File
@@ -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
}
}
}
+125
View File
@@ -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
}
}
}
@@ -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);
}
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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 } }
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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 {}
}
@@ -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 } }
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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 } }
}
}
+179
View File
@@ -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)
}
}
}
}
+81
View File
@@ -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
}
}
+29
View File
@@ -0,0 +1,29 @@
#include "aboutviewmodel.h"
#include "../components/licensesdialog.h"
#include <QCoreApplication>
#include <QDesktopServices>
#include <QUrl>
#include <QWidget>
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));
}
+28
View File
@@ -0,0 +1,28 @@
#ifndef ABOUTVIEWMODEL_H
#define ABOUTVIEWMODEL_H
#include <QObject>
#include <QPointer>
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<QWidget> m_dialogParent;
};
#endif // ABOUTVIEWMODEL_H
@@ -0,0 +1,737 @@
#include "adminlibrariesviewmodel.h"
#include "../components/librarycreatedlg.h"
#include "../components/libraryeditdlg.h"
#include "../components/mediaimageeditdialog.h"
#include "../components/moderntoast.h"
#include <api/embywebsocket.h>
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/manager/servermanager.h>
#include <QJsonObject>
#include <QTimer>
#include <QWidget>
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<int, QByteArray> 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<VirtualFolder> folders,
const QSet<QString> &selectedIds) {
beginResetModel();
m_folders = std::move(folders);
m_selectedIds = selectedIds;
QSet<QString> 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<QString> &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<void> AdminLibrariesViewModel::loadData(bool manual) {
if (!m_active || (m_loading && !manual))
co_return;
const quint64 generation = m_generation;
const quint64 loadGeneration = ++m_loadGeneration;
QPointer<AdminLibrariesViewModel> self(this);
setLoading(true, manual);
setError({});
try {
QList<VirtualFolder> 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<VirtualFolder> 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<QString> 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<void> AdminLibrariesViewModel::doCreateLibrary() {
if (!m_active || m_busy || !m_dialogParent)
co_return;
const quint64 generation = beginOperation();
QPointer<AdminLibrariesViewModel> 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<void> 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<AdminLibrariesViewModel> 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<void> 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<void> 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<AdminLibrariesViewModel> 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<void> AdminLibrariesViewModel::doDeleteSelected() {
if (!m_active || m_busy || m_selectedIds.isEmpty())
co_return;
const quint64 generation = beginOperation();
QPointer<AdminLibrariesViewModel> self(this);
const QSet<QString> selected = m_selectedIds;
setBusy(true);
int deleted = 0;
try {
const QList<VirtualFolder> 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<void> 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<AdminLibrariesViewModel> 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<void> AdminLibrariesViewModel::doScanAll() {
if (!m_active || scanActive())
co_return;
const quint64 generation = beginOperation();
QPointer<AdminLibrariesViewModel> 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<void> AdminLibrariesViewModel::doCancelScan() {
if (!m_active || m_runningTaskId.isEmpty())
co_return;
const quint64 generation = beginOperation();
QPointer<AdminLibrariesViewModel> 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<void> AdminLibrariesViewModel::saveOrder(QStringList orderedIds,
quint64 generation) {
QPointer<AdminLibrariesViewModel> 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<void> AdminLibrariesViewModel::pollScanProgress() {
if (!m_active)
co_return;
const quint64 generation = beginOperation();
QPointer<AdminLibrariesViewModel> 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<int>(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<AdminLibrariesViewModel> self(this);
QTimer::singleShot(1200, this, [self, generation] {
if (self && self->accepts(generation))
self->loadData(false);
});
}
@@ -0,0 +1,161 @@
#ifndef ADMINLIBRARIESVIEWMODEL_H
#define ADMINLIBRARIESVIEWMODEL_H
#include <QAbstractListModel>
#include <QObject>
#include <QPointer>
#include <QSet>
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
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<int, QByteArray> roleNames() const override;
void resetFolders(QList<VirtualFolder> folders,
const QSet<QString> &selectedIds);
const QList<VirtualFolder> &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<QString> &selectedIds);
void setScanProgress(const QString &itemId, int progress);
void clearAllScanProgress();
bool moveFolder(int from, int to);
private:
QList<VirtualFolder> m_folders;
QSet<QString> m_selectedIds;
QHash<QString, int> 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<void> loadData(bool manual);
QCoro::Task<void> doCreateLibrary();
QCoro::Task<void> doEditLibrary(QString itemId);
QCoro::Task<void> doEditImages(QString itemId);
QCoro::Task<void> doDeleteLibrary(QString itemId);
QCoro::Task<void> doDeleteSelected();
QCoro::Task<void> startLibraryRefresh(QString itemId, bool replaceAllMetadata,
bool replaceAllImages);
QCoro::Task<void> doScanAll();
QCoro::Task<void> doCancelScan();
QCoro::Task<void> saveOrder(QStringList orderedIds, quint64 generation);
QCoro::Task<void> 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<QWidget> m_dialogParent;
AdminLibrariesModel m_model;
QTimer *m_scanTimer = nullptr;
QList<QMetaObject::Connection> m_wsConnections;
QSet<QString> m_selectedIds;
QSet<QString> 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
+491
View File
@@ -0,0 +1,491 @@
#include "dashboardviewmodel.h"
#include "../components/modernmessagebox.h"
#include "../components/moderntoast.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QDateTime>
#include <QSet>
#include <QTimeZone>
#include <QTimer>
#include <QUrl>
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<int, QByteArray> 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<SessionInfo> &playing)
{
static constexpr qint64 kExpireMs = 30000;
const qint64 nowMs = QDateTime::currentMSecsSinceEpoch();
QSet<QString> 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<int, QByteArray> SessionListModel::roleNames() const
{
return {
{UserNameRole, "userName"},
{ClientLineRole, "clientLine"},
{InfoLineRole, "infoLine"},
};
}
void SessionListModel::reset(const QList<SessionInfo> &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<int, QByteArray> ActivityListModel::roleNames() const
{
return {
{NameRole, "name"},
{OverviewRole, "overview"},
{DateTextRole, "dateText"},
{SeverityRole, "severity"},
};
}
void ActivityListModel::reset(const QList<ActivityLogEntry> &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<void> DashboardViewModel::loadData(bool isManual)
{
if (!m_active || !m_core || !m_core->adminService() || m_loadingData)
co_return;
const quint64 generation = ++m_generation;
QPointer<DashboardViewModel> 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<SessionInfo> playingSessions;
QList<SessionInfo> 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<void> 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<DashboardViewModel> 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<void> 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<DashboardViewModel> 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();
}
}
+181
View File
@@ -0,0 +1,181 @@
#ifndef DASHBOARDVIEWMODEL_H
#define DASHBOARDVIEWMODEL_H
#include <QAbstractListModel>
#include <QObject>
#include <QPointer>
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
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<int, QByteArray> roleNames() const override;
void sync(const QList<SessionInfo> &playing);
private:
struct Entry
{
QString key;
SessionInfo session;
qint64 lastSeenMs = 0;
};
QList<Entry> 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<int, QByteArray> roleNames() const override;
void reset(const QList<SessionInfo> &sessions);
private:
struct Row
{
QString userName;
QString clientLine;
QString infoLine;
};
QList<Row> 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<int, QByteArray> roleNames() const override;
void reset(const QList<ActivityLogEntry> &entries);
private:
QList<ActivityLogEntry> 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<void> loadData(bool isManual);
QCoro::Task<void> doRestart();
QCoro::Task<void> doShutdown();
QEmbyCore *m_core;
QPointer<QWidget> 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
+148
View File
@@ -0,0 +1,148 @@
#include "embyimageprovider.h"
#include <qembycore.h>
#include <services/media/mediaservice.h>
#include <QCoreApplication>
#include <QPainter>
#include <QSvgRenderer>
#include <QUrlQuery>
#include <qcorotask.h>
#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<void> fetchCandidates(QPointer<QEmbyCore> core,
QList<ImageCandidate> candidates, int maxWidth,
QPointer<EmbyImageResponse> 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<ImageCandidate> 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<QEmbyCore> core = m_core;
QPointer<EmbyImageResponse> 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;
}
+55
View File
@@ -0,0 +1,55 @@
#ifndef EMBYIMAGEPROVIDER_H
#define EMBYIMAGEPROVIDER_H
#include <QImage>
#include <QPointer>
#include <QQuickAsyncImageProvider>
#include <QQuickImageProvider>
#include <QQuickImageResponse>
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<QEmbyCore> m_core;
};
// image://adaptiveicon/<qrc svg 路径>?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
+313
View File
@@ -0,0 +1,313 @@
#include "loginviewmodel.h"
#include "../components/modernmessagebox.h"
#include "../components/proxysettingsdialog.h"
#include "../components/webdavsyncdialog.h"
#include <QApplication>
#include <QTimer>
#include <QUrl>
#include <QWidget>
#include <config/config_keys.h>
#include <config/configstore.h>
#include <config/webdavprofilestore.h>
#include <qembycore.h>
#include <services/auth/authservice.h>
#include <services/manager/servermanager.h>
#include <algorithm>
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<int, QByteArray> LoginServerListModel::roleNames() const
{
return {{ServerIdRole, "serverId"}, {NameRole, "name"},
{UrlRole, "serverUrl"}, {IconSourceRole, "iconSource"}};
}
void LoginServerListModel::resetServers(const QList<ServerProfile> &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<bool>(ConfigKeys::RememberServer, false);
const QString lastId = ConfigStore::instance()->get<QString>(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<void> LoginViewModel::validateServer(QString serverId)
{
QPointer<LoginViewModel> 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<void> 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<LoginViewModel> 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();
}
+124
View File
@@ -0,0 +1,124 @@
#ifndef LOGINVIEWMODEL_H
#define LOGINVIEWMODEL_H
#include <QAbstractListModel>
#include <QObject>
#include <QPointer>
#include <QUrl>
#include <qcorotask.h>
#include <models/profile/proxyconfig.h>
#include <models/profile/serverprofile.h>
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<int, QByteArray> roleNames() const override;
void resetServers(const QList<ServerProfile> &servers);
private:
QList<ServerProfile> 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<void> validateServer(QString serverId);
QCoro::Task<void> performLogin();
QEmbyCore *m_core = nullptr;
QPointer<QWidget> 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
+295
View File
@@ -0,0 +1,295 @@
#include "logsviewmodel.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QDateTime>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QIODevice>
#include <QSaveFile>
#include <QStandardPaths>
#include <QStringList>
#include <QWidget>
#include <algorithm>
#include <stdexcept>
#include <utility>
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<int, QByteArray> LogsListModel::roleNames() const {
return {{NameRole, "name"},
{ModifiedRole, "modified"},
{ModifiedCompactRole, "modifiedCompact"},
{SizeRole, "sizeText"}};
}
void LogsListModel::resetLogs(QList<LogFileInfo> 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<void> LogsViewModel::loadFiles(bool manual) {
if (!m_active || (m_loading && !manual))
co_return;
const quint64 activation = m_activation;
const quint64 request = ++m_listRequest;
QPointer<LogsViewModel> guard(this);
m_loading = true;
m_refreshing = manual;
if (!m_hasLoaded)
m_errorMessage.clear();
emit stateChanged();
try {
QList<LogFileInfo> 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<void> LogsViewModel::loadContent(QString name) {
const quint64 activation = m_activation;
const quint64 request = ++m_contentRequest;
QPointer<LogsViewModel> 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<void> LogsViewModel::saveContent(QString name, QString path) {
const quint64 activation = m_activation;
QPointer<LogsViewModel> 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();
}
+114
View File
@@ -0,0 +1,114 @@
#ifndef LOGSVIEWMODEL_H
#define LOGSVIEWMODEL_H
#include <QAbstractListModel>
#include <QPointer>
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
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<int, QByteArray> roleNames() const override;
void resetLogs(QList<LogFileInfo> logs);
QString firstName() const;
const LogFileInfo *find(const QString &name) const;
private:
QList<LogFileInfo> 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<void> loadFiles(bool manual);
QCoro::Task<void> loadContent(QString name);
QCoro::Task<void> 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<QWidget> 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
+315
View File
@@ -0,0 +1,315 @@
#include "navigationcontroller.h"
#include <QMetaType>
#include <QSet>
#include <QUrl>
namespace
{
const QSet<QString> 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<QString> kSettingsSections = {
QStringLiteral("general"), QStringLiteral("appearance"),
QStringLiteral("library"), QStringLiteral("player"),
QStringLiteral("about")};
const QSet<QString> 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 &params, 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;
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "navigationroute.h"
#include <QObject>
#include <QVector>
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<NavigationRoute> m_stack;
bool m_authenticated = false;
bool m_administrator = false;
};
+15
View File
@@ -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},
};
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <QString>
#include <QVariantMap>
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;
};
+8
View File
@@ -0,0 +1,8 @@
#include "qmltask.h"
QmlTask::QmlTask(QObject *parent)
: QObject(parent)
{
}
QmlTask::~QmlTask() = default;
+73
View File
@@ -0,0 +1,73 @@
#ifndef QMLTASK_H
#define QMLTASK_H
#include <QObject>
#include <QPointer>
#include <QVariant>
#include <QString>
#include <qcorotask.h>
#include <type_traits>
// QmlTask — 将 QCoro::Task<T> 桥接为 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<T> 创建桥接实例。
// context 作为 QmlTask 的 parent,控制生命周期。
template<typename T>
static QmlTask* from(QCoro::Task<T> 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<typename T>
static QCoro::Task<void> launchBridge(QmlTask *self, QCoro::Task<T> task)
{
QPointer<QmlTask> guard(self);
try {
if constexpr (std::is_same_v<T, void>) {
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
+73
View File
@@ -0,0 +1,73 @@
#include "quickpagehost.h"
#include "../managers/thememanager.h"
#include "../tokens/ColorTokens.h"
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickWidget>
#include <QVBoxLayout>
#include <spdlog/spdlog.h>
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();
}
+39
View File
@@ -0,0 +1,39 @@
#ifndef QUICKPAGEHOST_H
#define QUICKPAGEHOST_H
#include <QUrl>
#include <QWidget>
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
+164
View File
@@ -0,0 +1,164 @@
#include "quickrouterhost.h"
#include "embyimageprovider.h"
#include "navigationcontroller.h"
#include "../managers/thememanager.h"
#include "../tokens/ColorTokens.h"
#include <QQmlContext>
#include <QQmlEngine>
#include <QQmlError>
#include <QQuickItem>
#include <QQuickWidget>
#include <QUrl>
#include <QVBoxLayout>
#include <spdlog/spdlog.h>
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);
}
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <QStringList>
#include <QWidget>
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;
};
+81
View File
@@ -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;
}
+113
View File
@@ -0,0 +1,113 @@
#ifndef QUICKTHEME_H
#define QUICKTHEME_H
#include <QColor>
#include <QObject>
#include <QtQml/qqmlregistration.h>
// 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
File diff suppressed because it is too large Load Diff
+400
View File
@@ -0,0 +1,400 @@
#ifndef SETTINGSVIEWMODEL_H
#define SETTINGSVIEWMODEL_H
#include <QObject>
#include <QString>
#include <QtQmlIntegration/qqmlintegration.h>
// ===== 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
+329
View File
@@ -0,0 +1,329 @@
#include "tasksviewmodel.h"
#include "../utils/taskutils.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QPointer>
#include <QStringList>
#include <QTimer>
#include <algorithm>
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<int, QByteArray> 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<ScheduledTaskInfo> 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<void> TasksViewModel::loadData(bool manual) {
if (!m_active || (m_loading && !manual))
co_return;
const quint64 activation = m_activation;
const quint64 request = ++m_requestSerial;
QPointer<TasksViewModel> guard(this);
m_loading = true;
m_refreshing = manual;
if (!m_hasLoaded)
m_errorMessage.clear();
emit stateChanged();
try {
QList<ScheduledTaskInfo> 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<void> TasksViewModel::changeTaskState(QString taskId, bool stop) {
const quint64 activation = m_activation;
QPointer<TasksViewModel> 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<ScheduledTaskInfo> &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();
}
+116
View File
@@ -0,0 +1,116 @@
#ifndef TASKSVIEWMODEL_H
#define TASKSVIEWMODEL_H
#include <QAbstractListModel>
#include <QSet>
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
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<int, QByteArray> roleNames() const override;
void resetTasks(QList<ScheduledTaskInfo> tasks);
void setActionBusy(const QString &taskId, bool busy);
void clearActionBusy();
QString taskName(const QString &taskId) const;
private:
QList<ScheduledTaskInfo> m_tasks;
QSet<QString> 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<void> loadData(bool manual);
QCoro::Task<void> changeTaskState(QString taskId, bool stop);
bool requestIsCurrent(quint64 activation, quint64 request) const;
void updateSummary(const QList<ScheduledTaskInfo> &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
+21 -4
View File
@@ -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 {
+23 -7
View File
@@ -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;
}
@@ -0,0 +1,75 @@
#include "navigationcontroller.h"
#include <QCoreApplication>
#include <QDebug>
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;
}
+4
View File
@@ -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"};
}
@@ -0,0 +1,383 @@
#include "collectionsviewmodel.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <QJsonArray>
#include <QJsonObject>
#include <utility>
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<int, QByteArray> CollectionsListModel::roleNames() const {
return {{ItemIdRole, "itemId"}, {NameRole, "name"},
{ChildCountRole, "childCount"}, {SubtitleRole, "subtitle"},
{ImageSourceRole, "imageSource"}, {SelectedRole, "selected"}};
}
void CollectionsListModel::setEntries(QList<CollectionListEntry> entries) {
QSet<QString> 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<void> 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<CollectionsViewModel> guard(this);
try {
const QJsonArray values =
co_await m_core->adminService()->getUserPlaylists();
if (!guard || !m_active || generation != m_loadGeneration)
co_return;
QList<CollectionListEntry> 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<void> CollectionsViewModel::create(QString name) {
setBusy(true);
QPointer<CollectionsViewModel> 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<void> CollectionsViewModel::rename(QString itemId, QString name) {
setBusy(true);
QPointer<CollectionsViewModel> 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<void> CollectionsViewModel::remove(QStringList itemIds) {
setBusy(true);
int deleted = 0;
QString failure;
QPointer<CollectionsViewModel> 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<void> CollectionsViewModel::saveOrder(QStringList oldOrder,
QStringList newOrder) {
if (oldOrder == newOrder || !m_core || !m_core->adminService())
co_return;
setBusy(true);
QPointer<CollectionsViewModel> 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();
}
@@ -0,0 +1,119 @@
#ifndef COLLECTIONSVIEWMODEL_H
#define COLLECTIONSVIEWMODEL_H
#include <QAbstractListModel>
#include <QObject>
#include <QPointer>
#include <QSet>
#include <qcorotask.h>
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<int, QByteArray> roleNames() const override;
void setEntries(QList<CollectionListEntry> 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<CollectionListEntry> m_entries;
QSet<QString> 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<void> load(bool manualRefresh);
QCoro::Task<void> create(QString name);
QCoro::Task<void> rename(QString itemId, QString name);
QCoro::Task<void> remove(QStringList itemIds);
QCoro::Task<void> saveOrder(QStringList oldOrder, QStringList newOrder);
void setBusy(bool busy);
QPointer<QEmbyCore> 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
+16 -2
View File
@@ -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"
@@ -143,7 +145,19 @@ void ManageView::setupUi() {
};
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);
File diff suppressed because it is too large Load Diff
+11 -77
View File
@@ -2,90 +2,24 @@
#define PAGECOLLECTIONS_H
#include "managepagebase.h"
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QStringList>
#include <optional>
#include <qcorotask.h>
class CollectionGrid;
class CollectionCard;
class LoadingOverlay;
class QLabel;
class QPushButton;
class QResizeEvent;
class CollectionsViewModel;
class QScrollArea;
class QShowEvent;
class QWidget;
class QuickPageHost;
class PageCollections : public ManagePageBase
{
// 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;
}
protected:
void showEvent(QShowEvent * event) override;
void resizeEvent(QResizeEvent * event) override;
QScrollArea *scrollArea() const { return m_scrollArea; }
private:
struct PlaylistEntry
{
QString id;
QString name;
int childCount = 0;
QString dateCreatedRaw;
QScrollArea *m_scrollArea = nullptr;
QuickPageHost *m_quickHost = nullptr;
CollectionsViewModel *m_viewModel = nullptr;
};
QCoro::Task<void> loadData(bool isManual = false);
QCoro::Task<void> loadCardImage(CollectionCard * card, QString itemId, quint64 imageLoadGeneration);
QCoro::Task<void> onCreateClicked();
QCoro::Task<void> onRenameClicked(QString itemId, QString itemName);
QCoro::Task<void> onDeleteClicked(QString itemId, QString itemName);
QCoro::Task<void> onDeleteSelectedClicked();
QCoro::Task<void> 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<PlaylistEntry> m_playlists;
QSet<QString> m_selectedPlaylistIds;
quint64 m_cardImageLoadGeneration = 0;
std::optional<QCoro::Task<void>> m_pendingTask;
};
#endif
#endif // PAGECOLLECTIONS_H
File diff suppressed because it is too large Load Diff
+9 -99
View File
@@ -2,114 +2,24 @@
#define PAGELIBRARIES_H
#include "managepagebase.h"
#include "librarycard.h"
#include "librarygrid.h"
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
#include <optional>
#include <QSet>
class EmbyWebSocket;
class LoadingOverlay;
class QLabel;
class QPushButton;
class QProgressBar;
class QResizeEvent;
class AdminLibrariesViewModel;
class QScrollArea;
class QTimer;
class QWidget;
class QuickPageHost;
class PageLibraries : public ManagePageBase {
// 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
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;
private:
QCoro::Task<void> loadData(bool isManual = false);
QCoro::Task<void> loadCardImage(LibraryCard* card, VirtualFolder folder,
quint64 imageLoadGeneration);
QCoro::Task<void> onCreateClicked();
QCoro::Task<void> onEditClicked(int row);
QCoro::Task<void> onEditImagesRequested(int row);
QCoro::Task<void> onDeleteClicked(int row);
QCoro::Task<void> onDeleteSelectedClicked();
QCoro::Task<void> onRefreshMetadataRequested(int row);
QCoro::Task<void> onScanLibraryFilesRequested(int row);
QCoro::Task<void> onScanAllClicked();
QCoro::Task<void> onOrderChanged(QStringList newOrderIds);
QCoro::Task<void> onCancelScanClicked();
QCoro::Task<void> pollScanProgress();
QCoro::Task<void> 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<VirtualFolder> m_folders;
QSet<QString> m_selectedLibraryIds;
bool m_loaded = false;
bool m_isLoading = false;
QSet<QString> m_scanningItemIds;
bool m_isScanAll = false;
QString m_runningTaskId;
std::optional<QCoro::Task<void>> m_pendingTask;
QList<QMetaObject::Connection> m_wsConnections;
quint64 m_cardImageLoadGeneration = 0;
bool m_reloadDeferredUntilScanEnd = false;
QScrollArea *m_scrollArea = nullptr;
QuickPageHost *m_quickHost = nullptr;
AdminLibrariesViewModel *m_viewModel = nullptr;
};
#endif
+21 -675
View File
@@ -1,690 +1,36 @@
#include "pagelogs.h"
#include "../../components/elidedlabel.h"
#include "../../components/moderntoast.h"
#include "../../managers/thememanager.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include "../../quick/logsviewmodel.h"
#include "../../quick/quickpagehost.h"
#include <QDateTime>
#include <QDir>
#include <QDebug>
#include <QFileDialog>
#include <QFileInfo>
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QIcon>
#include <QIODevice>
#include <QLabel>
#include <QListWidget>
#include <QMouseEvent>
#include <QPainter>
#include <QPlainTextEdit>
#include <QPointer>
#include <QPushButton>
#include <QPixmap>
#include <QSaveFile>
#include <QScrollBar>
#include <QHideEvent>
#include <QScrollArea>
#include <QShowEvent>
#include <QSignalBlocker>
#include <QSize>
#include <QStandardPaths>
#include <QStyledItemDelegate>
#include <QTextDocument>
#include <QTextOption>
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <algorithm>
#include <functional>
#include <stdexcept>
namespace {
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;
}
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"));
}
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<void(int, QString)>;
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<QMouseEvent*>(event);
updateHoveredButtonRow(hitDownloadButtonRow(mouseEvent->position().toPoint()));
break;
}
case QEvent::MouseButtonPress: {
auto* mouseEvent = static_cast<QMouseEvent*>(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<QMouseEvent*>(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;
};
}
#include <QUrl>
PageLogs::PageLogs(QEmbyCore *core, QWidget *parent)
: ManagePageBase(core, tr("Logs"), parent) {
m_mainLayout->setContentsMargins(16, 30, 0, 24);
m_mainLayout->setSpacing(16);
: ManagePageBase(core, QString(), parent) {
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(0);
auto* titleRow = new QHBoxLayout();
titleRow->setContentsMargins(0, 0, 12, 0);
titleRow->setSpacing(8);
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);
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);
});
// ManageView tracks legacy scroll areas. QML owns the visible scrolling.
m_scrollArea = new QScrollArea(this);
m_scrollArea->hide();
}
void PageLogs::showEvent(QShowEvent *event) {
ManagePageBase::showEvent(event);
if (!m_loaded) {
m_pendingTask = loadFileList();
m_loaded = true;
}
m_viewModel->setActive(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<void> PageLogs::loadFileList(bool isManual) {
QPointer<PageLogs> 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<LogFileInfo> 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<void> PageLogs::loadLogContent(QString logName) {
QPointer<PageLogs> 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<void> PageLogs::downloadLogFile(QString logName) {
QPointer<PageLogs> 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);
}
+11 -35
View File
@@ -2,52 +2,28 @@
#define PAGELOGS_H
#include "managepagebase.h"
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
#include <optional>
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
public:
explicit PageLogs(QEmbyCore *core, QWidget *parent = nullptr);
QScrollArea *scrollArea() const { return m_scrollArea; }
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
private:
QCoro::Task<void> loadFileList(bool isManual = false);
QCoro::Task<void> loadLogContent(QString logName);
QCoro::Task<void> 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<LogFileInfo> m_logFiles;
QString m_currentLogName;
bool m_loaded = false;
bool m_isLoading = false;
std::optional<QCoro::Task<void>> m_pendingTask;
std::optional<QCoro::Task<void>> m_pendingDownloadTask;
QuickPageHost *m_quickHost = nullptr;
LogsViewModel *m_viewModel = nullptr;
QScrollArea *m_scrollArea = nullptr;
};
#endif
#endif // PAGELOGS_H
+33 -562
View File
@@ -1,579 +1,50 @@
#include "pagetasks.h"
#include "../../components/moderntoast.h"
#include "../../components/taskeditdialog.h"
#include "../../utils/layoututils.h"
#include "../../utils/taskutils.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include "../../quick/embyimageprovider.h"
#include "../../quick/quickpagehost.h"
#include "../../quick/tasksviewmodel.h"
#include <QDebug>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPointer>
#include <QProgressBar>
#include <QPushButton>
#include <QHideEvent>
#include <QScrollArea>
#include <QShowEvent>
#include <QStringList>
#include <QTimer>
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include <QUrl>
#include <algorithm>
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;
}
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); });
}
void PageTasks::showEvent(QShowEvent *event)
{
ManagePageBase::showEvent(event);
if (!m_loaded)
{
m_pendingTask = loadData();
m_loaded = true;
}
m_refreshTimer->start();
}
void PageTasks::hideEvent(QHideEvent *event)
{
m_refreshTimer->stop();
ManagePageBase::hideEvent(event);
}
QCoro::Task<void> PageTasks::loadData(bool isManual)
{
QPointer<PageTasks> 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<void> PageTasks::onRunClicked(QString taskId, QString taskName)
{
QPointer<PageTasks> 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<void> PageTasks::onStopClicked(QString taskId, QString taskName)
{
QPointer<PageTasks> 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)
{
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, this, [this]() { m_pendingTask = loadData(false); });
connect(dialog, &TaskEditDialog::triggersUpdated, m_viewModel,
&TasksViewModel::refresh);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->open();
});
// 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();
}
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::showEvent(QShowEvent *event) {
ManagePageBase::showEvent(event);
m_viewModel->setActive(true);
}
void PageTasks::clearTaskSections()
{
LayoutUtils::clearLayout(m_taskSectionsLayout);
}
void PageTasks::updateSummaryCards(const QList<ScheduledTaskInfo> &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<ScheduledTaskInfo> &tasks)
{
clearTaskSections();
QList<ScheduledTaskInfo> runningTasks;
QList<ScheduledTaskInfo> scheduledTasks;
QList<ScheduledTaskInfo> manualTasks;
QList<ScheduledTaskInfo> 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<ScheduledTaskInfo> &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<int>(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);
}
+11 -45
View File
@@ -2,23 +2,22 @@
#define PAGETASKS_H
#include "managepagebase.h"
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
#include <optional>
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
public:
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:
@@ -26,42 +25,9 @@ protected:
void hideEvent(QHideEvent *event) override;
private:
QCoro::Task<void> loadData(bool isManual = false);
QCoro::Task<void> onRunClicked(QString taskId, QString taskName);
QCoro::Task<void> 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<ScheduledTaskInfo>& tasks);
void rebuildTaskSections(const QList<ScheduledTaskInfo>& tasks);
void addTaskSection(const QString& title,
const QString& subtitle,
const QList<ScheduledTaskInfo>& tasks);
void addTaskCard(const ScheduledTaskInfo& task, QVBoxLayout* parentLayout);
QPushButton* m_btnRefresh = nullptr;
QLabel* m_statsLabel = nullptr;
QuickPageHost *m_quickHost = nullptr;
TasksViewModel *m_viewModel = 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<QCoro::Task<void>> m_pendingTask;
};
#endif
#endif // PAGETASKS_H
File diff suppressed because it is too large Load Diff
+11 -166
View File
@@ -2,183 +2,28 @@
#define PAGETRANSCODING_H
#include "managepagebase.h"
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QSet>
#include <QStringList>
#include <QVariant>
#include <optional>
#include <qcorotask.h>
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;
class PageTranscoding : public ManagePageBase {
// 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
public:
explicit PageTranscoding(QEmbyCore *core, QWidget *parent = nullptr);
QScrollArea *scrollArea() const { return m_scrollArea; }
protected:
void showEvent(QShowEvent* event) override;
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<void> loadData(bool isManual = false);
QCoro::Task<void> 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<QWidget*> m_sectionCards;
QHash<QWidget*, QStringList> m_sectionBlocks;
QHash<QString, QWidget*> m_blocks;
QSet<QString> m_actualBlockKeys;
QHash<QString, ModernSwitch*> m_switches;
QHash<QString, QSpinBox*> m_spinBoxes;
QHash<QString, QDoubleSpinBox*> m_doubleSpinBoxes;
QHash<QString, QLineEdit*> m_lineEdits;
QHash<QString, ModernComboBox*> m_comboBoxes;
QHash<QString, ModernTagInput*> m_tagInputs;
QLineEdit* m_encoderAppPathDisplay = nullptr;
QHash<QString, ModernSwitch*> m_jellyfinCodecSwitches;
QHash<QString, QWidget*> m_jellyfinCodecBlocks;
QStringList m_unknownJellyfinCodecValues;
QVBoxLayout* m_embyDecoderLayout = nullptr;
QVBoxLayout* m_embyEncoderLayout = nullptr;
QVBoxLayout* m_embySoftwareLayout = nullptr;
QHash<QString, EmbyCodecState> m_embyCodecStates;
QHash<QString, QStringList> m_embyCodecOrders;
QStringList m_embyCodecGroupOrder;
QJsonObject m_rawConfig;
QJsonArray m_embyCodecInfo;
QJsonArray m_embyDefaultCodecConfigs;
QSet<QString> m_supportedKeys;
bool m_isJellyfin = false;
bool m_loaded = false;
bool m_hasLoadedData = false;
bool m_isLoading = false;
bool m_isSaving = false;
std::optional<QCoro::Task<void>> m_pendingTask;
QuickPageHost *m_quickHost = nullptr;
TranscodingViewModel *m_viewModel = nullptr;
};
#endif
+37 -273
View File
@@ -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 <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/manager/servermanager.h>
#include <QDebug>
#include <QHBoxLayout>
#include <QLabel>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QShowEvent>
#include <QTransform>
#include <QVBoxLayout>
#include <QVariantAnimation>
#include "../../tokens/AnimationTokens.h"
#include "../../utils/refreshspin.h"
#include "../../quick/embyimageprovider.h"
#include "../../quick/quickpagehost.h"
#include "usersviewmodel.h"
#include <QUrl>
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);
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);
auto *titleRow = new QHBoxLayout();
titleRow->setContentsMargins(0, 0, 12, 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); });
connect(m_viewModel, &UsersViewModel::addUserRequested, this,
&PageUsers::openAddUserDialog);
connect(m_viewModel, &UsersViewModel::editUserRequested, this,
&PageUsers::openUserEditor);
}
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]() {
void PageUsers::openAddUserDialog() {
auto *dialog = new AddUserDialog(m_core, this);
connect(dialog, &AddUserDialog::userCreated, this,
[this]() { m_pendingTask = loadData(false); });
connect(dialog, &AddUserDialog::userCreated, m_viewModel,
&UsersViewModel::reloadAfterDialog);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->open();
});
}
void PageUsers::showEvent(QShowEvent *event) {
ManagePageBase::showEvent(event);
if (!m_loaded) {
m_pendingTask = loadData();
m_loaded = true;
}
}
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<void> PageUsers::loadData(bool isManual) {
QPointer<PageUsers> 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<void> PageUsers::onToggleUserEnabled(QString userId,
QString userName,
bool enable) {
QPointer<PageUsers> 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<void> PageUsers::onDeleteUser(QString userId, QString userName) {
QPointer<PageUsers> 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;
void PageUsers::openUserEditor(const QString &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); });
connect(dialog, &UserEditDialog::userUpdated, m_viewModel,
&UsersViewModel::reloadAfterDialog);
connect(dialog, &UserEditDialog::userDeleted, m_viewModel,
&UsersViewModel::reloadAfterDialog);
dialog->setAttribute(Qt::WA_DeleteOnClose);
dialog->open();
}
+11 -32
View File
@@ -2,45 +2,24 @@
#define PAGEUSERS_H
#include "managepagebase.h"
#include <qcorotask.h>
#include <optional>
class FlowLayout;
class QLabel;
class QPushButton;
class QScrollArea;
class QWidget;
class QuickPageHost;
class UsersViewModel;
class PageUsers : public ManagePageBase {
// 保留 ManageView 依赖的 QWidget 构造与生命周期契约;页面主体由
// AdminUsersPage.qml 渲染,复杂的新增/编辑对话框暂由 Widgets 承载。
class PageUsers final : public ManagePageBase {
Q_OBJECT
public:
explicit PageUsers(QEmbyCore *core, QWidget *parent = nullptr);
protected:
void showEvent(QShowEvent* event) override;
private:
QCoro::Task<void> loadData(bool isManual = false);
QCoro::Task<void> onToggleUserEnabled(QString userId, QString userName, bool enable);
QCoro::Task<void> 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<QCoro::Task<void>> m_pendingTask;
QuickPageHost *m_quickHost = nullptr;
UsersViewModel *m_viewModel = nullptr;
};
#endif
#endif // PAGEUSERS_H
@@ -0,0 +1,370 @@
#include "transcodingviewmodel.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/manager/servermanager.h>
#include <QJsonDocument>
#include <QPointer>
#include <algorithm>
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<void> 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<TranscodingViewModel> 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<void> TranscodingViewModel::store() {
if (!m_core || !m_core->adminService() || !canSave())
co_return;
m_saving = true;
emit stateChanged();
emit formChanged();
QPointer<TranscodingViewModel> 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;
}
@@ -0,0 +1,92 @@
#ifndef TRANSCODINGVIEWMODEL_H
#define TRANSCODINGVIEWMODEL_H
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <QPointer>
#include <QVariantList>
#include <QVariantMap>
#include <qcorotask.h>
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<void> load(bool manual);
QCoro::Task<void> store();
void applyConfiguration(const QJsonObject &config);
void rebuildCodecRows();
QJsonObject collectedConfiguration() const;
QPointer<QEmbyCore> 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
+303
View File
@@ -0,0 +1,303 @@
#include "usersviewmodel.h"
#include <qembycore.h>
#include <services/admin/adminservice.h>
#include <services/manager/servermanager.h>
#include <QDateTime>
#include <QJsonObject>
#include <QTimeZone>
#include <stdexcept>
#include <utility>
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<int, QByteArray> 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<UserInfo> users,
const QString &currentUserId) {
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<void> 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<UsersViewModel> guard(this);
try {
QList<UserInfo> 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<void> 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<UsersViewModel> 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<void> 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<UsersViewModel> 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;
}
+105
View File
@@ -0,0 +1,105 @@
#ifndef USERSVIEWMODEL_H
#define USERSVIEWMODEL_H
#include <QAbstractListModel>
#include <QObject>
#include <QPointer>
#include <QSet>
#include <models/admin/adminmodels.h>
#include <qcorotask.h>
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<int, QByteArray> 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<UserInfo> users, const QString &currentUserId);
void setBusy(const QString &userId, bool busy);
private:
int indexOf(const QString &userId) const;
QList<UserInfo> m_users;
QString m_currentUserId;
QSet<QString> 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<void> load(bool manualRefresh);
QCoro::Task<void> updateEnabled(QString userId, QString displayName,
bool enabled);
QCoro::Task<void> removeUser(QString userId, QString displayName);
QPointer<QEmbyCore> 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
File diff suppressed because it is too large Load Diff
+16 -245
View File
@@ -2,265 +2,36 @@
#define DETAILVIEW_H
#include "../baseview.h"
#include "models/media/mediaitem.h"
#include <qcorotask.h>
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<void> loadItem(const QString &itemId,
const MediaItem &seedItem = {});
void prepareForStackLeave() override;
QCoro::Task<void> loadItem(const QString &itemId, const MediaItem &seedItem = {});
public Q_SLOTS:
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<void> 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<void> applySeriesPlayableItem(MediaItem playableItem,
bool scrollToEpisode = false);
QCoro::Task<void> playSelected(MediaItem item, int sourceIndex, bool resume);
QCoro::Task<void> 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<MediaItem> &collections);
QCoro::Task<void> executePlay(MediaItem targetItem, long long startTicks);
QCoro::Task<void> executePlaySeason(MediaItem seasonItem);
QCoro::Task<void> executeExternalPlay(MediaItem targetItem, QString playerPath);
QCoro::Task<void> 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<void> loadEpisodesForSeason(int idx,
QString highlightEpisodeId = QString(),
bool scrollToHighlight = false,
bool manualSelection = false);
static QCoro::Task<void>
executeFetchSecondaries(QPointer<DetailView> safeThis, QEmbyCore *core,
QString targetId, QString itemType);
static QCoro::Task<void> executeSilentRefresh(QPointer<DetailView> safeThis,
QEmbyCore *core,
QString itemId);
static QCoro::Task<void> executeLoadImages(QPointer<DetailView> safeThis,
QEmbyCore *core, MediaItem item,
bool allowPrimaryBackdropFallback);
QCoro::Task<void> loadDetailCacheAndStartPrefetch(QString itemId,
MediaItem seedItem,
QString cacheServerId,
QString cacheUserId);
QCoro::Task<void> prefetchItemDetail(QString itemId, QString cacheServerId,
QString cacheUserId,
QString cachedFingerprint);
void maybeFlushDeferredUpdate(const QString &itemId);
QCoro::Task<void> 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<void> 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<MediaItem> m_cachedSeriesSeasons;
QList<MediaItem> m_cachedSeasonEpisodes;
QList<MediaItem> m_cachedSimilarItems;
QList<MediaItem> m_cachedCollections;
QList<MediaItem> 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<MediaItem> m_seriesSeasons;
QList<MediaItem> m_currentSeasonEpisodes;
int m_currentSeasonIndex = 0;
SmoothScrollController *m_vScrollController = nullptr;
QuickPageHost *m_quickHost = nullptr;
DetailViewModel *m_viewModel = nullptr;
};
#endif
#endif // DETAILVIEW_H
@@ -0,0 +1,493 @@
#include "detailviewmodel.h"
#include <qembycore.h>
#include <services/media/mediaservice.h>
#include <QDate>
#include <QMetaObject>
#include <QStringList>
#include <utility>
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<int, QByteArray> 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<MediaItem> 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<void> 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<DetailViewModel> 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<MediaItem> 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<void> DetailViewModel::loadSecondaries(quint64 generation,
QString itemId,
QString itemType) {
QPointer<DetailViewModel> guard(this);
auto current = [&]() {
return guard && guard->m_active && generation == guard->m_generation &&
itemId == guard->m_itemId;
};
if (itemType == QLatin1String("Series")) {
try {
const QList<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> additional =
co_await m_core->mediaService()->getAdditionalParts(itemId);
if (!current())
co_return;
m_additional.setItems(additional);
emit stateChanged();
} catch (...) {
}
try {
const QList<MediaItem> collections =
co_await m_core->mediaService()->getItemCollections(itemId);
if (!current())
co_return;
m_collections.setItems(collections);
emit stateChanged();
} catch (...) {
}
try {
const QList<MediaItem> 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 &section) {
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);
}

Some files were not shown because too many files have changed in this diff Show More