feat: checkpoint QML view migration
This commit is contained in:
+1
-1
@@ -87,7 +87,7 @@ set(QEMBY_QT_ROOT "" CACHE PATH
|
|||||||
qemby_append_default_qt_prefixes()
|
qemby_append_default_qt_prefixes()
|
||||||
|
|
||||||
# 寻找 Qt6 (不再允许降级)
|
# 寻找 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)
|
if(UNIX AND NOT APPLE)
|
||||||
find_package(Qt6 QUIET COMPONENTS DBus)
|
find_package(Qt6 QUIET COMPONENTS DBus)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -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 路径稳定后进行,便于回退与差异核对。
|
||||||
@@ -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` 属性的问题。
|
||||||
@@ -10,6 +10,8 @@ set(APP_QRC_FILES resources/resources.qrc)
|
|||||||
|
|
||||||
file(GLOB_RECURSE APP_HEADERS CONFIGURE_DEPENDS "*.h")
|
file(GLOB_RECURSE APP_HEADERS CONFIGURE_DEPENDS "*.h")
|
||||||
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "*.cpp")
|
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
|
qt_add_executable(qEmbyApp
|
||||||
MANUAL_FINALIZATION
|
MANUAL_FINALIZATION
|
||||||
@@ -19,6 +21,81 @@ qt_add_executable(qEmbyApp
|
|||||||
${APP_QRC_FILES}
|
${APP_QRC_FILES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# QML 模块:Widgets 混合迁移的 Quick 页面
|
||||||
|
# QML 页面通过 QuickPageHost (QQuickWidget) 嵌入现有页面栈;
|
||||||
|
# C++ 类型 (Theme 单例等) 通过 QML_ELEMENT 自动注册到 qEmby 模块。
|
||||||
|
# ==========================================
|
||||||
|
if(COMMAND qt_policy)
|
||||||
|
qt_policy(SET QTP0004 NEW)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_qml_module(qEmbyApp
|
||||||
|
URI qEmby
|
||||||
|
VERSION 1.0
|
||||||
|
RESOURCE_PREFIX /qt/qml
|
||||||
|
QML_FILES
|
||||||
|
qml/Router.qml
|
||||||
|
qml/ManageDashboardPage.qml
|
||||||
|
qml/LoginPage.qml
|
||||||
|
qml/SearchPage.qml
|
||||||
|
qml/CategoryPage.qml
|
||||||
|
qml/FavoritesPage.qml
|
||||||
|
qml/DashboardPage.qml
|
||||||
|
qml/LibraryPage.qml
|
||||||
|
qml/DetailPage.qml
|
||||||
|
qml/SeasonPage.qml
|
||||||
|
qml/AdminUsersPage.qml
|
||||||
|
qml/AdminTasksPage.qml
|
||||||
|
qml/AdminLibrariesPage.qml
|
||||||
|
qml/AdminLogsPage.qml
|
||||||
|
qml/AdminCollectionsPage.qml
|
||||||
|
qml/AdminTranscodingPage.qml
|
||||||
|
qml/EmptyState.qml
|
||||||
|
qml/SettingsAboutPage.qml
|
||||||
|
qml/SettingsGeneralPage.qml
|
||||||
|
qml/SettingsAppearancePage.qml
|
||||||
|
qml/SettingsLibraryPage.qml
|
||||||
|
qml/SettingsPlayerPage.qml
|
||||||
|
qml/components/SettingsCard.qml
|
||||||
|
qml/components/CollapsibleSection.qml
|
||||||
|
qml/components/ThemedButton.qml
|
||||||
|
qml/components/ThemedSwitch.qml
|
||||||
|
qml/components/ThemedComboBox.qml
|
||||||
|
qml/components/ThemedTextField.qml
|
||||||
|
qml/components/ThemedSpinBox.qml
|
||||||
|
qml/components/ThemedSlider.qml
|
||||||
|
qml/components/ThemedScrollBar.qml
|
||||||
|
qml/components/ThemedDialog.qml
|
||||||
|
qml/components/ToastHost.qml
|
||||||
|
qml/components/LoadingState.qml
|
||||||
|
qml/components/ErrorState.qml
|
||||||
|
qml/components/MediaCard.qml
|
||||||
|
qml/components/MediaGrid.qml
|
||||||
|
qml/components/MediaList.qml
|
||||||
|
qml/components/MediaSection.qml
|
||||||
|
SOURCES
|
||||||
|
quick/quicktheme.h quick/quicktheme.cpp
|
||||||
|
quick/qmltask.h quick/qmltask.cpp
|
||||||
|
quick/settingsviewmodel.h quick/settingsviewmodel.cpp
|
||||||
|
quick/loginviewmodel.h quick/loginviewmodel.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
# qmltyperegistrar 生成的注册代码以 <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 播放器核心
|
# 集成 libmpv 播放器核心
|
||||||
# Linux / macOS 优先使用系统安装的 libmpv
|
# Linux / macOS 优先使用系统安装的 libmpv
|
||||||
@@ -65,6 +142,10 @@ target_link_libraries(qEmbyApp PRIVATE
|
|||||||
Qt${QT_VERSION_MAJOR}::OpenGLWidgets
|
Qt${QT_VERSION_MAJOR}::OpenGLWidgets
|
||||||
Qt${QT_VERSION_MAJOR}::Svg
|
Qt${QT_VERSION_MAJOR}::Svg
|
||||||
Qt${QT_VERSION_MAJOR}::Concurrent
|
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
|
qEmbyCore
|
||||||
QWKWidgets
|
QWKWidgets
|
||||||
WidgetFrame
|
WidgetFrame
|
||||||
|
|||||||
@@ -923,6 +923,7 @@ void MainWindow::navigateToHome()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_viewTransitionInProgress = true;
|
m_viewTransitionInProgress = true;
|
||||||
|
m_homeView->beginAuthenticatedSession();
|
||||||
|
|
||||||
|
|
||||||
m_homeView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
m_homeView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
@@ -979,6 +980,7 @@ void MainWindow::navigateToLogin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_viewTransitionInProgress = true;
|
m_viewTransitionInProgress = true;
|
||||||
|
m_homeView->clearAuthenticatedSession();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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> | <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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 ¶ms, const QString &name)
|
||||||
|
{
|
||||||
|
return params.value(name).toString().trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString encoded(const QString &value)
|
||||||
|
{
|
||||||
|
return QString::fromLatin1(QUrl::toPercentEncoding(value));
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
NavigationController::NavigationController(QObject *parent) : QObject(parent)
|
||||||
|
{
|
||||||
|
NavigationRoute login;
|
||||||
|
login.name = QStringLiteral("login");
|
||||||
|
login.key = QStringLiteral("login");
|
||||||
|
login.cachePolicy = QStringLiteral("singleton");
|
||||||
|
m_stack.append(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap NavigationController::currentRoute() const
|
||||||
|
{
|
||||||
|
return m_stack.isEmpty() ? QVariantMap{} : m_stack.constLast().toVariantMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::canGoBack() const
|
||||||
|
{
|
||||||
|
return m_stack.size() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int NavigationController::depth() const
|
||||||
|
{
|
||||||
|
return m_stack.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::normalize(const QVariantMap &request,
|
||||||
|
NavigationRoute *route,
|
||||||
|
QString *errorCode,
|
||||||
|
QString *errorMessage) const
|
||||||
|
{
|
||||||
|
const QString name = request.value(QStringLiteral("name")).toString().trimmed();
|
||||||
|
if (!kRouteNames.contains(name))
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("unknownRoute");
|
||||||
|
*errorMessage = QStringLiteral("Unknown route: %1").arg(name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QVariant paramsValue = request.value(QStringLiteral("params"), QVariantMap{});
|
||||||
|
if (paramsValue.metaType().id() != QMetaType::QVariantMap || !isSafeValue(paramsValue))
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("invalidParams");
|
||||||
|
*errorMessage = QStringLiteral("Route params must contain copy-safe values");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QVariantMap params = paramsValue.toMap();
|
||||||
|
|
||||||
|
QStringList required;
|
||||||
|
if (name == QLatin1String("detail"))
|
||||||
|
required << QStringLiteral("itemId");
|
||||||
|
else if (name == QLatin1String("player"))
|
||||||
|
required << QStringLiteral("mediaId");
|
||||||
|
else if (name == QLatin1String("category"))
|
||||||
|
required << QStringLiteral("categoryId");
|
||||||
|
else if (name == QLatin1String("library"))
|
||||||
|
required << QStringLiteral("libraryId");
|
||||||
|
else if (name == QLatin1String("person"))
|
||||||
|
required << QStringLiteral("personId");
|
||||||
|
else if (name == QLatin1String("search"))
|
||||||
|
required << QStringLiteral("query");
|
||||||
|
else if (name == QLatin1String("filtered"))
|
||||||
|
required << QStringLiteral("filterType") << QStringLiteral("filterValue");
|
||||||
|
else if (name == QLatin1String("season"))
|
||||||
|
required << QStringLiteral("seriesId") << QStringLiteral("seasonId");
|
||||||
|
else if (name == QLatin1String("navigationError"))
|
||||||
|
required << QStringLiteral("code");
|
||||||
|
|
||||||
|
for (const QString &field : required)
|
||||||
|
{
|
||||||
|
const QString value = requiredString(params, field);
|
||||||
|
if (value.isEmpty() || value.size() > 2048)
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("invalidParams");
|
||||||
|
*errorMessage = QStringLiteral("Missing or invalid route parameter: %1").arg(field);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
params.insert(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_authenticated && name != QLatin1String("login") &&
|
||||||
|
name != QLatin1String("navigationError"))
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("authenticationRequired");
|
||||||
|
*errorMessage = QStringLiteral("Authentication is required for this route");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name == QLatin1String("manage") && !m_administrator)
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("permissionDenied");
|
||||||
|
*errorMessage = QStringLiteral("Administrator permission is required");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString section = params.value(QStringLiteral("section")).toString();
|
||||||
|
if (name == QLatin1String("settings") && !section.isEmpty() &&
|
||||||
|
!kSettingsSections.contains(section))
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("invalidParams");
|
||||||
|
*errorMessage = QStringLiteral("Unknown settings section: %1").arg(section);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name == QLatin1String("manage") && !section.isEmpty() &&
|
||||||
|
!kManageSections.contains(section))
|
||||||
|
{
|
||||||
|
*errorCode = QStringLiteral("invalidParams");
|
||||||
|
*errorMessage = QStringLiteral("Unknown manage section: %1").arg(section);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
route->name = name;
|
||||||
|
route->params = params;
|
||||||
|
route->title = request.value(QStringLiteral("title"), params.value(QStringLiteral("title"))).toString();
|
||||||
|
route->presentation = name == QLatin1String("player") ? QStringLiteral("immersive")
|
||||||
|
: QStringLiteral("page");
|
||||||
|
route->cachePolicy = (name == QLatin1String("login") || name == QLatin1String("home") ||
|
||||||
|
name == QLatin1String("favorites"))
|
||||||
|
? QStringLiteral("singleton")
|
||||||
|
: (name == QLatin1String("player") || name == QLatin1String("navigationError"))
|
||||||
|
? QStringLiteral("destroyOnPop")
|
||||||
|
: QStringLiteral("lru");
|
||||||
|
|
||||||
|
QStringList identity;
|
||||||
|
for (const QString &field : required)
|
||||||
|
identity << encoded(params.value(field).toString());
|
||||||
|
route->key = identity.isEmpty() ? name : name + QLatin1Char(':') + identity.join(QLatin1Char(':'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::reject(const QString &code, const QString &message,
|
||||||
|
const QVariantMap &request)
|
||||||
|
{
|
||||||
|
Q_EMIT navigationRejected(code, message, request);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::notifyRouteAndStackChanged()
|
||||||
|
{
|
||||||
|
Q_EMIT currentRouteChanged();
|
||||||
|
Q_EMIT stackChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::push(const QVariantMap &request)
|
||||||
|
{
|
||||||
|
NavigationRoute route;
|
||||||
|
QString code;
|
||||||
|
QString message;
|
||||||
|
if (!normalize(request, &route, &code, &message))
|
||||||
|
return reject(code, message, request);
|
||||||
|
m_stack.append(route);
|
||||||
|
notifyRouteAndStackChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::replace(const QVariantMap &request)
|
||||||
|
{
|
||||||
|
NavigationRoute route;
|
||||||
|
QString code;
|
||||||
|
QString message;
|
||||||
|
if (!normalize(request, &route, &code, &message))
|
||||||
|
return reject(code, message, request);
|
||||||
|
if (m_stack.isEmpty())
|
||||||
|
m_stack.append(route);
|
||||||
|
else
|
||||||
|
m_stack.last() = route;
|
||||||
|
notifyRouteAndStackChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::back()
|
||||||
|
{
|
||||||
|
if (!canGoBack())
|
||||||
|
return false;
|
||||||
|
m_stack.removeLast();
|
||||||
|
notifyRouteAndStackChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::home()
|
||||||
|
{
|
||||||
|
if (m_authenticated && m_stack.size() == 1 &&
|
||||||
|
m_stack.constLast().name == QLatin1String("home"))
|
||||||
|
{
|
||||||
|
Q_EMIT refreshRequested(m_stack.constLast().key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetToRoot(QStringLiteral("home"));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NavigationController::resetToRoot(const QString &name)
|
||||||
|
{
|
||||||
|
if (name != QLatin1String("home") && name != QLatin1String("favorites"))
|
||||||
|
return reject(QStringLiteral("invalidRoot"), QStringLiteral("Invalid session root"),
|
||||||
|
{{QStringLiteral("name"), name}});
|
||||||
|
|
||||||
|
NavigationRoute route;
|
||||||
|
QString code;
|
||||||
|
QString message;
|
||||||
|
const QVariantMap request{{QStringLiteral("name"), name}};
|
||||||
|
if (!normalize(request, &route, &code, &message))
|
||||||
|
return reject(code, message, request);
|
||||||
|
m_stack = {route};
|
||||||
|
notifyRouteAndStackChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::updateUiState(const QVariantMap &patch)
|
||||||
|
{
|
||||||
|
if (m_stack.isEmpty())
|
||||||
|
return;
|
||||||
|
QVariantMap &state = m_stack.last().uiState;
|
||||||
|
for (const QString &key : {QStringLiteral("scrollY"), QStringLiteral("section"),
|
||||||
|
QStringLiteral("sections")})
|
||||||
|
{
|
||||||
|
const QVariant value = patch.value(key);
|
||||||
|
if (value.isValid() && isSafeValue(value))
|
||||||
|
state.insert(key, value);
|
||||||
|
}
|
||||||
|
Q_EMIT currentRouteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::enterAuthenticatedSession(bool administrator)
|
||||||
|
{
|
||||||
|
m_authenticated = true;
|
||||||
|
m_administrator = administrator;
|
||||||
|
m_stack.clear();
|
||||||
|
resetToRoot(QStringLiteral("home"));
|
||||||
|
Q_EMIT sessionRoutesCleared();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::leaveAuthenticatedSession()
|
||||||
|
{
|
||||||
|
m_authenticated = false;
|
||||||
|
m_administrator = false;
|
||||||
|
NavigationRoute login;
|
||||||
|
QString code;
|
||||||
|
QString message;
|
||||||
|
normalize({{QStringLiteral("name"), QStringLiteral("login")}}, &login, &code, &message);
|
||||||
|
m_stack = {login};
|
||||||
|
notifyRouteAndStackChanged();
|
||||||
|
Q_EMIT sessionRoutesCleared();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NavigationController::setAdministrator(bool administrator)
|
||||||
|
{
|
||||||
|
m_administrator = administrator;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#include "qmltask.h"
|
||||||
|
|
||||||
|
QmlTask::QmlTask(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
QmlTask::~QmlTask() = default;
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -98,6 +98,7 @@ QAbstractScrollArea::corner {
|
|||||||
QWK--WindowBar[bar-active=true],
|
QWK--WindowBar[bar-active=true],
|
||||||
QWK--WindowBar[bar-active=false] {
|
QWK--WindowBar[bar-active=false] {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar>QLabel#win-title-slot,
|
QWK--WindowBar>QLabel#win-title-slot,
|
||||||
@@ -129,17 +130,19 @@ QLabel#mac-title-label {
|
|||||||
/* 标题栏居中全局搜索框 */
|
/* 标题栏居中全局搜索框 */
|
||||||
QLineEdit#titlebar-search {
|
QLineEdit#titlebar-search {
|
||||||
background-color: #1E293B;
|
background-color: #1E293B;
|
||||||
border: 1px solid transparent;
|
border: 1px solid #334155;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0px 18px 0px 8px;
|
padding: 0px 18px 0px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #F8FAFC;
|
color: #F8FAFC;
|
||||||
|
selection-background-color: #3B82F6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QLineEdit#titlebar-search:hover {
|
QLineEdit#titlebar-search:hover {
|
||||||
background-color: #334155;
|
background-color: #1E293B;
|
||||||
|
border: 1px solid #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
QLineEdit#titlebar-search:focus {
|
QLineEdit#titlebar-search:focus {
|
||||||
@@ -333,6 +336,17 @@ QWK--WindowBar QAbstractButton[system-button=true] {
|
|||||||
background-color: transparent;
|
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 {
|
QWK--WindowBar QAbstractButton#pin-button {
|
||||||
qproperty-iconNormal: url(":/svg/dark/pin.svg");
|
qproperty-iconNormal: url(":/svg/dark/pin.svg");
|
||||||
qproperty-iconChecked: url(":/svg/dark/pin-fill.svg");
|
qproperty-iconChecked: url(":/svg/dark/pin-fill.svg");
|
||||||
@@ -354,11 +368,11 @@ QWK--WindowBar QAbstractButton#max-button:pressed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#fav-button {
|
QWK--WindowBar QAbstractButton#fav-button {
|
||||||
margin-right: 10px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWidget#mac-titlebar-nav QAbstractButton#fav-button {
|
QWidget#mac-titlebar-nav QAbstractButton#fav-button {
|
||||||
margin-right: 0px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#back-button:pressed,
|
QWK--WindowBar QAbstractButton#back-button:pressed,
|
||||||
@@ -373,14 +387,17 @@ QWK--WindowBar QAbstractButton#min-button {
|
|||||||
|
|
||||||
QWK--WindowBar QAbstractButton#back-button {
|
QWK--WindowBar QAbstractButton#back-button {
|
||||||
qproperty-iconNormal: url(":/svg/dark/arrow-left.svg");
|
qproperty-iconNormal: url(":/svg/dark/arrow-left.svg");
|
||||||
|
qproperty-iconSize: 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#home-button {
|
QWK--WindowBar QAbstractButton#home-button {
|
||||||
qproperty-iconNormal: url(":/svg/dark/bar-home.svg");
|
qproperty-iconNormal: url(":/svg/dark/bar-home.svg");
|
||||||
|
qproperty-iconSize: 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#fav-button {
|
QWK--WindowBar QAbstractButton#fav-button {
|
||||||
qproperty-iconNormal: url(":/svg/dark/bar-heart.svg");
|
qproperty-iconNormal: url(":/svg/dark/bar-heart.svg");
|
||||||
|
qproperty-iconSize: 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#max-button {
|
QWK--WindowBar QAbstractButton#max-button {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ QAbstractScrollArea::corner {
|
|||||||
QWK--WindowBar[bar-active=true],
|
QWK--WindowBar[bar-active=true],
|
||||||
QWK--WindowBar[bar-active=false] {
|
QWK--WindowBar[bar-active=false] {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
border-bottom: 1px solid #E2E8F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar>QLabel#win-title-slot,
|
QWK--WindowBar>QLabel#win-title-slot,
|
||||||
@@ -127,8 +128,8 @@ QLabel#mac-title-label {
|
|||||||
========================================= */
|
========================================= */
|
||||||
/* 标题栏居中全局搜索框 */
|
/* 标题栏居中全局搜索框 */
|
||||||
QLineEdit#titlebar-search {
|
QLineEdit#titlebar-search {
|
||||||
background-color: #F1F5F9;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid transparent;
|
border: 1px solid #E2E8F0;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
/* 32px 高度对应的完美胶囊圆角 */
|
/* 32px 高度对应的完美胶囊圆角 */
|
||||||
padding: 0px 18px 0px 8px;
|
padding: 0px 18px 0px 8px;
|
||||||
@@ -136,12 +137,13 @@ QLineEdit#titlebar-search {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #1E293B;
|
color: #1E293B;
|
||||||
|
selection-background-color: #3B82F6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QLineEdit#titlebar-search:hover {
|
QLineEdit#titlebar-search:hover {
|
||||||
background-color: #E2E8F0;
|
background-color: #FFFFFF;
|
||||||
/* 悬浮微量加深 */
|
border: 1px solid #CBD5E1;
|
||||||
}
|
}
|
||||||
|
|
||||||
QLineEdit#titlebar-search:focus {
|
QLineEdit#titlebar-search:focus {
|
||||||
@@ -339,6 +341,17 @@ QWK--WindowBar QAbstractButton[system-button=true] {
|
|||||||
background-color: transparent;
|
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 {
|
QWK--WindowBar QAbstractButton#pin-button {
|
||||||
qproperty-iconNormal: url(":/svg/light/pin.svg");
|
qproperty-iconNormal: url(":/svg/light/pin.svg");
|
||||||
qproperty-iconChecked: url(":/svg/light/pin-fill.svg");
|
qproperty-iconChecked: url(":/svg/light/pin-fill.svg");
|
||||||
@@ -360,18 +373,18 @@ QWK--WindowBar QAbstractButton#max-button:pressed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#fav-button {
|
QWK--WindowBar QAbstractButton#fav-button {
|
||||||
margin-right: 10px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWidget#mac-titlebar-nav QAbstractButton#fav-button {
|
QWidget#mac-titlebar-nav QAbstractButton#fav-button {
|
||||||
margin-right: 0px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#back-button:pressed,
|
QWK--WindowBar QAbstractButton#back-button:pressed,
|
||||||
QWK--WindowBar QAbstractButton#home-button:pressed,
|
QWK--WindowBar QAbstractButton#home-button:pressed,
|
||||||
QWK--WindowBar QAbstractButton#fav-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 {
|
QWK--WindowBar QAbstractButton#min-button {
|
||||||
@@ -380,14 +393,17 @@ QWK--WindowBar QAbstractButton#min-button {
|
|||||||
|
|
||||||
QWK--WindowBar QAbstractButton#back-button {
|
QWK--WindowBar QAbstractButton#back-button {
|
||||||
qproperty-iconNormal: url(":/svg/light/arrow-left.svg");
|
qproperty-iconNormal: url(":/svg/light/arrow-left.svg");
|
||||||
|
qproperty-iconSize: 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#home-button {
|
QWK--WindowBar QAbstractButton#home-button {
|
||||||
qproperty-iconNormal: url(":/svg/light/bar-home.svg");
|
qproperty-iconNormal: url(":/svg/light/bar-home.svg");
|
||||||
|
qproperty-iconSize: 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QWK--WindowBar QAbstractButton#fav-button {
|
QWK--WindowBar QAbstractButton#fav-button {
|
||||||
qproperty-iconNormal: url(":/svg/light/bar-heart.svg");
|
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;
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ namespace Red {
|
|||||||
inline const QColor k600 {"#DC2626"};
|
inline const QColor k600 {"#DC2626"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace Amber {
|
||||||
|
inline const QColor k500 {"#F59E0B"};
|
||||||
|
}
|
||||||
|
|
||||||
namespace Green {
|
namespace Green {
|
||||||
inline const QColor k500 {"#22C55E"};
|
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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "manageview.h"
|
#include "manageview.h"
|
||||||
#include "managepagebase.h"
|
#include "managepagebase.h"
|
||||||
#include "pagedashboard.h"
|
#include "../../quick/dashboardviewmodel.h"
|
||||||
|
#include "../../quick/embyimageprovider.h"
|
||||||
|
#include "../../quick/quickpagehost.h"
|
||||||
#include "pagecollections.h"
|
#include "pagecollections.h"
|
||||||
#include "pagelibraries.h"
|
#include "pagelibraries.h"
|
||||||
#include "pagelogs.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);
|
auto *libPage = new PageLibraries(m_core, m_stack);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,90 +2,24 @@
|
|||||||
#define PAGECOLLECTIONS_H
|
#define PAGECOLLECTIONS_H
|
||||||
|
|
||||||
#include "managepagebase.h"
|
#include "managepagebase.h"
|
||||||
#include <QDateTime>
|
|
||||||
#include <QList>
|
|
||||||
#include <QSet>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <optional>
|
|
||||||
#include <qcorotask.h>
|
|
||||||
|
|
||||||
class CollectionGrid;
|
class CollectionsViewModel;
|
||||||
class CollectionCard;
|
|
||||||
class LoadingOverlay;
|
|
||||||
class QLabel;
|
|
||||||
class QPushButton;
|
|
||||||
class QResizeEvent;
|
|
||||||
class QScrollArea;
|
class QScrollArea;
|
||||||
class QShowEvent;
|
class QuickPageHost;
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
|
// 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 {
|
||||||
class PageCollections : public ManagePageBase
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit PageCollections(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit PageCollections(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
QScrollArea *scrollArea() const
|
QScrollArea *scrollArea() const { return m_scrollArea; }
|
||||||
{
|
|
||||||
return m_scrollArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void showEvent(QShowEvent * event) override;
|
|
||||||
void resizeEvent(QResizeEvent * event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct PlaylistEntry
|
QScrollArea *m_scrollArea = nullptr;
|
||||||
{
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
QString id;
|
CollectionsViewModel *m_viewModel = nullptr;
|
||||||
QString name;
|
|
||||||
int childCount = 0;
|
|
||||||
QString dateCreatedRaw;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
QCoro::Task<void> loadData(bool isManual = false);
|
#endif // PAGECOLLECTIONS_H
|
||||||
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
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,114 +2,24 @@
|
|||||||
#define PAGELIBRARIES_H
|
#define PAGELIBRARIES_H
|
||||||
|
|
||||||
#include "managepagebase.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 AdminLibrariesViewModel;
|
||||||
class LoadingOverlay;
|
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QPushButton;
|
|
||||||
class QProgressBar;
|
|
||||||
class QResizeEvent;
|
|
||||||
class QScrollArea;
|
class QScrollArea;
|
||||||
class QTimer;
|
class QuickPageHost;
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
|
// 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 {
|
||||||
class PageLibraries : public ManagePageBase {
|
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit PageLibraries(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit PageLibraries(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
QScrollArea *scrollArea() const { return m_scrollArea; }
|
QScrollArea *scrollArea() const { return m_scrollArea; }
|
||||||
|
|
||||||
protected:
|
|
||||||
void showEvent(QShowEvent* event) override;
|
|
||||||
void hideEvent(QHideEvent* event) override;
|
|
||||||
void resizeEvent(QResizeEvent* event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QCoro::Task<void> loadData(bool isManual = false);
|
QScrollArea *m_scrollArea = nullptr;
|
||||||
QCoro::Task<void> loadCardImage(LibraryCard* card, VirtualFolder folder,
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
quint64 imageLoadGeneration);
|
AdminLibrariesViewModel *m_viewModel = nullptr;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,690 +1,36 @@
|
|||||||
#include "pagelogs.h"
|
#include "pagelogs.h"
|
||||||
|
|
||||||
#include "../../components/elidedlabel.h"
|
#include "../../quick/logsviewmodel.h"
|
||||||
#include "../../components/moderntoast.h"
|
#include "../../quick/quickpagehost.h"
|
||||||
#include "../../managers/thememanager.h"
|
|
||||||
#include <qembycore.h>
|
|
||||||
#include <services/admin/adminservice.h>
|
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QHideEvent>
|
||||||
#include <QDir>
|
#include <QScrollArea>
|
||||||
#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 <QShowEvent>
|
#include <QShowEvent>
|
||||||
#include <QSignalBlocker>
|
#include <QUrl>
|
||||||
#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;
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
PageLogs::PageLogs(QEmbyCore *core, QWidget *parent)
|
PageLogs::PageLogs(QEmbyCore *core, QWidget *parent)
|
||||||
: ManagePageBase(core, tr("Logs"), parent) {
|
: ManagePageBase(core, QString(), parent) {
|
||||||
m_mainLayout->setContentsMargins(16, 30, 0, 24);
|
m_mainLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
m_mainLayout->setSpacing(16);
|
m_mainLayout->setSpacing(0);
|
||||||
|
|
||||||
auto* titleRow = new QHBoxLayout();
|
m_quickHost = new QuickPageHost(this);
|
||||||
titleRow->setContentsMargins(0, 0, 12, 0);
|
m_viewModel = new LogsViewModel(core, this, m_quickHost);
|
||||||
titleRow->setSpacing(8);
|
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);
|
// ManageView tracks legacy scroll areas. QML owns the visible scrolling.
|
||||||
m_btnRefresh->setObjectName("ManageRefreshBtn");
|
m_scrollArea = new QScrollArea(this);
|
||||||
m_btnRefresh->setCursor(Qt::PointingHandCursor);
|
m_scrollArea->hide();
|
||||||
m_btnRefresh->setFixedSize(32, 32);
|
|
||||||
m_btnRefresh->setToolTip(tr("Refresh"));
|
|
||||||
titleRow->addWidget(m_btnRefresh);
|
|
||||||
titleRow->addStretch(1);
|
|
||||||
|
|
||||||
m_statsLabel = new QLabel(this);
|
|
||||||
m_statsLabel->setObjectName("ManageInfoValue");
|
|
||||||
m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
|
||||||
m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
|
|
||||||
m_statsLabel->setText(tr("Loading..."));
|
|
||||||
titleRow->addWidget(m_statsLabel);
|
|
||||||
|
|
||||||
m_mainLayout->addLayout(titleRow);
|
|
||||||
|
|
||||||
auto* fileListCard = new QFrame(this);
|
|
||||||
fileListCard->setObjectName("ManageLogListCard");
|
|
||||||
fileListCard->setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
fileListCard->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
|
||||||
fileListCard->setMaximumHeight(176);
|
|
||||||
|
|
||||||
auto* fileListLayout = new QVBoxLayout(fileListCard);
|
|
||||||
fileListLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
fileListLayout->setSpacing(0);
|
|
||||||
|
|
||||||
m_fileList = new QListWidget(fileListCard);
|
|
||||||
m_fileList->setObjectName("ManageLogFileList");
|
|
||||||
m_fileList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
m_fileList->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
m_fileList->setTextElideMode(Qt::ElideRight);
|
|
||||||
m_fileList->setWordWrap(false);
|
|
||||||
m_fileList->setSpacing(0);
|
|
||||||
m_fileList->setMouseTracking(true);
|
|
||||||
m_fileList->setUniformItemSizes(true);
|
|
||||||
m_fileList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
|
|
||||||
m_fileList->setMinimumHeight(140);
|
|
||||||
m_fileList->setItemDelegate(new LogFileItemDelegate(
|
|
||||||
m_fileList, [this](int row, QString logName) {
|
|
||||||
Q_UNUSED(row)
|
|
||||||
if (!logName.isEmpty()) {
|
|
||||||
m_pendingDownloadTask = downloadLogFile(logName);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
fileListLayout->addWidget(m_fileList, 1);
|
|
||||||
|
|
||||||
m_emptyLabel = new QLabel(tr("No log files found"), fileListCard);
|
|
||||||
m_emptyLabel->setObjectName("ManageEmptyLabel");
|
|
||||||
m_emptyLabel->setAlignment(Qt::AlignCenter);
|
|
||||||
m_emptyLabel->setContentsMargins(0, 0, 0, 0);
|
|
||||||
m_emptyLabel->hide();
|
|
||||||
fileListLayout->addWidget(m_emptyLabel, 1);
|
|
||||||
|
|
||||||
m_mainLayout->addWidget(fileListCard);
|
|
||||||
|
|
||||||
auto* dividerContainer = new QWidget(this);
|
|
||||||
auto* dividerLayout = new QHBoxLayout(dividerContainer);
|
|
||||||
dividerLayout->setContentsMargins(0, 0, 12, 0);
|
|
||||||
dividerLayout->setSpacing(0);
|
|
||||||
|
|
||||||
auto* divider = new QFrame(dividerContainer);
|
|
||||||
divider->setObjectName("ManageLogDivider");
|
|
||||||
divider->setFixedHeight(1);
|
|
||||||
divider->setFrameShape(QFrame::NoFrame);
|
|
||||||
dividerLayout->addWidget(divider);
|
|
||||||
|
|
||||||
m_mainLayout->addWidget(dividerContainer);
|
|
||||||
|
|
||||||
auto* viewerCard = new QFrame(this);
|
|
||||||
viewerCard->setObjectName("ManageLogViewerCard");
|
|
||||||
viewerCard->setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
viewerCard->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
|
||||||
|
|
||||||
auto* viewerLayout = new QVBoxLayout(viewerCard);
|
|
||||||
viewerLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
viewerLayout->setSpacing(0);
|
|
||||||
|
|
||||||
auto* viewerHeader = new QWidget(viewerCard);
|
|
||||||
auto* viewerHeaderLayout = new QVBoxLayout(viewerHeader);
|
|
||||||
viewerHeaderLayout->setContentsMargins(16, 0, 16, 10);
|
|
||||||
viewerHeaderLayout->setSpacing(6);
|
|
||||||
|
|
||||||
m_currentFileNameLabel = new ElidedLabel(viewerHeader);
|
|
||||||
m_currentFileNameLabel->setObjectName("ManageLogSelectedName");
|
|
||||||
viewerHeaderLayout->addWidget(m_currentFileNameLabel);
|
|
||||||
|
|
||||||
m_currentFileMetaLabel = new QLabel(viewerHeader);
|
|
||||||
m_currentFileMetaLabel->setObjectName("ManageLogSelectedMeta");
|
|
||||||
m_currentFileMetaLabel->setWordWrap(true);
|
|
||||||
viewerHeaderLayout->addWidget(m_currentFileMetaLabel);
|
|
||||||
viewerLayout->addWidget(viewerHeader);
|
|
||||||
|
|
||||||
m_viewerPlaceholder = new QLabel(tr("Select a log file to inspect its contents"), viewerCard);
|
|
||||||
m_viewerPlaceholder->setObjectName("ManageEmptyLabel");
|
|
||||||
m_viewerPlaceholder->setAlignment(Qt::AlignCenter);
|
|
||||||
m_viewerPlaceholder->setWordWrap(true);
|
|
||||||
m_viewerPlaceholder->setMargin(24);
|
|
||||||
viewerLayout->addWidget(m_viewerPlaceholder, 1);
|
|
||||||
|
|
||||||
m_logViewer = new QPlainTextEdit(viewerCard);
|
|
||||||
m_logViewer->setObjectName("ManageLogViewer");
|
|
||||||
m_logViewer->setReadOnly(true);
|
|
||||||
m_logViewer->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
m_logViewer->setLineWrapMode(QPlainTextEdit::WidgetWidth);
|
|
||||||
m_logViewer->setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
|
|
||||||
m_logViewer->document()->setDocumentMargin(16.0);
|
|
||||||
|
|
||||||
QFont monoFont(QStringLiteral("Consolas"), 10);
|
|
||||||
monoFont.setStyleHint(QFont::Monospace);
|
|
||||||
m_logViewer->setFont(monoFont);
|
|
||||||
m_logViewer->hide();
|
|
||||||
viewerLayout->addWidget(m_logViewer, 1);
|
|
||||||
|
|
||||||
m_mainLayout->addWidget(viewerCard, 1);
|
|
||||||
|
|
||||||
updateSelectionDetails(QString());
|
|
||||||
|
|
||||||
connect(m_btnRefresh, &QPushButton::clicked, this, [this]() { m_pendingTask = loadFileList(true); });
|
|
||||||
|
|
||||||
connect(m_fileList, &QListWidget::currentRowChanged, this, [this](int row) {
|
|
||||||
if (row < 0 || row >= m_fileList->count()) {
|
|
||||||
m_currentLogName.clear();
|
|
||||||
updateSelectionDetails(QString());
|
|
||||||
m_logViewer->clear();
|
|
||||||
setViewerPlaceholder(tr("Select a log file to inspect its contents"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString logName = m_fileList->item(row)->data(LogNameRole).toString();
|
|
||||||
if (logName.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectionDetails(logName);
|
|
||||||
m_pendingTask = loadLogContent(logName);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageLogs::showEvent(QShowEvent *event) {
|
void PageLogs::showEvent(QShowEvent *event) {
|
||||||
ManagePageBase::showEvent(event);
|
ManagePageBase::showEvent(event);
|
||||||
if (!m_loaded) {
|
m_viewModel->setActive(true);
|
||||||
m_pendingTask = loadFileList();
|
|
||||||
m_loaded = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString PageLogs::currentSelectedLogName() const {
|
void PageLogs::hideEvent(QHideEvent *event) {
|
||||||
if (!m_fileList || !m_fileList->currentItem()) {
|
m_viewModel->setActive(false);
|
||||||
return {};
|
ManagePageBase::hideEvent(event);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,52 +2,28 @@
|
|||||||
#define PAGELOGS_H
|
#define PAGELOGS_H
|
||||||
|
|
||||||
#include "managepagebase.h"
|
#include "managepagebase.h"
|
||||||
#include <models/admin/adminmodels.h>
|
|
||||||
#include <qcorotask.h>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
class ElidedLabel;
|
class LogsViewModel;
|
||||||
class QLabel;
|
class QHideEvent;
|
||||||
class QListWidget;
|
class QScrollArea;
|
||||||
class QPlainTextEdit;
|
|
||||||
class QPushButton;
|
|
||||||
class QShowEvent;
|
class QShowEvent;
|
||||||
|
class QuickPageHost;
|
||||||
|
|
||||||
|
// Compatibility shell retained until ManageView itself moves to QML.
|
||||||
class PageLogs : public ManagePageBase {
|
class PageLogs : public ManagePageBase {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit PageLogs(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit PageLogs(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
|
QScrollArea *scrollArea() const { return m_scrollArea; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void showEvent(QShowEvent *event) override;
|
void showEvent(QShowEvent *event) override;
|
||||||
|
void hideEvent(QHideEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QCoro::Task<void> loadFileList(bool isManual = false);
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
QCoro::Task<void> loadLogContent(QString logName);
|
LogsViewModel *m_viewModel = nullptr;
|
||||||
QCoro::Task<void> downloadLogFile(QString logName);
|
QScrollArea *m_scrollArea = nullptr;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif // PAGELOGS_H
|
||||||
|
|||||||
@@ -1,579 +1,50 @@
|
|||||||
#include "pagetasks.h"
|
#include "pagetasks.h"
|
||||||
|
|
||||||
#include "../../components/moderntoast.h"
|
|
||||||
#include "../../components/taskeditdialog.h"
|
#include "../../components/taskeditdialog.h"
|
||||||
#include "../../utils/layoututils.h"
|
#include "../../quick/embyimageprovider.h"
|
||||||
#include "../../utils/taskutils.h"
|
#include "../../quick/quickpagehost.h"
|
||||||
#include <qembycore.h>
|
#include "../../quick/tasksviewmodel.h"
|
||||||
#include <services/admin/adminservice.h>
|
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QHideEvent>
|
||||||
#include <QFrame>
|
|
||||||
#include <QGridLayout>
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QPointer>
|
|
||||||
#include <QProgressBar>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QShowEvent>
|
#include <QShowEvent>
|
||||||
#include <QStringList>
|
#include <QUrl>
|
||||||
#include <QTimer>
|
|
||||||
#include <QTransform>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <QVariantAnimation>
|
|
||||||
#include "../../tokens/AnimationTokens.h"
|
|
||||||
#include "../../utils/refreshspin.h"
|
|
||||||
|
|
||||||
#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;
|
connect(m_viewModel, &TasksViewModel::editRequested, this,
|
||||||
|
[this](const QString &taskId, const QString &taskName) {
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
auto *dialog = new TaskEditDialog(m_core, taskId, taskName, this);
|
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->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
dialog->open();
|
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)
|
void PageTasks::showEvent(QShowEvent *event) {
|
||||||
{
|
ManagePageBase::showEvent(event);
|
||||||
auto *card = new QFrame(parent);
|
m_viewModel->setActive(true);
|
||||||
card->setObjectName("ManageInfoCard");
|
|
||||||
card->setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
card->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
|
||||||
|
|
||||||
auto *layout = new QVBoxLayout(card);
|
|
||||||
layout->setContentsMargins(14, 10, 14, 10);
|
|
||||||
layout->setSpacing(4);
|
|
||||||
|
|
||||||
valueLabel = new QLabel(QStringLiteral("0"), card);
|
|
||||||
valueLabel->setObjectName("TaskSummaryValue");
|
|
||||||
layout->addWidget(valueLabel);
|
|
||||||
|
|
||||||
auto *titleLabel = new QLabel(title, card);
|
|
||||||
titleLabel->setObjectName("TaskSummaryCaption");
|
|
||||||
titleLabel->setWordWrap(false);
|
|
||||||
layout->addWidget(titleLabel);
|
|
||||||
layout->addStretch(1);
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageTasks::clearTaskSections()
|
void PageTasks::hideEvent(QHideEvent *event) {
|
||||||
{
|
m_viewModel->setActive(false);
|
||||||
LayoutUtils::clearLayout(m_taskSectionsLayout);
|
ManagePageBase::hideEvent(event);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,22 @@
|
|||||||
#define PAGETASKS_H
|
#define PAGETASKS_H
|
||||||
|
|
||||||
#include "managepagebase.h"
|
#include "managepagebase.h"
|
||||||
#include <models/admin/adminmodels.h>
|
|
||||||
#include <qcorotask.h>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
class QLabel;
|
|
||||||
class QPushButton;
|
|
||||||
class QScrollArea;
|
|
||||||
class QHideEvent;
|
class QHideEvent;
|
||||||
|
class QScrollArea;
|
||||||
class QShowEvent;
|
class QShowEvent;
|
||||||
class QTimer;
|
class QuickPageHost;
|
||||||
class QVBoxLayout;
|
class TasksViewModel;
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
|
// Compatibility shell kept for ManageView while the page body lives in QML.
|
||||||
class PageTasks : public ManagePageBase {
|
class PageTasks : public ManagePageBase {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit PageTasks(QEmbyCore *core, QWidget *parent = nullptr);
|
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; }
|
QScrollArea *scrollArea() const { return m_scrollArea; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -26,42 +25,9 @@ protected:
|
|||||||
void hideEvent(QHideEvent *event) override;
|
void hideEvent(QHideEvent *event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QCoro::Task<void> loadData(bool isManual = false);
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
QCoro::Task<void> onRunClicked(QString taskId, QString taskName);
|
TasksViewModel *m_viewModel = nullptr;
|
||||||
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;
|
|
||||||
QScrollArea *m_scrollArea = 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
@@ -2,183 +2,28 @@
|
|||||||
#define PAGETRANSCODING_H
|
#define PAGETRANSCODING_H
|
||||||
|
|
||||||
#include "managepagebase.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 QScrollArea;
|
||||||
class QShowEvent;
|
class QuickPageHost;
|
||||||
class QSpinBox;
|
class TranscodingViewModel;
|
||||||
class QVBoxLayout;
|
|
||||||
class QWidget;
|
|
||||||
class ModernComboBox;
|
|
||||||
class ModernSwitch;
|
|
||||||
class ModernTagInput;
|
|
||||||
class TranscodingCodecRowWidget;
|
|
||||||
|
|
||||||
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
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit PageTranscoding(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit PageTranscoding(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
QScrollArea *scrollArea() const { return m_scrollArea; }
|
QScrollArea *scrollArea() const { return m_scrollArea; }
|
||||||
|
|
||||||
protected:
|
|
||||||
void showEvent(QShowEvent* event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct EmbyCodecState {
|
void choosePath(const QString &key, bool directory);
|
||||||
QString codecId;
|
void showCodecInfo(const QString &codecId);
|
||||||
QString direction;
|
void showCodecParameters(const QString &codecId);
|
||||||
QString mediaType;
|
|
||||||
QString title;
|
|
||||||
QString description;
|
|
||||||
int priority = 0;
|
|
||||||
bool enabled = false;
|
|
||||||
TranscodingCodecRowWidget* row = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
QScrollArea *m_scrollArea = nullptr;
|
||||||
QWidget* m_scrollContentWidget = nullptr;
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
QWidget* m_contentContainer = nullptr;
|
TranscodingViewModel *m_viewModel = 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,291 +1,55 @@
|
|||||||
#include "pageusers.h"
|
#include "pageusers.h"
|
||||||
#include "../../components/adminusercard.h"
|
|
||||||
#include "../../components/adduserdialog.h"
|
#include "../../components/adduserdialog.h"
|
||||||
#include "../../components/flowlayout.h"
|
|
||||||
#include "../../components/moderntoast.h"
|
|
||||||
#include "../../components/usereditdialog.h"
|
#include "../../components/usereditdialog.h"
|
||||||
#include "../../components/modernmessagebox.h"
|
#include "../../quick/embyimageprovider.h"
|
||||||
#include <qembycore.h>
|
#include "../../quick/quickpagehost.h"
|
||||||
#include <services/admin/adminservice.h>
|
#include "usersviewmodel.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 <QUrl>
|
||||||
|
|
||||||
PageUsers::PageUsers(QEmbyCore *core, QWidget *parent)
|
PageUsers::PageUsers(QEmbyCore *core, QWidget *parent)
|
||||||
: ManagePageBase(core, tr("Users"), parent) {
|
: ManagePageBase(core, QString(), parent) {
|
||||||
m_mainLayout->setContentsMargins(16, 30, 0, 0);
|
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();
|
connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel,
|
||||||
titleRow->setContentsMargins(0, 0, 12, 0);
|
[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);
|
void PageUsers::openAddUserDialog() {
|
||||||
m_btnRefresh->setObjectName("ManageRefreshBtn");
|
|
||||||
m_btnRefresh->setCursor(Qt::PointingHandCursor);
|
|
||||||
m_btnRefresh->setFixedSize(32, 32);
|
|
||||||
m_btnRefresh->setToolTip(tr("Refresh"));
|
|
||||||
titleRow->addWidget(m_btnRefresh);
|
|
||||||
|
|
||||||
titleRow->addSpacing(6);
|
|
||||||
|
|
||||||
m_btnAddUser = new QPushButton(this);
|
|
||||||
m_btnAddUser->setObjectName("ManageLibCreateBtn");
|
|
||||||
m_btnAddUser->setCursor(Qt::PointingHandCursor);
|
|
||||||
m_btnAddUser->setFixedSize(32, 32);
|
|
||||||
m_btnAddUser->setToolTip(tr("Add user"));
|
|
||||||
titleRow->addWidget(m_btnAddUser);
|
|
||||||
|
|
||||||
titleRow->addStretch(2);
|
|
||||||
|
|
||||||
m_statsLabel = new QLabel(this);
|
|
||||||
m_statsLabel->setObjectName("ManageInfoValue");
|
|
||||||
m_statsLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
|
||||||
m_statsLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
|
|
||||||
titleRow->addWidget(m_statsLabel);
|
|
||||||
|
|
||||||
m_mainLayout->addLayout(titleRow);
|
|
||||||
|
|
||||||
auto *scrollContent = new QWidget(this);
|
|
||||||
scrollContent->setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
auto *scrollLayout = new QVBoxLayout(scrollContent);
|
|
||||||
scrollLayout->setContentsMargins(0, 0, 12, 40);
|
|
||||||
scrollLayout->setSpacing(16);
|
|
||||||
|
|
||||||
m_cardsContainer = new QWidget(scrollContent);
|
|
||||||
m_userCardsLayout = new FlowLayout(m_cardsContainer, 0, 12, 12);
|
|
||||||
scrollLayout->addWidget(m_cardsContainer, 0, Qt::AlignTop);
|
|
||||||
|
|
||||||
|
|
||||||
m_emptyStateContainer = new QWidget(scrollContent);
|
|
||||||
m_emptyStateContainer->setSizePolicy(QSizePolicy::Preferred,
|
|
||||||
QSizePolicy::Expanding);
|
|
||||||
auto *emptyLayout = new QVBoxLayout(m_emptyStateContainer);
|
|
||||||
emptyLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
emptyLayout->setSpacing(0);
|
|
||||||
emptyLayout->addStretch(1);
|
|
||||||
m_emptyLabel = new QLabel(tr("No users found"), m_emptyStateContainer);
|
|
||||||
m_emptyLabel->setObjectName("ManageEmptyLabel");
|
|
||||||
m_emptyLabel->setAlignment(Qt::AlignCenter);
|
|
||||||
m_emptyLabel->hide();
|
|
||||||
emptyLayout->addWidget(m_emptyLabel);
|
|
||||||
emptyLayout->addStretch(1);
|
|
||||||
m_emptyStateContainer->hide();
|
|
||||||
scrollLayout->addWidget(m_emptyStateContainer, 1);
|
|
||||||
|
|
||||||
m_scrollArea = new QScrollArea(this);
|
|
||||||
m_scrollArea->setObjectName("SettingsScrollArea");
|
|
||||||
m_scrollArea->setWidget(scrollContent);
|
|
||||||
m_scrollArea->setWidgetResizable(true);
|
|
||||||
m_scrollArea->setFrameShape(QFrame::NoFrame);
|
|
||||||
m_scrollArea->viewport()->setAutoFillBackground(false);
|
|
||||||
|
|
||||||
m_mainLayout->addWidget(m_scrollArea, 1);
|
|
||||||
|
|
||||||
|
|
||||||
connect(m_btnRefresh, &QPushButton::clicked, this,
|
|
||||||
[this]() { m_pendingTask = loadData(true); });
|
|
||||||
connect(m_btnAddUser, &QPushButton::clicked, this, [this]() {
|
|
||||||
auto *dialog = new AddUserDialog(m_core, this);
|
auto *dialog = new AddUserDialog(m_core, this);
|
||||||
connect(dialog, &AddUserDialog::userCreated, this,
|
connect(dialog, &AddUserDialog::userCreated, m_viewModel,
|
||||||
[this]() { m_pendingTask = loadData(false); });
|
&UsersViewModel::reloadAfterDialog);
|
||||||
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
dialog->open();
|
dialog->open();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageUsers::showEvent(QShowEvent *event) {
|
void PageUsers::openUserEditor(const QString &userId) {
|
||||||
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;
|
|
||||||
auto *dialog = new UserEditDialog(m_core, userId, this);
|
auto *dialog = new UserEditDialog(m_core, userId, this);
|
||||||
connect(dialog, &UserEditDialog::userUpdated, this,
|
connect(dialog, &UserEditDialog::userUpdated, m_viewModel,
|
||||||
[this]() { m_pendingTask = loadData(false); });
|
&UsersViewModel::reloadAfterDialog);
|
||||||
connect(dialog, &UserEditDialog::userDeleted, this,
|
connect(dialog, &UserEditDialog::userDeleted, m_viewModel,
|
||||||
[this]() { m_pendingTask = loadData(false); });
|
&UsersViewModel::reloadAfterDialog);
|
||||||
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
dialog->open();
|
dialog->open();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,45 +2,24 @@
|
|||||||
#define PAGEUSERS_H
|
#define PAGEUSERS_H
|
||||||
|
|
||||||
#include "managepagebase.h"
|
#include "managepagebase.h"
|
||||||
#include <qcorotask.h>
|
|
||||||
#include <optional>
|
|
||||||
|
|
||||||
class FlowLayout;
|
class QuickPageHost;
|
||||||
class QLabel;
|
class UsersViewModel;
|
||||||
class QPushButton;
|
|
||||||
class QScrollArea;
|
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
class PageUsers : public ManagePageBase {
|
// 保留 ManageView 依赖的 QWidget 构造与生命周期契约;页面主体由
|
||||||
|
// AdminUsersPage.qml 渲染,复杂的新增/编辑对话框暂由 Widgets 承载。
|
||||||
|
class PageUsers final : public ManagePageBase {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit PageUsers(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit PageUsers(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
|
|
||||||
protected:
|
|
||||||
void showEvent(QShowEvent* event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QCoro::Task<void> loadData(bool isManual = false);
|
void openAddUserDialog();
|
||||||
QCoro::Task<void> onToggleUserEnabled(QString userId, QString userName, bool enable);
|
void openUserEditor(const QString &userId);
|
||||||
QCoro::Task<void> onDeleteUser(QString userId, QString userName);
|
|
||||||
void onCardClicked(const QString &userId);
|
|
||||||
void clearUserCards();
|
|
||||||
void updateContentState(int userCount);
|
|
||||||
|
|
||||||
QPushButton* m_btnRefresh;
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
QPushButton* m_btnAddUser;
|
UsersViewModel *m_viewModel = nullptr;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#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
|
||||||
@@ -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 ¤tUserId) {
|
||||||
|
beginResetModel();
|
||||||
|
m_users = std::move(users);
|
||||||
|
m_currentUserId = currentUserId;
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersListModel::setBusy(const QString &userId, bool busy) {
|
||||||
|
const int row = indexOf(userId);
|
||||||
|
if (busy)
|
||||||
|
m_busyUserIds.insert(userId);
|
||||||
|
else
|
||||||
|
m_busyUserIds.remove(userId);
|
||||||
|
if (row < 0)
|
||||||
|
return;
|
||||||
|
const QModelIndex changed = index(row);
|
||||||
|
emit dataChanged(changed, changed, {BusyRole});
|
||||||
|
}
|
||||||
|
|
||||||
|
int UsersListModel::indexOf(const QString &userId) const {
|
||||||
|
for (int row = 0; row < m_users.size(); ++row) {
|
||||||
|
if (m_users.at(row).id == userId)
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
UsersViewModel::UsersViewModel(QEmbyCore *core, QObject *parent)
|
||||||
|
: QObject(parent), m_core(core), m_users(this) {}
|
||||||
|
|
||||||
|
void UsersViewModel::setActive(bool active) {
|
||||||
|
if (m_active == active)
|
||||||
|
return;
|
||||||
|
m_active = active;
|
||||||
|
if (!active) {
|
||||||
|
++m_loadGeneration;
|
||||||
|
if (m_loading || m_refreshing) {
|
||||||
|
m_loaded = false;
|
||||||
|
m_loading = false;
|
||||||
|
m_refreshing = false;
|
||||||
|
emit stateChanged();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!m_loaded)
|
||||||
|
QCoro::connect(load(false), this, []() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersViewModel::refresh() {
|
||||||
|
if (!m_active || m_loading || m_refreshing)
|
||||||
|
return;
|
||||||
|
QCoro::connect(load(true), this, []() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersViewModel::retry() { refresh(); }
|
||||||
|
|
||||||
|
void UsersViewModel::reloadAfterDialog() {
|
||||||
|
if (!m_active) {
|
||||||
|
m_loaded = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QCoro::connect(load(false), this, []() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersViewModel::requestAddUser() { emit addUserRequested(); }
|
||||||
|
|
||||||
|
void UsersViewModel::requestEditUser(const QString &userId) {
|
||||||
|
if (m_users.userById(userId))
|
||||||
|
emit editUserRequested(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersViewModel::setUserEnabled(const QString &userId, bool enabled) {
|
||||||
|
const UserInfo *user = m_users.userById(userId);
|
||||||
|
if (!m_core || !m_core->serverManager() || !user ||
|
||||||
|
m_users.isBusy(userId) ||
|
||||||
|
user->id == m_core->serverManager()->activeProfile().userId)
|
||||||
|
return;
|
||||||
|
QCoro::connect(updateEnabled(user->id, displayName(*user), enabled), this,
|
||||||
|
[]() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsersViewModel::deleteUser(const QString &userId) {
|
||||||
|
const UserInfo *user = m_users.userById(userId);
|
||||||
|
if (!m_core || !m_core->serverManager() || !user ||
|
||||||
|
m_users.isBusy(userId) ||
|
||||||
|
user->id == m_core->serverManager()->activeProfile().userId)
|
||||||
|
return;
|
||||||
|
QCoro::connect(removeUser(user->id, displayName(*user)), this, []() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
QCoro::Task<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;
|
||||||
|
}
|
||||||
@@ -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 ¤tUserId);
|
||||||
|
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
|
||||||
+107
-3725
File diff suppressed because it is too large
Load Diff
@@ -2,265 +2,36 @@
|
|||||||
#define DETAILVIEW_H
|
#define DETAILVIEW_H
|
||||||
|
|
||||||
#include "../baseview.h"
|
#include "../baseview.h"
|
||||||
#include "models/media/mediaitem.h"
|
|
||||||
#include <qcorotask.h>
|
#include <qcorotask.h>
|
||||||
|
|
||||||
class QScrollArea;
|
class DetailViewModel;
|
||||||
class QVBoxLayout;
|
class QuickPageHost;
|
||||||
class QGridLayout;
|
|
||||||
class QLabel;
|
|
||||||
class QPushButton;
|
|
||||||
class QLineEdit;
|
|
||||||
class QIntValidator;
|
|
||||||
class FlowLayout;
|
|
||||||
class ModernMenuButton;
|
|
||||||
class SmoothScrollController;
|
|
||||||
|
|
||||||
class DetailActionWidget;
|
// QWidget compatibility shell for the QML detail page. HomeView constructs and
|
||||||
class DetailBottomInfoWidget;
|
// connects this class exactly as before; presentation state lives in
|
||||||
class MediaSectionWidget;
|
// DetailViewModel.
|
||||||
class QGraphicsDropShadowEffect;
|
class DetailView final : public BaseView {
|
||||||
|
|
||||||
class DetailView : public BaseView {
|
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit DetailView(QEmbyCore *core, QWidget *parent = nullptr);
|
explicit DetailView(QEmbyCore *core, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
QCoro::Task<void> loadItem(const QString &itemId,
|
||||||
|
const MediaItem &seedItem = {});
|
||||||
|
void prepareForStackLeave() override;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void scrollToTop() override;
|
||||||
QCoro::Task<void> loadItem(const QString &itemId, const MediaItem &seedItem = {});
|
|
||||||
|
|
||||||
public Q_SLOTS:
|
|
||||||
void onMediaItemUpdated(const MediaItem &item) override;
|
void onMediaItemUpdated(const MediaItem &item) override;
|
||||||
|
|
||||||
void beginOptimisticPlayedUpdate() override;
|
void beginOptimisticPlayedUpdate() override;
|
||||||
void endOptimisticPlayedUpdate() 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:
|
private:
|
||||||
void setupUi();
|
QCoro::Task<void> playSelected(MediaItem item, int sourceIndex, bool resume);
|
||||||
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> updateUi(MediaItem item, bool isSilentRefresh = false);
|
QuickPageHost *m_quickHost = nullptr;
|
||||||
|
DetailViewModel *m_viewModel = nullptr;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#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 §ion) {
|
||||||
|
if (section == QLatin1String("seasons"))
|
||||||
|
return &m_seasons;
|
||||||
|
if (section == QLatin1String("episodes"))
|
||||||
|
return &m_episodes;
|
||||||
|
if (section == QLatin1String("cast"))
|
||||||
|
return &m_cast;
|
||||||
|
if (section == QLatin1String("collections"))
|
||||||
|
return &m_collections;
|
||||||
|
if (section == QLatin1String("additional"))
|
||||||
|
return &m_additional;
|
||||||
|
return &m_similar;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailViewModel::activateSectionItem(QString section, int row) {
|
||||||
|
if (const MediaItem *item = sectionModel(section)->itemAt(row))
|
||||||
|
emit itemActivated(*item);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailViewModel::playSectionItem(QString section, int row) {
|
||||||
|
if (const MediaItem *item = sectionModel(section)->itemAt(row))
|
||||||
|
emit playRequested(*item, 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailViewModel::favoriteSectionItem(QString section, int row) {
|
||||||
|
if (const MediaItem *item = sectionModel(section)->itemAt(row))
|
||||||
|
emit favoriteRequested(*item);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailViewModel::moreSectionItem(QString section, int row) {
|
||||||
|
if (const MediaItem *item = sectionModel(section)->itemAt(row))
|
||||||
|
emit moreRequested(*item);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user