Compare commits

...
15 Commits
Author SHA1 Message Date
dela 2ee3a234a6 feat: complete QML shell migration and ship 0.0.8 Arch package
Replace the Widgets main shell with AppShellHost + QML routes, add a QML
independent player window, and remove the legacy player/page Widgets stack.
Also wire settings cache/proxy actions, fix quit-time engine teardown, improve
list/home scroll performance, and update Arch packaging to 0.0.8.
2026-07-27 16:41:18 +08:00
dela e2357aaa32 feat: route library browsing through QML shell 2026-07-15 14:03:31 +08:00
dela a411fd396a feat: route search through QML shell 2026-07-15 13:47:20 +08:00
dela b117bbf99e feat: checkpoint QML view migration 2026-07-15 11:57:34 +08:00
dela 3002adb7bf style: unify visual language on Slate/Blue token scales
- add tokens/ColorTokens.h (Slate/Blue/Red/Green scales, semantic roles,
  shared SearchTint ramp) and reference it from painted widgets
- dark: media-action-menu and modern-tray-menu moved from Gray/Zinc to Slate
- light: full Gray->Slate sweep, one-off tinted blues collapsed to
  Blue-50/Blue-100/Slate-50, Windows-blue links mapped to Blue-600/700
- radii off-scale values (10px/16px) converged to the 8/14 scale,
  font sizes 9/15/22/26px converged to the documented scale
- dark white overlays collapsed from 40+ alpha stops to a 6-step scale
  (player OSD keeps a separate high-alpha band, now decimal notation)
- librarycard/collectioncard palettes now read from ColorTokens (no visual change)
- searchhistorychip/searchcompleterpopup private blue palettes replaced by
  the shared SearchTint ramp so both render identical heat levels
- removed dead #playerControlBar/#playPauseBtn and webdav-visibility-toggle
  rules; close-button red unified on #DC2626/#EF4444
- palette header comments in both QSS files updated as the reference
2026-07-08 11:45:11 +08:00
dela ab1accc821 perf: smooth UI animations and reduce repaint overhead
Batch of performance and animation work: shared FadeHelper/MotionPrefs
primitives, hover animators, shadow pixmap caching, smooth scrolling,
seek time bubble, and widespread component repaint reductions.
2026-07-08 11:29:38 +08:00
dela 03bc33518c chore: trim mpv warmup whitespace 2026-06-22 13:51:41 +08:00
dela 7979a108c9 feat: encrypt saved server profile tokens 2026-06-22 13:51:10 +08:00
dela 4b650d4152 feat: add mpv tone mapping setting 2026-06-22 13:50:34 +08:00
dela 53ef34195e fix: sync danmaku and buffer updates with mpv 2026-06-22 13:49:35 +08:00
dela 9a5e04d281 fix: keep mpv relay routes isolated 2026-06-22 13:48:35 +08:00
dela 5717181e37 fix: request desktop opengl for mpv rendering 2026-06-22 13:46:56 +08:00
dela 4fcfde8be3 fix: refine playback shutdown and danmaku sizing 2026-06-21 21:59:54 +08:00
dela cf58267d41 perf: sync native danmaku with screen refresh 2026-06-21 21:30:57 +08:00
dela 907de1feeb perf(ui): replace animated-sidebar drop shadows with pre-baked 9-patch overlay
QGraphicsDropShadowEffect forces the host widget through an off-screen
software Gaussian blur on every repaint. On the floating sidebar (HomeView,
blur 25) and the player right sidebar (PlayerView, blur 35), the blur was
re-rasterized on every frame of the 350ms slide animation, the main source
of UI jank.

Bake the blur once into a 9-patch PNG (cached to CacheLocation/shadows via
QGraphicsBlurEffect) and render it at runtime as a cheap 9-slice stretch with
zero per-frame blur cost:

- utils/shadowpixmap: off-screen bake + on-disk cache, dpr-aware.
- components/shadowoverlay: mouse-transparent sibling widget, lazy-baked,
  paints the 9-patch; optional body cutout for semi-transparent targets so
  the shadow does not bleed through the body.
- homeview/playerview: drop the effect; the overlay tracks the sidebar
  geometry via the existing eventFilter (Move/Resize/Show/Hide), so every
  setGeometry/move site and the slide animation drive it for free.
2026-06-18 17:03:57 +08:00
332 changed files with 63689 additions and 64059 deletions
+7 -2
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.19)
project(qEmby VERSION 0.0.6 LANGUAGES CXX)
project(qEmby VERSION 0.0.8 LANGUAGES CXX)
if(APPLE)
set(CMAKE_OSX_DEPLOYMENT_TARGET 13.0)
@@ -87,7 +87,7 @@ set(QEMBY_QT_ROOT "" CACHE PATH
qemby_append_default_qt_prefixes()
# 寻找 Qt6 (不再允许降级)
find_package(Qt6 REQUIRED COMPONENTS Widgets Core Network Concurrent OpenGLWidgets LinguistTools WebSockets Svg)
find_package(Qt6 REQUIRED COMPONENTS Widgets Core Network Concurrent OpenGLWidgets LinguistTools WebSockets Svg Quick Qml QuickWidgets QuickControls2)
if(UNIX AND NOT APPLE)
find_package(Qt6 QUIET COMPONENTS DBus)
endif()
@@ -184,6 +184,11 @@ unset(_qemby_saved_skip_install_rules)
set(QWINDOWKIT_INSTALL OFF CACHE BOOL
"Disable QWindowKit development install rules inside qEmby" FORCE)
# QML 外壳迁移需要 QWK::QuickWindowAgent 为 ApplicationWindow 提供无边框窗口;
# 上游默认只构建 widgets 模块。
set(QWINDOWKIT_BUILD_QUICK ON CACHE BOOL
"Build QWindowKit quick module for the QML shell" FORCE)
add_subdirectory(libs/qwindowkit)
add_subdirectory(libs/qwindowkit/examples/shared)
add_subdirectory(src/qEmbyCore)
+73
View File
@@ -0,0 +1,73 @@
# qEmby QML 迁移状态
更新时间:2026-07-27(设置缺口补全)
## 结论
**唯一外壳 = 全 QML**(`AppShellHost` + `AppShell.qml`)。
Widgets 页面壳与播放器栈已删除。
**独立播放窗 = 第二个 QML `ApplicationWindow`**(`IndependentPlayerHost` + `IndependentPlayerWindow.qml`)。
保留 `QApplication`:托盘、文件对话框、业务 Widget 对话框(弹幕设置/识别、字幕设置、库/用户管理等)仍需要。
## 播放路径
| 模式 | 实现 |
|---|---|
| 壳内嵌入 | `PlayerRouteController` → 路由 `player` → `PlayerPage` |
| 独立窗口 | `IndependentPlayerHost` → 二级引擎 → `IndependentPlayerWindow` |
| 外部播放器 | `PlaybackManager` + `ExternalPlayerIPC` |
## 已删除(播放器栈 + 无引用 Widgets)
**播放器:** `PlayerWindow` `PlayerView` `NativeDanmakuOverlay` `MpvWidget` 及 OSD/切集/统计/长按/seek bubble 等。
**孤儿 UI(约 35 组,无 live `#include` 路径):**
`MediaGridWidget` `Horizontal*Gallery` `MediaCardDelegate` `MediaListModel`
`Detail*Widget` `AdminUserCard` `CollectionCard/Grid`
`SearchHistoryPopup`(Widgets 版,QML 已有)`ServerWheelView`
`DashboardSectionOrderWidget/Strip` `DanmakuServerDialog*`
`LibraryActionMenu` `TranscodingCodecList*`
`SettingsCard/SubPanel`(Widgets 版)`ThemeTransitionWidget` `ShadowOverlay`
`ShimmerWidget` `ModernScrollPanel` `ModernSortButton` `ModernNumberInput` 等。
## 仍保留的 Widgets
| 角色 | 代表 |
|---|---|
| 播放对话框 | `PlayerDanmaku*Dialog` `PlayerSubtitleSettingsDialog` `PlayerOverlayDialog` |
| 管理/库 | `LibraryCreate/EditDlg` `UserEditDialog` `TaskEditDialog` `*Codec*Dialog` |
| 媒体操作 | `MediaActionMenu` `MediaIdentify/ImageEditDialog` `AddToPlaylistDialog` |
| 通用 | `ModernDialogBase` `ModernToast` `ModernMessageBox` `ProxySettingsDialog` `WebDav*` |
| 播放核心 | `MpvController` `PlayerDanmakuController` |
## 启动
```bash
cmake --build cmake-build-debug --target qEmbyApp -j$(nproc)
./cmake-build-debug/bin/qEmbyApp
```
## 本轮设置补全
| 功能 | 位置 |
|---|---|
| 主页区块自定义排序(↑↓ + 重置) | `SettingsLibraryPage` + `SettingsViewModel::{customHomeSectionOrderEnabled,homeSectionOrder,moveHomeSection}` |
| 弹幕 API 服务器管理(选/启停/增删改) | `SettingsPlayerPage` + `DanmakuSettings` 桥接 |
| 缺失弹幕属性 | `danmakuSourceMode` / `AutoLoad` / `AutoMatch` / `WithRelated` / `CacheHours` |
| 库开关按服务器作用域 | `showContinueWatching` 等写入 `server/<id>/library/...`(与 Dashboard 一致,兼容读全局旧值) |
## 本轮优化(2026-07-27)
| 项 | 说明 |
|---|---|
| 设置缓存清理 | `clearRecommendCache` / `clearImageCache` 已接线 + toast |
| 代理设置 | 设置-通用「配置…」打开 `ProxySettingsDialog` |
| 列表性能 | MediaCard DPR sourceSize、滑动关 smooth/mipmap;Grid/Section 减小 cacheBuffer |
| 首页 | 区块 Loader 异步;reduceMotion 跳过入场动画 |
| 路由 | reduceMotion / 播放器页瞬时切页,少缩放动画 |
## 后续可选
- 播放器内弹幕设置 / 识别 / 字幕对话框 QML 化
- 主页排序改为拖拽(当前为上下按钮,足够用)
+420
View File
@@ -0,0 +1,420 @@
# 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` 属性的问题。
2026-07-15 完成 Phase 2 第二批 Search:
- `Router.qml` 增加 Search 静态组件;Search 与 Home/Favorites 共享根 engine、图片 provider 和 action bridge。
- `HomeView::triggerSearch()` 改为 push 规范 search route;同一 Router widget 内切换也会记录历史,因此 Home → Search → Back 可以恢复 Home,Search → legacy Detail → Back 可以恢复原搜索。
- `SearchViewModel` 增加 active/generation 取消语义;inactive 时只记录待搜索 query,重新激活后按需恢复,避免隐藏页面回写陈旧结果。
- 导航契约测试新增 search key 和返回用例;默认 Router 与 legacy fallback 的无服务器 offscreen 冒烟均无 runtime 输出。
2026-07-15 完成 Phase 2 第三批 Category/Library/Person/Filtered:
- 根 Router 静态注册 CategoryPage 与 LibraryPage;Library、Person、Filtered 复用同一页面类型,但 route key 变化时重新创建 QML 页面。
- HomeView 的分类、媒体库、文件夹、人物和过滤入口统一 push 规范 route;legacy fallback 仍调用原 `create*View()`。
- Favorite Category 将 `favorite` 纳入规范 key,避免与普通 Category 使用相同身份。
- Category/Library ViewModel 的 inactive 路由更新只保存最新参数;generation 同时覆盖详情探测、首屏、渐进加载和 loadMore,防止离开后陈旧回写。
- 当前每类参数化页面仍共享一个会话 ViewModel;已加载 route 返回时复用数据,切换 route 时重建状态。每个历史 route 独立的 LRU 实例和滚动 uiState 尚未实现。
- 导航测试增加 Favorite Category 身份用例;Debug 构建、qmllint、默认 Router 与 legacy fallback 无服务器启动冒烟通过。
+2 -2
View File
@@ -9,7 +9,7 @@ if(UNIX AND NOT APPLE)
"${CMAKE_INSTALL_LIBDIR}/${QEMBY_RUNTIME_LIB_SUBDIR}")
endif()
foreach(_qemby_runtime_target IN ITEMS qEmbyApp qEmbyCore QWKCore QWKWidgets)
foreach(_qemby_runtime_target IN ITEMS qEmbyApp qEmbyCore QWKCore QWKWidgets QWKQuick)
if(TARGET "${_qemby_runtime_target}")
set_target_properties("${_qemby_runtime_target}" PROPERTIES
INSTALL_RPATH "$ORIGIN"
@@ -18,7 +18,7 @@ if(UNIX AND NOT APPLE)
endforeach()
set(_qemby_qwk_install_targets)
foreach(_qemby_qwk_target IN ITEMS QWKCore QWKWidgets)
foreach(_qemby_qwk_target IN ITEMS QWKCore QWKWidgets QWKQuick)
if(TARGET "${_qemby_qwk_target}")
list(APPEND _qemby_qwk_install_targets "${_qemby_qwk_target}")
endif()
+6 -3
View File
@@ -1,7 +1,7 @@
# Maintainer: local
pkgname=qemby
pkgver=0.0.6
pkgver=0.0.8
pkgrel=1
pkgdesc="Desktop client for Emby and Jellyfin media servers"
arch=('x86_64')
@@ -15,6 +15,7 @@ depends=(
'mpv'
'qcoro'
'qt6-base'
'qt6-declarative'
'qt6-svg'
'qt6-websockets'
'spdlog'
@@ -29,17 +30,19 @@ makedepends=(
# new repository has published versioned release tags.
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
options=('!lto')
build() {
cmake -S "${srcdir}/qEmby-${pkgver}" -B "${srcdir}/build" -G Ninja \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DQEMBY_USE_SYSTEM_DEPS=ON \
-DQEMBY_FETCHCONTENT_FALLBACK=OFF \
-DFETCHCONTENT_FULLY_DISCONNECTED=ON \
-DQEMBY_NATIVE_OPTIMIZE=OFF \
-DQEMBY_ENABLE_IPO=OFF
-DQEMBY_ENABLE_IPO=OFF \
-DQEMBY_BUILD_NAVIGATION_TESTS=OFF
cmake --build "${srcdir}/build"
}
+28 -21
View File
@@ -1,44 +1,51 @@
# Arch package
This PKGBUILD is intended for Arch Linux local release packaging. It builds
from a local source archive exported from this repository, uses Arch system
libraries, and disables FetchContent so package builds do not download
dependencies during CMake configure.
从当前仓库源码打包 qEmby(使用系统 Qt6 / mpv / qcoro / spdlog)。
Before publishing an AUR-style release package, switch `source` to the new
repository's versioned tag archive and replace the temporary `SKIP` checksum
with a real checksum, for example by running `updpkgsums` after the tag archive
is available.
## 构建
## Build
```fish
```bash
cd packaging/arch
git archive --format=tar.gz --prefix=qEmby-0.0.6/ -o qemby-0.0.6.tar.gz HEAD
# 从工作树导出源码包(含未提交改动;排除 build 产物)
VERSION=0.0.8
rm -f "qemby-${VERSION}.tar.gz"
tar -C ../.. -czf "qemby-${VERSION}.tar.gz" \
--transform "s,^,qEmby-${VERSION}/," \
--exclude='./build' \
--exclude='./cmake-build-*' \
--exclude='./packaging/arch/pkg' \
--exclude='./packaging/arch/src' \
--exclude='./packaging/arch/*.pkg.tar.zst' \
--exclude='./packaging/arch/*.tar.gz' \
--exclude='./.git' \
--exclude='./.omo' \
.
makepkg -f
```
## Install
## 安装
```fish
```bash
cd packaging/arch
sudo pacman -U qemby-*.pkg.tar.zst
sudo pacman -U ./qemby-0.0.8-1-x86_64.pkg.tar.zst
```
## Run
## 运行
```fish
```bash
qemby
```
If native Wayland causes windowing or OpenGL issues, run with the XCB fallback:
Wayland 异常时可强制 XCB:
```fish
```bash
QEMBY_FORCE_XCB=1 qemby
```
## Uninstall
## 卸载
```fish
```bash
sudo pacman -R qemby
```
+103 -2
View File
@@ -1,8 +1,8 @@
# src/qEmbyApp/CMakeLists.txt
# 界面语言:英文源文 + 简体中文。法语 .ts 保留仓库但不参与编译。
set(TS_FILES
resources/i18n/qEmby_zh_CN.ts
resources/i18n/qEmby_fr_FR.ts
)
# set(QSS_QWK_FILES ../../libs/qwindowkit/examples/mainwindow/mainwindow.qrc)
# set(QRC_QWK_FILES ../../libs/qwindowkit/examples/shared/resources/shared.qrc)
@@ -10,15 +10,111 @@ set(APP_QRC_FILES resources/resources.qrc)
file(GLOB_RECURSE APP_HEADERS CONFIGURE_DEPENDS "*.h")
file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS "*.cpp")
list(FILTER APP_HEADERS EXCLUDE REGEX "/tests/")
list(FILTER APP_SOURCES EXCLUDE REGEX "/tests/")
qt_add_executable(qEmbyApp
MANUAL_FINALIZATION
${APP_HEADERS}
${APP_HEADERS}
${APP_SOURCES}
${TS_FILES}
${APP_QRC_FILES}
)
# ==========================================
# QML 模块:Widgets 混合迁移的 Quick 页面
# QML 页面通过 QuickPageHost (QQuickWidget) 嵌入现有页面栈;
# C++ 类型 (Theme 单例等) 通过 QML_ELEMENT 自动注册到 qEmby 模块。
# ==========================================
if(COMMAND qt_policy)
qt_policy(SET QTP0004 NEW)
endif()
qt_add_qml_module(qEmbyApp
URI qEmby
VERSION 1.0
RESOURCE_PREFIX /qt/qml
QML_FILES
qml/AppShell.qml
qml/Router.qml
qml/TitleBar.qml
qml/SideBar.qml
qml/components/SearchHistoryPopup.qml
qml/ManagePage.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/PlayerPage.qml
qml/IndependentPlayerWindow.qml
qml/AdminUsersPage.qml
qml/AdminTasksPage.qml
qml/AdminLibrariesPage.qml
qml/AdminLogsPage.qml
qml/AdminCollectionsPage.qml
qml/AdminTranscodingPage.qml
qml/EmptyState.qml
qml/SettingsPage.qml
qml/SettingsAboutPage.qml
qml/SettingsGeneralPage.qml
qml/SettingsAppearancePage.qml
qml/SettingsLibraryPage.qml
qml/SettingsPlayerPage.qml
qml/components/SettingsCard.qml
qml/components/PageHeader.qml
qml/components/SegmentedTabs.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/ConfirmDialog.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
qml/MpvSpikePage.qml
qml/MpvSpikeHarness.qml
SOURCES
quick/quicktheme.h quick/quicktheme.cpp
quick/mpvitem.h quick/mpvitem.cpp
quick/danmakupainteditem.h quick/danmakupainteditem.cpp
quick/qmltask.h quick/qmltask.cpp
quick/settingsviewmodel.h quick/settingsviewmodel.cpp
quick/loginviewmodel.h quick/loginviewmodel.cpp
quick/searchhistoryviewmodel.h quick/searchhistoryviewmodel.cpp
quick/sidebarviewmodel.h quick/sidebarviewmodel.cpp
quick/appshellhost.h quick/appshellhost.cpp
quick/shelllifecyclecontroller.h quick/shelllifecyclecontroller.cpp
)
# qmltyperegistrar 生成的注册代码以 <quicktheme.h> 形式包含头文件
target_include_directories(qEmbyApp PRIVATE quick)
option(QEMBY_BUILD_NAVIGATION_TESTS
"Build the lightweight NavigationController contract test" ON)
if(QEMBY_BUILD_NAVIGATION_TESTS)
add_executable(qEmbyNavigationTest EXCLUDE_FROM_ALL
tests/navigationcontroller_test.cpp
quick/navigationcontroller.h quick/navigationcontroller.cpp
quick/navigationroute.h quick/navigationroute.cpp
)
target_include_directories(qEmbyNavigationTest PRIVATE quick)
target_link_libraries(qEmbyNavigationTest PRIVATE Qt${QT_VERSION_MAJOR}::Core)
endif()
# ==========================================
# 集成 libmpv 播放器核心
# Linux / macOS 优先使用系统安装的 libmpv
@@ -65,8 +161,13 @@ target_link_libraries(qEmbyApp PRIVATE
Qt${QT_VERSION_MAJOR}::OpenGLWidgets
Qt${QT_VERSION_MAJOR}::Svg
Qt${QT_VERSION_MAJOR}::Concurrent
Qt${QT_VERSION_MAJOR}::Quick
Qt${QT_VERSION_MAJOR}::Qml
Qt${QT_VERSION_MAJOR}::QuickWidgets
Qt${QT_VERSION_MAJOR}::QuickControls2
qEmbyCore
QWKWidgets
QWKQuick
WidgetFrame
QCoro6::Core
QCoro6::Network
-202
View File
@@ -1,202 +0,0 @@
#include "adminusercard.h"
#include "../utils/layoututils.h"
#include "elidedlabel.h"
#include "modernswitch.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QTimeZone>
#include <QVBoxLayout>
namespace
{
QString formatIsoDateTime(QString isoDate)
{
if (isoDate.isEmpty())
{
return {};
}
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 displayNameForUser(const UserInfo &user)
{
const QString trimmedName = user.name.trimmed();
if (!trimmedName.isEmpty())
{
return trimmedName;
}
return user.id;
}
QString pageUsersText(const char *sourceText)
{
return QCoreApplication::translate("PageUsers", sourceText);
}
void setPassiveClickThrough(QWidget *widget)
{
if (!widget)
{
return;
}
widget->setAttribute(Qt::WA_TransparentForMouseEvents, true);
}
}
AdminUserCard::AdminUserCard(UserInfo userInfo, QString currentUserId, QWidget *parent)
: QFrame(parent), m_user(userInfo), m_currentUserId(currentUserId), m_displayName(displayNameForUser(m_user))
{
static constexpr int CardWidth = 268;
static constexpr int CardHeight = 196;
setObjectName("ManageUserCard");
setAttribute(Qt::WA_StyledBackground, true);
setAttribute(Qt::WA_Hover, true);
setMouseTracking(true);
setCursor(Qt::PointingHandCursor);
setToolTip(m_displayName);
setFixedSize(CardWidth, CardHeight);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
auto *cardLayout = new QVBoxLayout(this);
cardLayout->setContentsMargins(14, 12, 14, 12);
cardLayout->setSpacing(8);
auto *topRow = new QHBoxLayout();
topRow->setContentsMargins(0, 0, 0, 0);
topRow->setSpacing(10);
auto *avatarLabel = new QLabel(this);
avatarLabel->setObjectName("ManageUserAvatar");
avatarLabel->setFixedSize(48, 48);
avatarLabel->setAlignment(Qt::AlignCenter);
avatarLabel->setText(m_displayName.isEmpty() ? QStringLiteral("?") : m_displayName.left(1).toUpper());
setPassiveClickThrough(avatarLabel);
topRow->addWidget(avatarLabel, 0, Qt::AlignTop);
auto *titleColumn = new QVBoxLayout();
titleColumn->setContentsMargins(0, 0, 0, 0);
titleColumn->setSpacing(4);
auto *nameLabel = new ElidedLabel(this);
nameLabel->setObjectName("ManageCardTitle");
nameLabel->setFullText(m_displayName);
setPassiveClickThrough(nameLabel);
titleColumn->addWidget(nameLabel);
auto *badgeRow = new QHBoxLayout();
badgeRow->setContentsMargins(0, 0, 0, 0);
badgeRow->setSpacing(6);
if (m_user.isAdministrator)
{
auto *adminBadge = new QLabel(pageUsersText("Admin"), this);
adminBadge->setObjectName("ManageUserBadgeAdmin");
setPassiveClickThrough(adminBadge);
badgeRow->addWidget(adminBadge);
}
if (m_user.isDisabled)
{
auto *disabledBadge = new QLabel(pageUsersText("Disabled"), this);
disabledBadge->setObjectName("ManageUserBadgeDisabled");
setPassiveClickThrough(disabledBadge);
badgeRow->addWidget(disabledBadge);
}
badgeRow->addStretch(1);
titleColumn->addLayout(badgeRow);
topRow->addLayout(titleColumn, 1);
cardLayout->addLayout(topRow);
auto *separator = LayoutUtils::createSeparator(this);
setPassiveClickThrough(separator);
cardLayout->addWidget(separator);
const QString lastLoginText = formatIsoDateTime(m_user.lastLoginDate);
auto *lastLoginLabel = new QLabel(
pageUsersText("Last login: %1").arg(lastLoginText.isEmpty() ? QStringLiteral("-") : lastLoginText), this);
lastLoginLabel->setObjectName("ManageSessionClient");
lastLoginLabel->setWordWrap(true);
setPassiveClickThrough(lastLoginLabel);
cardLayout->addWidget(lastLoginLabel);
const QString lastActivityText = formatIsoDateTime(m_user.lastActivityDate);
auto *lastActivityLabel = new QLabel(
pageUsersText("Last active: %1").arg(lastActivityText.isEmpty() ? QStringLiteral("-") : lastActivityText),
this);
lastActivityLabel->setObjectName("ManageSessionTime");
lastActivityLabel->setWordWrap(true);
setPassiveClickThrough(lastActivityLabel);
cardLayout->addWidget(lastActivityLabel);
cardLayout->addStretch(1);
auto *footerRow = new QHBoxLayout();
footerRow->setContentsMargins(0, 0, 0, 0);
footerRow->setSpacing(8);
footerRow->addStretch(1);
auto *switchWidget = new ModernSwitch(this);
switchWidget->setChecked(!m_user.isDisabled);
switchWidget->setToolTip(m_user.isDisabled ? pageUsersText("Enable user") : pageUsersText("Disable user"));
if (m_user.id == m_currentUserId)
{
switchWidget->setEnabled(false);
switchWidget->setToolTip(pageUsersText("Cannot disable yourself"));
}
connect(switchWidget, &ModernSwitch::toggled, this,
[this, switchWidget](bool checked)
{
switchWidget->setToolTip(checked ? pageUsersText("Disable user") : pageUsersText("Enable user"));
emit toggleRequested(m_user.id, m_displayName, checked);
});
footerRow->addWidget(switchWidget, 0, Qt::AlignVCenter);
auto *deleteBtn = new QPushButton(this);
deleteBtn->setObjectName("CollCardDeleteBtn");
deleteBtn->setFixedSize(24, 24);
deleteBtn->setCursor(Qt::PointingHandCursor);
deleteBtn->setToolTip(pageUsersText("Delete user"));
if (m_user.id == m_currentUserId)
{
deleteBtn->setEnabled(false);
deleteBtn->setToolTip(pageUsersText("Cannot delete yourself"));
}
connect(deleteBtn, &QPushButton::clicked, this, [this]() { emit deleteRequested(m_user.id, m_displayName); });
footerRow->addWidget(deleteBtn, 0, Qt::AlignVCenter);
cardLayout->addLayout(footerRow);
}
void AdminUserCard::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
{
emit clicked(m_user.id);
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}
-32
View File
@@ -1,32 +0,0 @@
#ifndef ADMINUSERCARD_H
#define ADMINUSERCARD_H
#include <QFrame>
#include <QString>
#include <models/admin/adminmodels.h>
class QMouseEvent;
class AdminUserCard : public QFrame {
Q_OBJECT
public:
explicit AdminUserCard(UserInfo userInfo,
QString currentUserId,
QWidget* parent = nullptr);
signals:
void clicked(QString userId);
void toggleRequested(QString userId, QString userName, bool enable);
void deleteRequested(QString userId, QString userName);
protected:
void mouseReleaseEvent(QMouseEvent* event) override;
private:
UserInfo m_user;
QString m_currentUserId;
QString m_displayName;
};
#endif
+39 -1
View File
@@ -2,11 +2,15 @@
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QStyle>
#include <QStyleOptionButton>
#include <QVBoxLayout>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
class SectionToggleButton : public QPushButton {
public:
@@ -94,6 +98,16 @@ CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent)
m_contentLayout->setSpacing(8);
mainLayout->addWidget(m_content);
m_heightAnim = new QPropertyAnimation(m_content, "maximumHeight", this);
m_heightAnim->setDuration(AnimationTokens::DurationMs::kNormal);
connect(m_heightAnim, &QPropertyAnimation::finished, this, [this]() {
if (m_expanded) {
m_content->setMaximumHeight(QWIDGETSIZE_MAX);
} else {
m_content->setVisible(false);
}
});
updateToggleText();
connect(m_toggle, &QPushButton::clicked, this,
@@ -101,8 +115,32 @@ CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent)
}
void CollapsibleSection::setExpanded(bool expanded) {
if (m_expanded == expanded) {
updateToggleText();
return;
}
m_expanded = expanded;
m_content->setVisible(expanded);
m_heightAnim->stop();
if (MotionPrefs::reduceMotion()) {
m_content->setMaximumHeight(QWIDGETSIZE_MAX);
m_content->setVisible(expanded);
updateToggleText();
return;
}
if (expanded) {
m_content->setMaximumHeight(0);
m_content->setVisible(true);
m_heightAnim->setEasingCurve(AnimationTokens::Curve::enter());
m_heightAnim->setStartValue(0);
m_heightAnim->setEndValue(m_content->sizeHint().height());
} else {
m_heightAnim->setEasingCurve(AnimationTokens::Curve::exit());
m_heightAnim->setStartValue(m_content->height());
m_heightAnim->setEndValue(0);
}
m_heightAnim->start();
updateToggleText();
}
@@ -4,6 +4,7 @@
#include <QString>
#include <QWidget>
class QPropertyAnimation;
class QPushButton;
class QVBoxLayout;
@@ -22,6 +23,7 @@ private:
QPushButton* m_toggle;
QWidget* m_content;
QVBoxLayout* m_contentLayout;
QPropertyAnimation* m_heightAnim = nullptr;
bool m_expanded = false;
QString m_title;
};
-306
View File
@@ -1,306 +0,0 @@
#include "collectioncard.h"
#include "collectiongrid.h"
#include "../managers/thememanager.h"
#include <QApplication>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
CollectionCard::CollectionCard(const QString& itemId,
const QString& name,
int childCount,
const QString& type,
QWidget* parent)
: QFrame(parent)
, m_itemId(itemId)
, m_name(name)
, m_childCount(childCount)
, m_type(type)
{
setFixedSize(CollectionGrid::CardWidth, CollectionGrid::CardHeight);
setMouseTracking(true);
setCursor(Qt::OpenHandCursor);
auto makeBtn = [this](const QString& objName) {
auto* btn = new QPushButton(this);
btn->setObjectName(objName);
btn->setFixedSize(24, 24);
btn->setCursor(Qt::PointingHandCursor);
return btn;
};
m_btnRename = makeBtn("CollCardRenameBtn");
m_btnDelete = makeBtn("CollCardDeleteBtn");
m_btnRename->setToolTip(tr("Rename"));
m_btnDelete->setToolTip(tr("Delete"));
updateButtonLayout();
connect(m_btnRename, &QPushButton::clicked, this,
[this]() { emit renameClicked(m_itemId, m_name); });
connect(m_btnDelete, &QPushButton::clicked, this,
[this]() { emit deleteClicked(m_itemId, m_name); });
}
void CollectionCard::updateButtonLayout()
{
const int buttonSize = 24;
const int buttonSpacing = 4;
const int totalWidth = 2 * buttonSize + buttonSpacing;
const int startX = width() - totalWidth - 8;
const int buttonY = height() - buttonSize - 6;
m_btnRename->move(startX, buttonY);
m_btnDelete->move(startX + buttonSize + buttonSpacing, buttonY);
}
void CollectionCard::setImage(const QPixmap& pixmap)
{
m_image = pixmap;
update();
}
void CollectionCard::setDisplayData(const QString& name, int childCount,
const QString& type)
{
if (m_name == name && m_childCount == childCount && m_type == type) {
return;
}
m_name = name;
m_childCount = childCount;
m_type = type;
update();
}
void CollectionCard::setSelected(bool selected)
{
if (m_selected == selected) {
return;
}
m_selected = selected;
update();
}
void CollectionCard::setDragging(bool dragging)
{
if (m_dragging == dragging) {
return;
}
m_dragging = dragging;
update();
}
void CollectionCard::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event)
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
bool isDark = ThemeManager::instance()->isDarkMode();
int w = width(), h = height();
QColor cardBg = isDark ? QColor(51, 65, 85, 140) : QColor(255, 255, 255, 245);
QColor border = isDark ? QColor(71, 85, 105, 100) : QColor(226, 232, 240);
QColor nameFg = isDark ? QColor("#F1F5F9") : QColor("#0F172A");
QColor typeFg = isDark ? QColor("#94A3B8") : QColor("#64748B");
const QColor accent("#3B82F6");
if (m_selected) {
cardBg = isDark ? QColor(30, 41, 59, 220) : QColor(239, 246, 255, 245);
border = isDark ? QColor(96, 165, 250, 220) : QColor(59, 130, 246, 220);
}
if (m_dragging) {
cardBg = isDark ? QColor(15, 23, 42, 235) : QColor(248, 250, 252, 252);
border = isDark ? QColor(59, 130, 246, 235) : QColor(37, 99, 235, 230);
}
p.setPen(QPen(border, m_selected ? 2 : 1));
p.setBrush(cardBg);
p.drawRoundedRect(QRectF(0.5, 0.5, w - 1, h - 1), 12, 12);
const QRectF badgeRect(w - 28, 10, 18, 18);
p.setPen(QPen(m_selected ? accent
: (isDark ? QColor(100, 116, 139, 160)
: QColor(148, 163, 184, 180)),
1.5));
p.setBrush(m_selected ? accent
: (isDark ? QColor(15, 23, 42, 180)
: QColor(255, 255, 255, 220)));
p.drawEllipse(badgeRect);
if (m_selected) {
QPainterPath checkPath;
checkPath.moveTo(badgeRect.left() + 4.5, badgeRect.center().y());
checkPath.lineTo(badgeRect.left() + 7.5, badgeRect.bottom() - 4.5);
checkPath.lineTo(badgeRect.right() - 4.0, badgeRect.top() + 4.5);
p.setPen(QPen(Qt::white, 2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
p.drawPath(checkPath);
}
const int imagePadding = 4;
const int defaultImgAreaH = 120;
const bool useSquareThumb = (m_type == "Playlist");
const int imgSide = qMax(0, w - imagePadding * 2);
QRectF imgRect(imagePadding, imagePadding,
w - imagePadding * 2,
useSquareThumb ? imgSide : defaultImgAreaH);
QLinearGradient grad(imgRect.topLeft(), imgRect.bottomRight());
grad.setColorAt(0, isDark ? QColor(30, 41, 59) : QColor(241, 245, 249));
grad.setColorAt(1, isDark ? QColor(51, 65, 85) : QColor(226, 232, 240));
QPainterPath clipPath;
clipPath.addRoundedRect(imgRect, 9, 9);
p.save();
p.setClipPath(clipPath);
p.fillRect(imgRect, grad);
if (!m_image.isNull()) {
const Qt::AspectRatioMode aspectMode =
useSquareThumb ? Qt::KeepAspectRatio : Qt::KeepAspectRatioByExpanding;
QPixmap scaled = m_image.scaled(imgRect.size().toSize(), aspectMode,
Qt::SmoothTransformation);
const qreal scaledDpr = scaled.devicePixelRatio();
QSize scaledSize(qRound(scaled.width() / scaledDpr),
qRound(scaled.height() / scaledDpr));
QRect drawRect(0, 0, scaledSize.width(), scaledSize.height());
drawRect.moveLeft(qRound(imgRect.x() + (imgRect.width() - drawRect.width()) / 2.0));
drawRect.moveTop(qRound(imgRect.y() + (imgRect.height() - drawRect.height()) / 2.0));
p.drawPixmap(drawRect.topLeft(), scaled);
} else {
QFont iconFont = font();
iconFont.setPixelSize(32);
p.setFont(iconFont);
p.setPen(isDark ? QColor(148, 163, 184, 80) : QColor(100, 116, 139, 80));
QString placeholder = (m_type == "Playlist") ? QString::fromUtf8("🎵") : QString::fromUtf8("📂");
p.drawText(imgRect, Qt::AlignCenter, placeholder);
}
p.restore();
int textTop = qRound(imgRect.bottom()) + 8;
int textLeft = 10;
int textWidth = w - 20;
QFont nameFont = font();
nameFont.setPixelSize(13);
nameFont.setWeight(QFont::DemiBold);
nameFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"});
QFontMetrics nameFm(nameFont);
QString elidedName = nameFm.elidedText(m_name, Qt::ElideRight, textWidth);
p.setFont(nameFont);
p.setPen(nameFg);
p.drawText(QRect(textLeft, textTop, textWidth, 18), Qt::AlignLeft | Qt::AlignVCenter, elidedName);
QFont typeFont = font();
typeFont.setPixelSize(11);
typeFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"});
QString typeStr;
if (m_type == "Playlist") {
typeStr = tr("Playlist");
} else {
typeStr = tr("Collection");
}
typeStr += QString(" · %1 %2").arg(m_childCount)
.arg(m_childCount == 1 ? tr("item") : tr("items"));
p.setFont(typeFont);
p.setPen(typeFg);
p.drawText(QRect(textLeft, textTop + 20, textWidth, 16), Qt::AlignLeft | Qt::AlignVCenter, typeStr);
}
void CollectionCard::mousePressEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton) {
if (!qobject_cast<QPushButton*>(childAt(event->position().toPoint()))) {
m_pressCandidate = true;
m_dragStartPos = event->globalPosition().toPoint();
setCursor(Qt::OpenHandCursor);
event->accept();
return;
}
}
QFrame::mousePressEvent(event);
}
void CollectionCard::mouseMoveEvent(QMouseEvent* event)
{
if ((event->buttons() & Qt::LeftButton) && m_pressCandidate) {
const QPoint globalPos = event->globalPosition().toPoint();
if (!m_dragging) {
const int distance = (globalPos - m_dragStartPos).manhattanLength();
if (distance >= QApplication::startDragDistance()) {
setDragging(true);
setCursor(Qt::ClosedHandCursor);
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragStart(this, globalPos);
}
}
}
if (m_dragging) {
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragMove(this, globalPos);
}
event->accept();
return;
}
}
QFrame::mouseMoveEvent(event);
}
void CollectionCard::mouseReleaseEvent(QMouseEvent* event)
{
if (event->button() == Qt::LeftButton && m_pressCandidate) {
const bool wasDragging = m_dragging;
m_pressCandidate = false;
if (wasDragging) {
setDragging(false);
setCursor(Qt::OpenHandCursor);
if (auto* grid = qobject_cast<CollectionGrid*>(parentWidget())) {
grid->onCardDragEnd(this);
}
event->accept();
return;
}
toggleSelected();
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}
void CollectionCard::resizeEvent(QResizeEvent* event)
{
QFrame::resizeEvent(event);
updateButtonLayout();
}
void CollectionCard::toggleSelected()
{
m_selected = !m_selected;
update();
emit selectionChanged(m_itemId, m_selected);
}
-66
View File
@@ -1,66 +0,0 @@
#ifndef COLLECTIONCARD_H
#define COLLECTIONCARD_H
#include <QFrame>
#include <QPoint>
#include <QPixmap>
#include <QPushButton>
class CollectionGrid;
class QMouseEvent;
class QResizeEvent;
class CollectionCard : public QFrame {
Q_OBJECT
public:
explicit CollectionCard(const QString& itemId,
const QString& name,
int childCount,
const QString& type,
QWidget* parent = nullptr);
QString itemId() const { return m_itemId; }
QString itemName() const { return m_name; }
bool hasImage() const { return !m_image.isNull(); }
bool isSelected() const { return m_selected; }
bool isDragging() const { return m_dragging; }
void setImage(const QPixmap& pixmap);
void setDisplayData(const QString& name, int childCount, const QString& type);
void setSelected(bool selected);
void setDragging(bool dragging);
signals:
void renameClicked(const QString& id, const QString& name);
void deleteClicked(const QString& id, const QString& name);
void selectionChanged(const QString& id, bool selected);
protected:
void paintEvent(QPaintEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
void updateButtonLayout();
void toggleSelected();
QString m_itemId;
QString m_name;
int m_childCount;
QString m_type;
QPixmap m_image;
bool m_selected = false;
bool m_dragging = false;
bool m_pressCandidate = false;
QPoint m_dragStartPos;
QPushButton* m_btnRename;
QPushButton* m_btnDelete;
};
#endif
-335
View File
@@ -1,335 +0,0 @@
#include "collectiongrid.h"
#include "collectioncard.h"
#include <QPropertyAnimation>
#include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QSet>
#include <QTimer>
namespace {
void stopPosAnimations(CollectionCard* card)
{
const auto children = card->children();
for (auto* child : children) {
if (auto* anim = qobject_cast<QPropertyAnimation*>(child)) {
if (anim->propertyName() == "pos") {
anim->stop();
anim->deleteLater();
}
}
}
}
}
CollectionGrid::CollectionGrid(QWidget* parent)
: QWidget(parent)
{
setMinimumHeight(100);
m_autoScrollTimer = new QTimer(this);
m_autoScrollTimer->setInterval(16);
connect(m_autoScrollTimer, &QTimer::timeout, this, [this]() {
if (!m_dragCard) {
m_autoScrollTimer->stop();
return;
}
auto* scrollArea = parentScrollArea();
if (!scrollArea) {
return;
}
auto* scrollBar = scrollArea->verticalScrollBar();
const int delta = autoScrollDelta(m_lastDragGlobalPos);
if (delta == 0) {
return;
}
const int oldValue = scrollBar->value();
scrollBar->setValue(
qBound(scrollBar->minimum(), oldValue + delta, scrollBar->maximum()));
if (scrollBar->value() != oldValue) {
updateDragPosition(m_dragCard, m_lastDragGlobalPos);
}
});
}
int CollectionGrid::cardHeightForWidth(int width) const
{
const int imageSide = qMax(0, width - 8);
const int footerHeight = 84;
return qMax(CardHeight, imageSide + footerHeight);
}
void CollectionGrid::setCards(const QList<CollectionCard*>& cards)
{
m_dragCard = nullptr;
m_dragOriginalIndex = -1;
m_dragCurrentIndex = -1;
if (m_autoScrollTimer) {
m_autoScrollTimer->stop();
}
QSet<CollectionCard*> retainedCards;
retainedCards.reserve(cards.size());
for (auto* card : cards) {
retainedCards.insert(card);
}
for (auto* card : m_cards) {
if (!retainedCards.contains(card)) {
card->hide();
card->deleteLater();
}
}
m_cards = cards;
for (auto* card : m_cards) {
card->setParent(this);
card->show();
}
relayout();
}
int CollectionGrid::columnsForWidth(int w) const
{
if (w <= 0) return 1;
const int cw = qMax(0, w - LeftPadding - RightPadding);
int cols = (cw + CardSpacing) / (CardWidth + CardSpacing);
return qMax(1, cols);
}
int CollectionGrid::contentWidth() const
{
return qMax(0, width() - LeftPadding - RightPadding);
}
int CollectionGrid::cardWidthForColumn(int column, int columns) const
{
if (columns <= 0) return CardWidth;
const int availableWidth =
qMax(0, contentWidth() - (columns - 1) * CardSpacing);
const int baseWidth = qMax(1, availableWidth / columns);
const int extraPixels = qMax(0, availableWidth % columns);
return baseWidth + (column < extraPixels ? 1 : 0);
}
int CollectionGrid::columnX(int column, int columns) const
{
int x = LeftPadding;
for (int i = 0; i < column; ++i) {
x += cardWidthForColumn(i, columns) + CardSpacing;
}
return x;
}
int CollectionGrid::columnForX(int x, int columns) const
{
if (columns <= 0) return 0;
for (int col = 0; col < columns; ++col) {
const int rightEdge = columnX(col, columns) + cardWidthForColumn(col, columns);
if (col == columns - 1 || x < rightEdge + CardSpacing / 2) {
return col;
}
}
return columns - 1;
}
QPoint CollectionGrid::posForIndex(int index, int columns) const
{
if (columns <= 0) columns = 1;
const int col = index % columns;
const int row = index / columns;
return QPoint(columnX(col, columns), row * (m_rowHeight + CardSpacing));
}
int CollectionGrid::indexForPos(const QPoint& pos, int columns) const
{
if (m_cards.isEmpty()) return 0;
const int col = columnForX(pos.x(), columns);
const int row = qMax(0, pos.y() / (m_rowHeight + CardSpacing));
const int idx = row * columns + col;
return qBound(0, idx, m_cards.size() - 1);
}
QScrollArea* CollectionGrid::parentScrollArea() const
{
QWidget* current = parentWidget();
while (current) {
if (auto* scrollArea = qobject_cast<QScrollArea*>(current)) {
return scrollArea;
}
current = current->parentWidget();
}
return nullptr;
}
int CollectionGrid::autoScrollDelta(const QPoint& globalPos) const
{
auto* scrollArea = parentScrollArea();
if (!scrollArea) return 0;
auto* scrollBar = scrollArea->verticalScrollBar();
if (!scrollBar || scrollBar->maximum() <= scrollBar->minimum()) return 0;
const QRect viewportRect(scrollArea->viewport()->mapToGlobal(QPoint(0, 0)),
scrollArea->viewport()->size());
static constexpr int EdgeThreshold = 56;
static constexpr int MaxScrollStep = 22;
const int topHotZone = viewportRect.top() + EdgeThreshold;
if (globalPos.y() < topHotZone) {
const int distance = topHotZone - globalPos.y();
return -qBound(4, distance / 2, MaxScrollStep);
}
const int bottomHotZone = viewportRect.bottom() - EdgeThreshold;
if (globalPos.y() > bottomHotZone) {
const int distance = globalPos.y() - bottomHotZone;
return qBound(4, distance / 2, MaxScrollStep);
}
return 0;
}
void CollectionGrid::updateDragPosition(CollectionCard* card, const QPoint& globalPos)
{
QPoint localPos =
mapFromGlobal(globalPos) - QPoint(card->width() / 2, card->height() / 2);
card->move(localPos);
const QPoint center = mapFromGlobal(globalPos);
int targetIndex = indexForPos(center, m_columns);
if (targetIndex != m_dragCurrentIndex &&
targetIndex >= 0 && targetIndex < m_cards.size()) {
m_cards.removeOne(card);
targetIndex = qBound(0, targetIndex, m_cards.size());
m_cards.insert(targetIndex, card);
m_dragCurrentIndex = targetIndex;
relayout(true);
}
}
void CollectionGrid::relayout(bool animate)
{
m_columns = columnsForWidth(width());
const int availableWidth =
qMax(0, contentWidth() - (m_columns - 1) * CardSpacing);
const int baseWidth = qMax(1, availableWidth / m_columns);
const int extraPixels = qMax(0, availableWidth % m_columns);
const int maxCardWidth = baseWidth + (extraPixels > 0 ? 1 : 0);
m_rowHeight = cardHeightForWidth(maxCardWidth);
int rows = m_cards.isEmpty() ? 0 : (m_cards.size() + m_columns - 1) / m_columns;
int totalH = rows * (m_rowHeight + CardSpacing) - (rows > 0 ? CardSpacing : 0);
setMinimumHeight(qMax(totalH, 100));
for (int i = 0; i < m_cards.size(); ++i) {
auto* card = m_cards[i];
const int col = i % m_columns;
const int cardW = baseWidth + (col < extraPixels ? 1 : 0);
card->setFixedSize(cardW, m_rowHeight);
if (card == m_dragCard) {
continue;
}
QPoint target = posForIndex(i, m_columns);
if (animate && card->pos() != target) {
stopPosAnimations(card);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(220);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
stopPosAnimations(card);
card->move(target);
}
}
}
void CollectionGrid::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
relayout(false);
}
void CollectionGrid::onCardDragStart(CollectionCard* card, const QPoint& globalPos)
{
if (!card) {
return;
}
m_dragCard = card;
m_dragOriginalIndex = m_cards.indexOf(card);
m_dragCurrentIndex = m_dragOriginalIndex;
m_lastDragGlobalPos = globalPos;
for (auto* currentCard : m_cards) {
stopPosAnimations(currentCard);
}
card->setDragging(true);
card->raise();
m_autoScrollTimer->start();
}
void CollectionGrid::onCardDragMove(CollectionCard* card, const QPoint& globalPos)
{
if (!card || card != m_dragCard) {
return;
}
m_lastDragGlobalPos = globalPos;
updateDragPosition(card, globalPos);
}
void CollectionGrid::onCardDragEnd(CollectionCard* card)
{
if (!card) {
return;
}
card->setDragging(false);
m_autoScrollTimer->stop();
CollectionCard* dragCard = m_dragCard;
const int finalIndex = qMax(0, m_dragCurrentIndex);
const bool changed = (m_dragOriginalIndex != m_dragCurrentIndex);
m_dragCard = nullptr;
m_dragOriginalIndex = -1;
m_dragCurrentIndex = -1;
stopPosAnimations(card);
const QPoint target = posForIndex(finalIndex, m_columns);
auto* anim = new QPropertyAnimation(card, "pos", card);
anim->setDuration(180);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setEndValue(target);
anim->start(QAbstractAnimation::DeleteWhenStopped);
if (changed && dragCard) {
QStringList newOrderIds;
for (auto* currentCard : m_cards) {
newOrderIds.append(currentCard->itemId());
}
emit orderChanged(newOrderIds);
}
}
-67
View File
@@ -1,67 +0,0 @@
#ifndef COLLECTIONGRID_H
#define COLLECTIONGRID_H
#include <QList>
#include <QPoint>
#include <QStringList>
#include <QWidget>
class CollectionCard;
class QResizeEvent;
class QScrollArea;
class QTimer;
class CollectionGrid : public QWidget {
Q_OBJECT
public:
explicit CollectionGrid(QWidget* parent = nullptr);
void setCards(const QList<CollectionCard*>& cards);
QList<CollectionCard*> cards() const { return m_cards; }
void relayout(bool animate = false);
static constexpr int CardWidth = 170;
static constexpr int CardHeight = 246;
static constexpr int CardSpacing = 12;
static constexpr int LeftPadding = 4;
static constexpr int RightPadding = 20;
signals:
void orderChanged(const QStringList& newOrderIds);
public slots:
void onCardDragStart(CollectionCard* card, const QPoint& globalPos);
void onCardDragMove(CollectionCard* card, const QPoint& globalPos);
void onCardDragEnd(CollectionCard* card);
protected:
void resizeEvent(QResizeEvent* event) override;
private:
int cardHeightForWidth(int width) const;
int columnsForWidth(int width) const;
int contentWidth() const;
int cardWidthForColumn(int column, int columns) const;
int columnX(int column, int columns) const;
int columnForX(int x, int columns) const;
QPoint posForIndex(int index, int columns) const;
int indexForPos(const QPoint& pos, int columns) const;
QScrollArea* parentScrollArea() const;
int autoScrollDelta(const QPoint& globalPos) const;
void updateDragPosition(CollectionCard* card, const QPoint& globalPos);
QList<CollectionCard*> m_cards;
CollectionCard* m_dragCard = nullptr;
int m_dragOriginalIndex = -1;
int m_dragCurrentIndex = -1;
int m_columns = 3;
int m_rowHeight = CardHeight;
QPoint m_lastDragGlobalPos;
QTimer* m_autoScrollTimer = nullptr;
};
#endif
@@ -1,887 +0,0 @@
#include "danmakuserverdialog.h"
#include "danmakuserverlistitemwidget.h"
#include "modernmessagebox.h"
#include "services/danmaku/danmakusettings.h"
#include <QAbstractItemView>
#include <QEvent>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QListWidgetItem>
#include <QPushButton>
#include <QSignalBlocker>
#include <QShowEvent>
#include <QUrl>
#include <QUuid>
#include <QVBoxLayout>
#include <utility>
namespace {
QString fallbackServerName(const DanmakuServerDefinition &server)
{
return server.name.trimmed().isEmpty() ? server.baseUrl.trimmed()
: server.name.trimmed();
}
}
DanmakuServerDialog::DanmakuServerDialog(QWidget *parent)
: ModernDialogBase(parent)
{
setTitle(tr("Danmaku Servers"));
setMinimumSize(920, 620);
auto *hintLabel = new QLabel(
tr("Manage multiple danmaku servers here. Click a server card to edit "
"it and choose the active server used for matching and loading "
"danmaku."),
this);
hintLabel->setObjectName("dialog-text");
hintLabel->setWordWrap(true);
contentLayout()->addWidget(hintLabel);
contentLayout()->addSpacing(8);
auto *bodyLayout = new QHBoxLayout();
bodyLayout->setSpacing(18);
auto *listPanel = new QFrame(this);
listPanel->setObjectName("DanmakuServerListPanel");
listPanel->setMinimumWidth(360);
auto *listLayout = new QVBoxLayout(listPanel);
listLayout->setContentsMargins(18, 18, 18, 18);
listLayout->setSpacing(12);
auto *listTitle = new QLabel(tr("Servers"), listPanel);
listTitle->setObjectName("SettingsCardTitle");
listLayout->addWidget(listTitle);
auto *listDesc = new QLabel(
tr("Each item shows the danmaku server name, address, app "
"credentials, and description. The built-in official "
"DandanPlay server supports anime only."),
listPanel);
listDesc->setObjectName("SettingsCardDesc");
listDesc->setWordWrap(true);
listLayout->addWidget(listDesc);
auto *actionRow = new QHBoxLayout();
actionRow->setSpacing(10);
m_addButton = new QPushButton(tr("Add"), listPanel);
m_addButton->setObjectName("SettingsCardButton");
m_addButton->setCursor(Qt::PointingHandCursor);
m_addButton->setFixedHeight(32);
actionRow->addWidget(m_addButton);
actionRow->addStretch();
listLayout->addLayout(actionRow);
m_serverList = new QListWidget(listPanel);
m_serverList->setObjectName("DanmakuServerList");
m_serverList->setFrameShape(QFrame::NoFrame);
m_serverList->setCursor(Qt::PointingHandCursor);
m_serverList->setSelectionMode(QAbstractItemView::SingleSelection);
m_serverList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
m_serverList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_serverList->setSpacing(10);
m_serverList->viewport()->installEventFilter(this);
listLayout->addWidget(m_serverList, 1);
bodyLayout->addWidget(listPanel, 1);
auto *editorPanel = new QFrame(this);
editorPanel->setObjectName("DanmakuServerEditorPanel");
editorPanel->setMinimumWidth(420);
auto *editorLayout = new QVBoxLayout(editorPanel);
editorLayout->setContentsMargins(18, 18, 18, 18);
editorLayout->setSpacing(14);
auto *editorTitle = new QLabel(tr("Edit Selected Server"), editorPanel);
editorTitle->setObjectName("DanmakuServerEditorTitle");
editorLayout->addWidget(editorTitle);
auto *editorDesc = new QLabel(
tr("Update the server name, address, description, and app credentials for the "
"selected danmaku server. Official DandanPlay endpoints require "
"App ID and App Secret and support anime only."),
editorPanel);
editorDesc->setObjectName("DanmakuServerEditorDesc");
editorDesc->setWordWrap(true);
editorLayout->addWidget(editorDesc);
auto *formLayout = new QFormLayout();
formLayout->setContentsMargins(0, 4, 0, 0);
formLayout->setHorizontalSpacing(14);
formLayout->setVerticalSpacing(14);
formLayout->setLabelAlignment(Qt::AlignLeft | Qt::AlignVCenter);
auto *nameLabel = new QLabel(tr("Name"), editorPanel);
nameLabel->setObjectName("LibraryMetadataFieldLabel");
m_nameEdit = new QLineEdit(editorPanel);
m_nameEdit->setObjectName("ManageLibInput");
m_nameEdit->setClearButtonEnabled(true);
m_nameEdit->setPlaceholderText(tr("e.g., Official DandanPlay"));
formLayout->addRow(nameLabel, m_nameEdit);
auto *urlLabel = new QLabel(tr("Server URL"), editorPanel);
urlLabel->setObjectName("LibraryMetadataFieldLabel");
m_baseUrlEdit = new QLineEdit(editorPanel);
m_baseUrlEdit->setObjectName("ManageLibInput");
m_baseUrlEdit->setClearButtonEnabled(true);
m_baseUrlEdit->setPlaceholderText(
QStringLiteral("https://api.dandanplay.net"));
formLayout->addRow(urlLabel, m_baseUrlEdit);
m_descriptionLabel = new QLabel(tr("Description"), editorPanel);
m_descriptionLabel->setObjectName("LibraryMetadataFieldLabel");
m_descriptionEdit = new QLineEdit(editorPanel);
m_descriptionEdit->setObjectName("ManageLibInput");
m_descriptionEdit->setClearButtonEnabled(true);
m_descriptionEdit->setPlaceholderText(tr("e.g., Supports anime only"));
formLayout->addRow(m_descriptionLabel, m_descriptionEdit);
m_appIdLabel = new QLabel(tr("App ID"), editorPanel);
m_appIdLabel->setObjectName("LibraryMetadataFieldLabel");
m_appIdEdit = new QLineEdit(editorPanel);
m_appIdEdit->setObjectName("ManageLibInput");
m_appIdEdit->setClearButtonEnabled(true);
m_appIdEdit->setPlaceholderText(tr("Required for official DandanPlay"));
formLayout->addRow(m_appIdLabel, m_appIdEdit);
m_appSecretLabel = new QLabel(tr("App Secret"), editorPanel);
m_appSecretLabel->setObjectName("LibraryMetadataFieldLabel");
m_appSecretEdit = new QLineEdit(editorPanel);
m_appSecretEdit->setObjectName("ManageLibInput");
m_appSecretEdit->setClearButtonEnabled(true);
m_appSecretEdit->setEchoMode(QLineEdit::Password);
m_appSecretEdit->setPlaceholderText(
tr("Required for official DandanPlay"));
formLayout->addRow(m_appSecretLabel, m_appSecretEdit);
editorLayout->addLayout(formLayout);
editorLayout->addStretch();
bodyLayout->addWidget(editorPanel, 1);
contentLayout()->addLayout(bodyLayout);
contentLayout()->addSpacing(24);
auto *buttonLayout = new QHBoxLayout();
buttonLayout->setSpacing(12);
buttonLayout->addStretch();
auto *cancelButton = new QPushButton(tr("Cancel"), this);
cancelButton->setObjectName("dialog-btn-cancel");
cancelButton->setCursor(Qt::PointingHandCursor);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
buttonLayout->addWidget(cancelButton);
auto *saveButton = new QPushButton(tr("Save"), this);
saveButton->setObjectName("dialog-btn-primary");
saveButton->setCursor(Qt::PointingHandCursor);
connect(saveButton, &QPushButton::clicked, this,
[this]() { accept(); });
buttonLayout->addWidget(saveButton);
contentLayout()->addLayout(buttonLayout);
connect(m_serverList, &QListWidget::currentRowChanged, this,
[this](int row) {
if (m_loading || row < 0 || row >= m_servers.size()) {
return;
}
m_editingServerId = m_servers.at(row).id;
if (m_servers.at(row).enabled) {
m_selectedServerId = m_servers.at(row).id;
}
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
});
connect(m_nameEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerName(text); });
connect(m_baseUrlEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerUrl(text); });
connect(m_descriptionEdit, &QLineEdit::textChanged, this,
[this](const QString &text) {
updateCurrentServerDescription(text);
});
connect(m_appIdEdit, &QLineEdit::textChanged, this,
[this](const QString &text) { updateCurrentServerAppId(text); });
connect(m_appSecretEdit, &QLineEdit::textChanged, this,
[this](const QString &text) {
updateCurrentServerAppSecret(text);
});
connect(m_addButton, &QPushButton::clicked, this, [this]() {
DanmakuServerDefinition server =
DanmakuSettings::builtInOfficialDandanplayServer();
server.id = createServerId();
server.builtIn = false;
server.contentScope.clear();
server.description.clear();
server.appId.clear();
server.appSecret.clear();
server.name = tr("Danmaku Server %1").arg(m_servers.size() + 1);
server.enabled = true;
m_servers.append(server);
refreshServerList();
selectServer(m_servers.size() - 1);
m_nameEdit->setFocus();
m_nameEdit->selectAll();
});
setServers({DanmakuSettings::builtInOfficialDandanplayServer()},
QStringLiteral("default"));
}
void DanmakuServerDialog::setServers(QList<DanmakuServerDefinition> servers,
QString selectedServerId)
{
if (servers.isEmpty()) {
servers.append(DanmakuSettings::builtInOfficialDandanplayServer());
}
for (DanmakuServerDefinition &server : servers) {
server.id = server.id.trimmed();
server.name = server.name.trimmed();
server.provider = server.provider.trimmed();
server.baseUrl = normalizeBaseUrl(server.baseUrl);
server.description = server.description.trimmed();
server.appId = server.appId.trimmed();
server.appSecret = server.appSecret.trimmed();
server.contentScope = server.contentScope.trimmed().toLower();
if (server.id.isEmpty()) {
server.id = createServerId();
}
if (server.builtIn) {
const DanmakuServerDefinition builtIn =
DanmakuSettings::builtInOfficialDandanplayServer();
server.id = builtIn.id;
server.provider = builtIn.provider;
server.baseUrl = builtIn.baseUrl;
server.appId = builtIn.appId;
server.appSecret = builtIn.appSecret;
server.description.clear();
server.contentScope = builtIn.contentScope;
if (server.name.isEmpty()) {
server.name = builtIn.name;
}
}
if (server.provider.isEmpty()) {
server.provider = QStringLiteral("dandanplay");
}
if (server.name.isEmpty()) {
server.name = fallbackServerName(server);
}
}
m_servers = std::move(servers);
m_selectedServerId = selectedServerId.trimmed();
if (m_selectedServerId.trimmed().isEmpty() && !m_servers.isEmpty()) {
const int activeIndex = preferredServerIndex(0);
if (activeIndex >= 0) {
m_selectedServerId = m_servers.at(activeIndex).id;
}
}
m_editingServerId = m_selectedServerId;
refreshServerList();
selectServer(editingServerIndex());
}
QList<DanmakuServerDefinition> DanmakuServerDialog::servers() const
{
return m_servers;
}
QString DanmakuServerDialog::selectedServerId() const
{
if (enabledServerCount() <= 0) {
return QString();
}
const int index = preferredServerIndex(currentServerIndex());
if (index >= 0 && index < m_servers.size()) {
return m_servers.at(index).id;
}
return m_selectedServerId.trimmed().isEmpty() && !m_servers.isEmpty()
? m_servers.first().id
: m_selectedServerId;
}
void DanmakuServerDialog::accept()
{
if (m_servers.isEmpty()) {
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("At least one danmaku server is required."), tr("OK"));
return;
}
for (const DanmakuServerDefinition &server : std::as_const(m_servers)) {
if (server.name.trimmed().isEmpty()) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_nameEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Every danmaku server must have a name."), tr("OK"));
return;
}
if (server.baseUrl.trimmed().isEmpty()) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_baseUrlEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Every danmaku server must have a server URL."), tr("OK"));
return;
}
const QUrl url = QUrl::fromUserInput(server.baseUrl.trimmed());
const QString scheme = url.scheme().trimmed().toLower();
if (!url.isValid() || url.host().trimmed().isEmpty() ||
(scheme != QStringLiteral("http") &&
scheme != QStringLiteral("https"))) {
m_editingServerId = server.id;
if (server.enabled) {
m_selectedServerId = server.id;
}
refreshServerList();
selectServer(editingServerIndex());
m_baseUrlEdit->setFocus();
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("Server URL must be a valid http or https address."),
tr("OK"));
return;
}
}
const QString activeServerId = selectedServerId();
if (!activeServerId.trimmed().isEmpty()) {
m_selectedServerId = activeServerId;
}
QDialog::accept();
}
bool DanmakuServerDialog::eventFilter(QObject *watched, QEvent *event)
{
if (m_serverList && watched == m_serverList->viewport() &&
event && event->type() == QEvent::Resize) {
syncAllServerListItemSizes();
}
return ModernDialogBase::eventFilter(watched, event);
}
void DanmakuServerDialog::showEvent(QShowEvent *event)
{
ModernDialogBase::showEvent(event);
syncAllServerListItemSizes();
}
int DanmakuServerDialog::currentServerIndex() const
{
for (int index = 0; index < m_servers.size(); ++index) {
if (m_servers.at(index).id == m_selectedServerId) {
return index;
}
}
return m_servers.isEmpty() ? -1 : 0;
}
int DanmakuServerDialog::editingServerIndex() const
{
for (int index = 0; index < m_servers.size(); ++index) {
if (m_servers.at(index).id == m_editingServerId) {
return index;
}
}
return m_servers.isEmpty() ? -1 : currentServerIndex();
}
int DanmakuServerDialog::enabledServerCount() const
{
int count = 0;
for (const DanmakuServerDefinition &server : m_servers) {
if (server.enabled) {
++count;
}
}
return count;
}
int DanmakuServerDialog::preferredServerIndex(int preferredIndex) const
{
if (m_servers.isEmpty()) {
return -1;
}
const int normalizedPreferred =
qBound(0, preferredIndex < 0 ? 0 : preferredIndex, m_servers.size() - 1);
if (enabledServerCount() > 0) {
for (int offset = 0; offset < m_servers.size(); ++offset) {
const int forwardIndex = normalizedPreferred + offset;
if (forwardIndex < m_servers.size() &&
m_servers.at(forwardIndex).enabled) {
return forwardIndex;
}
const int backwardIndex = normalizedPreferred - offset;
if (offset > 0 && backwardIndex >= 0 &&
m_servers.at(backwardIndex).enabled) {
return backwardIndex;
}
}
}
return normalizedPreferred;
}
void DanmakuServerDialog::removeServer(int index)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
if (m_servers.size() <= 1) {
ModernMessageBox::warning(
this, tr("Danmaku Servers"),
tr("At least one danmaku server is required."), tr("OK"));
return;
}
const QString removedServerId = m_servers.at(index).id;
const QString serverName = fallbackServerName(m_servers.at(index));
if (!ModernMessageBox::question(
this, tr("Remove Danmaku Server"),
tr("Remove \"%1\" from the danmaku server list?").arg(serverName),
tr("Remove"), tr("Cancel"), ModernMessageBox::Danger,
ModernMessageBox::Warning)) {
return;
}
const bool removingSelected = removedServerId == m_selectedServerId;
const bool removingEditing = removedServerId == m_editingServerId;
m_servers.removeAt(index);
if (m_servers.isEmpty()) {
refreshServerList();
return;
}
if (removingSelected) {
const int nextActiveIndex =
preferredServerIndex(qBound(0, index, m_servers.size() - 1));
if (nextActiveIndex >= 0 && nextActiveIndex < m_servers.size()) {
m_selectedServerId = m_servers.at(nextActiveIndex).id;
}
}
if (removingEditing) {
const int nextEditingIndex =
qBound(0, index, m_servers.size() - 1);
m_editingServerId = m_servers.at(nextEditingIndex).id;
}
refreshServerList();
}
void DanmakuServerDialog::selectServer(int index)
{
if (index < 0 || index >= m_servers.size()) {
updateEditorState();
updateActionState();
return;
}
m_loading = true;
{
const QSignalBlocker blocker(m_serverList);
m_serverList->setCurrentRow(index);
}
m_editingServerId = m_servers.at(index).id;
if (m_servers.at(index).enabled) {
m_selectedServerId = m_servers.at(index).id;
}
m_loading = false;
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::refreshServerList()
{
const int activeIndex = preferredServerIndex(currentServerIndex());
const int editorIndex = editingServerIndex();
m_loading = true;
m_serverList->blockSignals(true);
m_serverList->clear();
for (int index = 0; index < m_servers.size(); ++index) {
const DanmakuServerDefinition &server = m_servers.at(index);
auto *item = new QListWidgetItem();
auto *widget = new DanmakuServerListItemWidget(m_serverList);
widget->setServer(server);
widget->setRemovable(m_servers.size() > 1 && !server.builtIn);
item->setData(Qt::UserRole, server.id);
m_serverList->addItem(item);
m_serverList->setItemWidget(item, widget);
connect(widget, &DanmakuServerListItemWidget::clicked, this,
[this, item]() {
if (!item) {
return;
}
const int row = m_serverList->row(item);
if (row < 0 || row >= m_servers.size()) {
return;
}
selectServer(row);
});
connect(widget, &DanmakuServerListItemWidget::enabledChanged, this,
[this, item](bool enabled) {
if (!item) {
return;
}
const int row = m_serverList->row(item);
if (row < 0 || row >= m_servers.size()) {
return;
}
updateServerEnabled(row, enabled);
});
connect(widget, &DanmakuServerListItemWidget::removeRequested, this,
[this, item]() {
if (!item) {
return;
}
removeServer(m_serverList->row(item));
});
}
if (!m_servers.isEmpty()) {
const int row = qBound(
0, editorIndex < 0 ? activeIndex : editorIndex, m_servers.size() - 1);
m_serverList->setCurrentRow(row);
m_editingServerId = m_servers.at(row).id;
const int normalizedActiveIndex =
activeIndex >= 0 ? activeIndex : preferredServerIndex(row);
if (normalizedActiveIndex >= 0) {
m_selectedServerId = m_servers.at(normalizedActiveIndex).id;
}
}
m_serverList->blockSignals(false);
m_loading = false;
syncAllServerListItemSizes();
loadCurrentServer();
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::refreshServerListItem(int index)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
auto *item = m_serverList->item(index);
if (!item) {
return;
}
auto *widget =
qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(item));
if (!widget) {
return;
}
widget->setServer(m_servers.at(index));
widget->setRemovable(m_servers.size() > 1 &&
!m_servers.at(index).builtIn);
syncServerListItemSize(index);
updateSelectionState();
}
void DanmakuServerDialog::syncServerListItemSize(int index)
{
constexpr int kListItemRightInset = 8;
if (!m_serverList || index < 0 || index >= m_serverList->count() ||
index >= m_servers.size()) {
return;
}
auto *item = m_serverList->item(index);
if (!item) {
return;
}
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(item));
if (!widget || !m_serverList->viewport()) {
return;
}
const int viewportWidth = m_serverList->viewport()->width();
const int targetWidth = viewportWidth - kListItemRightInset;
if (targetWidth <= 0) {
return;
}
widget->updateGeometry();
int targetHeight = widget->hasHeightForWidth()
? widget->heightForWidth(targetWidth)
: widget->sizeHint().height();
if (targetHeight <= 0) {
widget->adjustSize();
targetHeight = widget->sizeHint().height();
}
targetHeight = qMax(targetHeight, widget->minimumSizeHint().height());
item->setSizeHint(QSize(targetWidth, targetHeight));
widget->resize(targetWidth, targetHeight);
}
void DanmakuServerDialog::syncAllServerListItemSizes()
{
for (int index = 0; index < m_serverList->count(); ++index) {
syncServerListItemSize(index);
}
}
void DanmakuServerDialog::loadCurrentServer()
{
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
m_loading = true;
const DanmakuServerDefinition &server = m_servers.at(index);
m_nameEdit->setText(server.name);
m_baseUrlEdit->setText(server.baseUrl);
m_descriptionEdit->setText(server.description);
m_appIdEdit->setText(server.appId);
m_appSecretEdit->setText(server.appSecret);
m_loading = false;
}
void DanmakuServerDialog::updateSelectionState()
{
const int currentRow = m_serverList->currentRow();
const QString activeServerId = selectedServerId();
for (int row = 0; row < m_serverList->count(); ++row) {
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(m_serverList->item(row)));
if (widget) {
widget->setSelected(row == currentRow);
widget->setCurrentServer(m_servers.at(row).id == activeServerId);
}
}
}
void DanmakuServerDialog::updateEditorState()
{
const int index = editingServerIndex();
const bool hasCurrentServer = index >= 0 && index < m_servers.size();
const bool isBuiltIn = hasCurrentServer && m_servers.at(index).builtIn;
m_nameEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_baseUrlEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_descriptionLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_descriptionEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_descriptionEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_appIdLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_appIdEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_appIdEdit->setEnabled(hasCurrentServer && !isBuiltIn);
m_appSecretLabel->setVisible(hasCurrentServer && !isBuiltIn);
m_appSecretEdit->setVisible(hasCurrentServer && !isBuiltIn);
m_appSecretEdit->setEnabled(hasCurrentServer && !isBuiltIn);
}
void DanmakuServerDialog::updateServerEnabled(int index, bool enabled)
{
if (index < 0 || index >= m_servers.size()) {
return;
}
const QString previousActiveServerId = selectedServerId();
m_servers[index].enabled = enabled;
if (enabled && m_servers.at(index).id == m_editingServerId) {
m_selectedServerId = m_servers.at(index).id;
} else if (!enabled && m_servers.at(index).id == m_selectedServerId) {
const int nextIndex = preferredServerIndex(index);
if (nextIndex >= 0 && nextIndex != index) {
m_selectedServerId = m_servers.at(nextIndex).id;
}
}
refreshServerListItem(index);
const QString activeServerId = selectedServerId();
if (activeServerId != previousActiveServerId) {
for (int row = 0; row < m_servers.size(); ++row) {
if (m_servers.at(row).id == previousActiveServerId ||
m_servers.at(row).id == activeServerId) {
refreshServerListItem(row);
}
}
}
updateSelectionState();
updateEditorState();
updateActionState();
}
void DanmakuServerDialog::updateCurrentServerName(const QString &name)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].name = name.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerUrl(const QString &baseUrl)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].baseUrl = baseUrl.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerDescription(
const QString &description)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].description = description.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerAppId(const QString &appId)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].appId = appId.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateCurrentServerAppSecret(
const QString &appSecret)
{
if (m_loading) {
return;
}
const int index = editingServerIndex();
if (index < 0 || index >= m_servers.size()) {
return;
}
if (m_servers.at(index).builtIn) {
return;
}
m_servers[index].appSecret = appSecret.trimmed();
refreshServerListItem(index);
}
void DanmakuServerDialog::updateActionState()
{
for (int row = 0; row < m_serverList->count(); ++row) {
auto *widget = qobject_cast<DanmakuServerListItemWidget *>(
m_serverList->itemWidget(m_serverList->item(row)));
if (widget) {
const bool removable =
row < m_servers.size() && m_servers.size() > 1 &&
!m_servers.at(row).builtIn;
widget->setRemovable(removable);
}
}
}
QString DanmakuServerDialog::createServerId()
{
return QUuid::createUuid().toString(QUuid::WithoutBraces);
}
QString DanmakuServerDialog::normalizeBaseUrl(QString baseUrl)
{
baseUrl = baseUrl.trimmed();
while (baseUrl.endsWith('/') &&
!baseUrl.endsWith(QStringLiteral("://"))) {
baseUrl.chop(1);
}
return baseUrl;
}
@@ -1,76 +0,0 @@
#ifndef DANMAKUSERVERDIALOG_H
#define DANMAKUSERVERDIALOG_H
#include "moderndialogbase.h"
#include "models/danmaku/danmakumodels.h"
#include <QList>
#include <QString>
class QListWidget;
class QLineEdit;
class QLabel;
class QPushButton;
class QEvent;
class QShowEvent;
class DanmakuServerDialog : public ModernDialogBase
{
Q_OBJECT
public:
explicit DanmakuServerDialog(QWidget *parent = nullptr);
void setServers(QList<DanmakuServerDefinition> servers,
QString selectedServerId = QString());
QList<DanmakuServerDefinition> servers() const;
QString selectedServerId() const;
protected:
void accept() override;
bool eventFilter(QObject *watched, QEvent *event) override;
void showEvent(QShowEvent *event) override;
private:
int currentServerIndex() const;
int editingServerIndex() const;
int enabledServerCount() const;
int preferredServerIndex(int preferredIndex) const;
void removeServer(int index);
void selectServer(int index);
void refreshServerList();
void refreshServerListItem(int index);
void syncServerListItemSize(int index);
void syncAllServerListItemSizes();
void loadCurrentServer();
void updateSelectionState();
void updateEditorState();
void updateServerEnabled(int index, bool enabled);
void updateCurrentServerName(const QString &name);
void updateCurrentServerUrl(const QString &baseUrl);
void updateCurrentServerDescription(const QString &description);
void updateCurrentServerAppId(const QString &appId);
void updateCurrentServerAppSecret(const QString &appSecret);
void updateActionState();
static QString createServerId();
static QString normalizeBaseUrl(QString baseUrl);
QList<DanmakuServerDefinition> m_servers;
QString m_selectedServerId;
QString m_editingServerId;
bool m_loading = false;
QListWidget *m_serverList = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_baseUrlEdit = nullptr;
QLabel *m_descriptionLabel = nullptr;
QLineEdit *m_descriptionEdit = nullptr;
QLabel *m_appIdLabel = nullptr;
QLineEdit *m_appIdEdit = nullptr;
QLabel *m_appSecretLabel = nullptr;
QLineEdit *m_appSecretEdit = nullptr;
QPushButton *m_addButton = nullptr;
};
#endif
@@ -1,230 +0,0 @@
#include "danmakuserverlistitemwidget.h"
#include "../managers/thememanager.h"
#include "modernswitch.h"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSizePolicy>
#include <QStyle>
#include <QStringList>
#include <QVBoxLayout>
namespace {
QColor activeRemoveIconColor()
{
return ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#FCA5A5"))
: QColor(QStringLiteral("#DC2626"));
}
QColor inactiveRemoveIconColor()
{
return ThemeManager::instance()->isDarkMode()
? QColor(QStringLiteral("#64748B"))
: QColor(QStringLiteral("#94A3B8"));
}
}
DanmakuServerListItemWidget::DanmakuServerListItemWidget(QWidget *parent)
: QFrame(parent)
{
constexpr int actionColumnWidth = 74;
setObjectName("DanmakuServerListRow");
setCursor(Qt::PointingHandCursor);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
auto *outerLayout = new QHBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 8, 0);
outerLayout->setSpacing(0);
m_cardFrame = new QFrame(this);
m_cardFrame->setObjectName("DanmakuServerCard");
m_cardFrame->setProperty("selected", false);
m_cardFrame->setProperty("currentServer", false);
m_cardFrame->setProperty("serverEnabled", true);
outerLayout->addWidget(m_cardFrame);
auto *layout = new QHBoxLayout(m_cardFrame);
layout->setContentsMargins(16, 14, 16, 14);
layout->setSpacing(12);
auto *textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(4);
layout->addLayout(textLayout, 1);
auto *actionContainer = new QWidget(m_cardFrame);
actionContainer->setFixedWidth(actionColumnWidth);
actionContainer->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
auto *actionLayout = new QHBoxLayout(actionContainer);
actionLayout->setContentsMargins(0, 0, 2, 0);
actionLayout->setSpacing(4);
actionLayout->addStretch(1);
layout->addWidget(actionContainer, 0, Qt::AlignVCenter);
m_removeButton = new QPushButton(actionContainer);
m_removeButton->setObjectName("DanmakuServerRemoveButton");
m_removeButton->setCursor(Qt::PointingHandCursor);
m_removeButton->setToolTip(tr("Remove Server"));
m_removeButton->setFixedSize(20, 20);
m_removeButton->setIconSize(QSize(10, 10));
m_removeButton->setFocusPolicy(Qt::NoFocus);
m_enabledSwitch = new ModernSwitch(actionContainer);
m_enabledSwitch->setFixedSize(m_enabledSwitch->sizeHint());
m_enabledSwitch->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
actionLayout->addWidget(m_removeButton, 0, Qt::AlignVCenter | Qt::AlignRight);
actionLayout->addWidget(m_enabledSwitch, 0, Qt::AlignVCenter | Qt::AlignRight);
m_nameLabel = new QLabel(m_cardFrame);
m_nameLabel->setObjectName("DanmakuServerCardTitle");
m_nameLabel->setWordWrap(true);
m_nameLabel->installEventFilter(this);
textLayout->addWidget(m_nameLabel);
m_baseUrlLabel = new QLabel(m_cardFrame);
m_baseUrlLabel->setObjectName("DanmakuServerCardLine");
m_baseUrlLabel->setWordWrap(true);
m_baseUrlLabel->installEventFilter(this);
textLayout->addWidget(m_baseUrlLabel);
m_credentialsLabel = new QLabel(m_cardFrame);
m_credentialsLabel->setObjectName("DanmakuServerCardMeta");
m_credentialsLabel->setWordWrap(true);
m_credentialsLabel->installEventFilter(this);
textLayout->addWidget(m_credentialsLabel);
m_cardFrame->installEventFilter(this);
refreshRemoveIcon();
connect(m_enabledSwitch, &ModernSwitch::toggled, this,
&DanmakuServerListItemWidget::enabledChanged);
connect(m_removeButton, &QPushButton::clicked, this,
&DanmakuServerListItemWidget::removeRequested);
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this,
[this](ThemeManager::Theme) { refreshRemoveIcon(); });
}
void DanmakuServerListItemWidget::setServer(
const DanmakuServerDefinition &server)
{
m_server = server;
const QString displayName = server.name.trimmed().isEmpty()
? server.baseUrl.trimmed()
: server.name.trimmed();
{
const QSignalBlocker blocker(m_enabledSwitch);
m_enabledSwitch->setChecked(server.enabled);
}
m_cardFrame->setProperty("serverEnabled", server.enabled);
m_nameLabel->setText(displayName);
m_baseUrlLabel->setText(server.baseUrl.trimmed());
refreshCredentialsLabel();
refreshSelectionState();
}
void DanmakuServerListItemWidget::setSelected(bool selected)
{
if (m_cardFrame->property("selected").toBool() == selected) {
return;
}
m_cardFrame->setProperty("selected", selected);
refreshSelectionState();
}
void DanmakuServerListItemWidget::setCurrentServer(bool currentServer)
{
if (m_cardFrame->property("currentServer").toBool() == currentServer) {
return;
}
m_cardFrame->setProperty("currentServer", currentServer);
refreshSelectionState();
}
void DanmakuServerListItemWidget::setRemovable(bool removable)
{
m_removable = removable;
m_removeButton->setVisible(removable);
m_removeButton->setEnabled(removable);
m_removeButton->setProperty("removable", removable);
m_removeButton->setToolTip(removable ? tr("Remove Server")
: QString());
m_removeButton->style()->unpolish(m_removeButton);
m_removeButton->style()->polish(m_removeButton);
refreshRemoveIcon();
updateGeometry();
}
bool DanmakuServerListItemWidget::eventFilter(QObject *watched, QEvent *event)
{
if ((watched == m_cardFrame || watched == m_nameLabel ||
watched == m_baseUrlLabel || watched == m_credentialsLabel) &&
event && event->type() == QEvent::MouseButtonPress) {
emit clicked();
}
return QFrame::eventFilter(watched, event);
}
QString DanmakuServerListItemWidget::maskedSecret(const QString &secret)
{
const QString trimmed = secret.trimmed();
if (trimmed.isEmpty()) {
return QString();
}
if (trimmed.size() <= 4) {
return QStringLiteral("****");
}
return QStringLiteral("%1****%2")
.arg(trimmed.left(2), trimmed.right(2));
}
void DanmakuServerListItemWidget::refreshCredentialsLabel()
{
QStringList parts;
if (m_server.builtIn &&
m_server.contentScope.trimmed().compare(QStringLiteral("anime"),
Qt::CaseInsensitive) == 0) {
parts.append(tr("Anime only"));
}
if (!m_server.builtIn && !m_server.description.trimmed().isEmpty()) {
parts.append(m_server.description.trimmed());
}
if (!m_server.builtIn && !m_server.appId.trimmed().isEmpty()) {
parts.append(tr("App ID: %1").arg(m_server.appId.trimmed()));
}
if (!m_server.builtIn && !m_server.appSecret.trimmed().isEmpty()) {
parts.append(tr("App Secret: %1")
.arg(maskedSecret(m_server.appSecret)));
}
const QString text = parts.join(QStringLiteral(" "));
m_credentialsLabel->setVisible(!text.isEmpty());
m_credentialsLabel->setText(text);
updateGeometry();
}
void DanmakuServerListItemWidget::refreshRemoveIcon()
{
m_removeButton->setIcon(ThemeManager::getAdaptiveIcon(
QStringLiteral(":/svg/light/delete.svg"),
m_removable ? activeRemoveIconColor() : inactiveRemoveIconColor()));
}
void DanmakuServerListItemWidget::refreshSelectionState()
{
m_cardFrame->style()->unpolish(m_cardFrame);
m_cardFrame->style()->polish(m_cardFrame);
m_cardFrame->update();
}
@@ -1,49 +0,0 @@
#ifndef DANMAKUSERVERLISTITEMWIDGET_H
#define DANMAKUSERVERLISTITEMWIDGET_H
#include <QFrame>
#include "models/danmaku/danmakumodels.h"
class QEvent;
class QFrame;
class QLabel;
class ModernSwitch;
class QPushButton;
class DanmakuServerListItemWidget : public QFrame
{
Q_OBJECT
public:
explicit DanmakuServerListItemWidget(QWidget *parent = nullptr);
void setServer(const DanmakuServerDefinition &server);
void setSelected(bool selected);
void setCurrentServer(bool currentServer);
void setRemovable(bool removable);
signals:
void clicked();
void enabledChanged(bool enabled);
void removeRequested();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
static QString maskedSecret(const QString &secret);
void refreshCredentialsLabel();
void refreshRemoveIcon();
void refreshSelectionState();
DanmakuServerDefinition m_server;
bool m_removable = true;
QFrame *m_cardFrame = nullptr;
QLabel *m_nameLabel = nullptr;
QLabel *m_baseUrlLabel = nullptr;
QLabel *m_credentialsLabel = nullptr;
ModernSwitch *m_enabledSwitch = nullptr;
QPushButton *m_removeButton = nullptr;
};
#endif
@@ -1,560 +0,0 @@
#include "dashboardsectionorderstrip.h"
#include <QApplication>
#include <QEvent>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPropertyAnimation>
#include <QResizeEvent>
#include <QStyle>
namespace {
constexpr int StripHeight = 36;
constexpr int ChipHeight = 28;
constexpr int ChipSpacing = 8;
constexpr int ChipHorizontalPadding = 12;
constexpr int DragPreviewYOffset = 5;
constexpr int ChipAnimationDuration = 220;
constexpr qreal DragPreviewOpacity = 0.82;
}
DashboardSectionOrderStrip::DashboardSectionOrderStrip(QWidget* parent)
: QFrame(parent)
{
setObjectName("DashboardSectionOrderStrip");
setFrameShape(QFrame::NoFrame);
setMouseTracking(true);
setMinimumHeight(StripHeight);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
m_dropIndicator = new QFrame(this);
m_dropIndicator->setObjectName("DashboardSectionOrderDropIndicator");
m_dropIndicator->setAttribute(Qt::WA_TransparentForMouseEvents);
m_dropIndicator->setFixedHeight(ChipHeight);
m_dropIndicator->hide();
m_dragPreview = new QFrame(this);
m_dragPreview->setObjectName("DashboardSectionOrderDragPreview");
m_dragPreview->setAttribute(Qt::WA_TransparentForMouseEvents);
m_dragPreview->hide();
auto* previewLayout = new QHBoxLayout(m_dragPreview);
previewLayout->setContentsMargins(ChipHorizontalPadding, 0,
ChipHorizontalPadding, 0);
previewLayout->setSpacing(0);
m_dragPreviewLabel = new QLabel(m_dragPreview);
m_dragPreviewLabel->setObjectName("DashboardSectionOrderDragPreviewLabel");
m_dragPreviewLabel->setAlignment(Qt::AlignCenter);
previewLayout->addWidget(m_dragPreviewLabel);
m_dragPreviewOpacity = new QGraphicsOpacityEffect(m_dragPreview);
m_dragPreviewOpacity->setOpacity(DragPreviewOpacity);
m_dragPreview->setGraphicsEffect(m_dragPreviewOpacity);
}
void DashboardSectionOrderStrip::setSectionOrder(
const QStringList& order, const QHash<QString, QString>& titles)
{
if (m_dragging || m_pressedChip) {
resetDragState(false);
}
m_titles = titles;
m_order = order;
rebuildChips();
}
QStringList DashboardSectionOrderStrip::sectionOrder() const
{
return m_order;
}
QSize DashboardSectionOrderStrip::sizeHint() const
{
return QSize(fullContentWidth(), StripHeight);
}
QSize DashboardSectionOrderStrip::minimumSizeHint() const
{
return QSize(0, StripHeight);
}
bool DashboardSectionOrderStrip::eventFilter(QObject* watched, QEvent* event)
{
auto* chip = qobject_cast<QFrame*>(watched);
if (!chip) {
return QFrame::eventFilter(watched, event);
}
switch (event->type()) {
case QEvent::MouseButtonPress: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (mouseEvent->button() != Qt::LeftButton) {
break;
}
m_pressedChip = chip;
m_pressGlobalPos = mouseEvent->globalPosition().toPoint();
m_pressChipOffset = mouseEvent->position().toPoint();
chip->setCursor(Qt::ClosedHandCursor);
return true;
}
case QEvent::MouseMove: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (!m_pressedChip || !(mouseEvent->buttons() & Qt::LeftButton)) {
break;
}
const QPoint globalPos = mouseEvent->globalPosition().toPoint();
if (!m_dragging &&
(globalPos - m_pressGlobalPos).manhattanLength() >=
QApplication::startDragDistance()) {
beginDrag(m_pressedChip);
}
if (m_dragging && chip == m_draggedChip) {
updateDrag(globalPos);
}
return true;
}
case QEvent::MouseButtonRelease: {
auto* mouseEvent = static_cast<QMouseEvent*>(event);
if (mouseEvent->button() != Qt::LeftButton) {
break;
}
if (m_dragging && chip == m_draggedChip) {
finishDrag();
} else {
chip->setCursor(Qt::OpenHandCursor);
resetDragState();
}
return true;
}
case QEvent::Leave:
if (!m_dragging) {
chip->setCursor(Qt::OpenHandCursor);
}
break;
default:
break;
}
return QFrame::eventFilter(watched, event);
}
QFrame* DashboardSectionOrderStrip::createChip(const QString& title)
{
auto* chip = new QFrame(this);
chip->setObjectName("DashboardSectionOrderChip");
chip->setCursor(Qt::OpenHandCursor);
chip->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
chip->installEventFilter(this);
auto* layout = new QHBoxLayout(chip);
layout->setContentsMargins(ChipHorizontalPadding, 0, ChipHorizontalPadding,
0);
layout->setSpacing(0);
auto* label = new QLabel(title, chip);
label->setObjectName("DashboardSectionOrderChipLabel");
label->setAlignment(Qt::AlignCenter);
label->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(label);
chip->setFixedSize(
qMax(68, label->sizeHint().width() + ChipHorizontalPadding * 2),
ChipHeight);
return chip;
}
void DashboardSectionOrderStrip::rebuildChips()
{
for (ChipItem& item : m_chipItems) {
if (item.chip) {
item.chip->deleteLater();
}
}
m_chipItems.clear();
for (const QString& sectionId : m_order) {
const QString title = m_titles.value(sectionId, sectionId);
QFrame* chip = createChip(title);
m_chipItems.append({sectionId, chip});
}
relayoutChips(false);
updateGeometry();
}
int DashboardSectionOrderStrip::indexOfChip(const QFrame* chip) const
{
for (int index = 0; index < m_chipItems.size(); ++index) {
if (m_chipItems[index].chip == chip) {
return index;
}
}
return -1;
}
int DashboardSectionOrderStrip::insertionIndexForPosition(int localX) const
{
int insertionIndex = 0;
for (const ChipItem& item : m_chipItems) {
if (!item.chip || item.chip == m_draggedChip) {
continue;
}
if (localX < item.chip->geometry().center().x()) {
return insertionIndex;
}
++insertionIndex;
}
return insertionIndex;
}
void DashboardSectionOrderStrip::beginDrag(QFrame* chip)
{
m_draggedChip = chip;
m_dragSourceIndex = indexOfChip(chip);
if (m_dragSourceIndex < 0) {
resetDragState();
return;
}
m_dragging = true;
setDraggingChip(chip, true);
if (m_dragPreview && m_dragPreviewLabel) {
if (auto* label =
chip->findChild<QLabel*>("DashboardSectionOrderChipLabel")) {
m_dragPreviewLabel->setText(label->text());
} else {
m_dragPreviewLabel->clear();
}
m_dragPreview->setFixedSize(chip->size());
m_dragPreview->show();
m_dragPreview->raise();
updateDragPreview(m_pressGlobalPos);
}
grabMouse();
chip->hide();
setIndicatorIndex(m_dragSourceIndex, true);
}
void DashboardSectionOrderStrip::updateDrag(const QPoint& globalPos)
{
const QPoint localPos = mapFromGlobal(globalPos);
updateDragPreview(globalPos);
setIndicatorIndex(insertionIndexForPosition(localPos.x()));
}
void DashboardSectionOrderStrip::updateDragPreview(const QPoint& globalPos)
{
if (!m_dragPreview || !m_dragPreview->isVisible()) {
return;
}
const QPoint localPos = mapFromGlobal(globalPos);
const int previewWidth = m_dragPreview->width();
const int previewHeight = m_dragPreview->height();
int x = localPos.x() - m_pressChipOffset.x();
if (m_dropIndicator->isVisible()) {
const int indicatorCenterX = m_dropIndicator->geometry().center().x();
const int previewCenterX = x + previewWidth / 2;
const int snappedCenterX =
qRound(previewCenterX * 0.7 + indicatorCenterX * 0.3);
x = snappedCenterX - previewWidth / 2;
}
x = qBound(0, x, qMax(0, width() - previewWidth));
const int y = qBound(0, chipY() - DragPreviewYOffset,
qMax(0, height() - previewHeight));
m_dragPreview->move(x, y);
m_dragPreview->raise();
}
void DashboardSectionOrderStrip::finishDrag()
{
if (!m_dragging || m_dragSourceIndex < 0 || !m_draggedChip) {
resetDragState();
return;
}
QStringList newOrder = m_order;
const QString draggedSectionId = newOrder.takeAt(m_dragSourceIndex);
const int targetIndex =
qBound(0, m_indicatorIndex, newOrder.size());
newOrder.insert(targetIndex, draggedSectionId);
const bool changed = newOrder != m_order;
ChipItem draggedItem = m_chipItems.takeAt(m_dragSourceIndex);
m_chipItems.insert(targetIndex, draggedItem);
m_order = newOrder;
resetDragState(true);
if (changed) {
emit sectionOrderChanged(m_order);
}
}
void DashboardSectionOrderStrip::resetDragState(bool animateDraggedChip)
{
if (QWidget::mouseGrabber() == this) {
releaseMouse();
}
QPointer<QFrame> draggedChip = m_draggedChip;
const QPoint dragPreviewPos =
m_dragPreview ? m_dragPreview->pos() : QPoint();
if (m_draggedChip) {
setDraggingChip(m_draggedChip, false);
}
if (draggedChip) {
if (animateDraggedChip && m_dragPreview && m_dragPreview->isVisible()) {
draggedChip->move(dragPreviewPos);
draggedChip->raise();
}
draggedChip->show();
draggedChip->setCursor(Qt::OpenHandCursor);
}
if (m_pressedChip) {
m_pressedChip->setCursor(Qt::OpenHandCursor);
}
if (m_dragPreview) {
m_dragPreview->hide();
}
m_pressedChip.clear();
m_draggedChip.clear();
m_dragSourceIndex = -1;
m_indicatorIndex = -1;
m_dragging = false;
relayoutChips(animateDraggedChip);
}
void DashboardSectionOrderStrip::mouseMoveEvent(QMouseEvent* event)
{
if (m_dragging) {
updateDrag(event->globalPosition().toPoint());
event->accept();
return;
}
QFrame::mouseMoveEvent(event);
}
void DashboardSectionOrderStrip::mouseReleaseEvent(QMouseEvent* event)
{
if (m_dragging && event->button() == Qt::LeftButton) {
finishDrag();
event->accept();
return;
}
QFrame::mouseReleaseEvent(event);
}
void DashboardSectionOrderStrip::resizeEvent(QResizeEvent* event)
{
QFrame::resizeEvent(event);
relayoutChips(false);
}
void DashboardSectionOrderStrip::setDraggingChip(QFrame* chip, bool dragging)
{
if (!chip) {
return;
}
chip->setProperty("dragging", dragging);
chip->style()->unpolish(chip);
chip->style()->polish(chip);
chip->update();
}
void DashboardSectionOrderStrip::setIndicatorIndex(int index, bool animate)
{
const int maxIndex = m_dragging ? qMax(0, m_chipItems.size() - 1)
: m_chipItems.size();
const int adjustedIndex = qBound(0, index, maxIndex);
if (m_indicatorIndex == adjustedIndex && m_dropIndicator->isVisible()) {
return;
}
m_indicatorIndex = adjustedIndex;
relayoutChips(animate);
}
void DashboardSectionOrderStrip::relayoutChips(bool animate)
{
if (m_chipItems.isEmpty()) {
if (m_dropIndicator) {
stopPosAnimations(m_dropIndicator);
m_dropIndicator->hide();
}
return;
}
const bool showDropSlot =
m_dragging && m_draggedChip && m_indicatorIndex >= 0;
const int slotWidth =
(showDropSlot && m_draggedChip) ? m_draggedChip->width() : 0;
const int layoutWidth = showDropSlot ? fullContentWidth()
: contentWidthForCurrentState();
const int startX = qMax(0, width() - layoutWidth);
int x = startX;
int visibleIndex = 0;
bool indicatorPlaced = false;
for (int index = 0; index < m_chipItems.size(); ++index) {
auto* chip = m_chipItems[index].chip;
if (!chip || (m_dragging && chip == m_draggedChip)) {
continue;
}
if (showDropSlot && m_indicatorIndex == visibleIndex) {
m_dropIndicator->setFixedSize(slotWidth, ChipHeight);
m_dropIndicator->show();
moveWidgetTo(m_dropIndicator, QPoint(x, indicatorY()), animate,
ChipAnimationDuration);
m_dropIndicator->raise();
indicatorPlaced = true;
x += slotWidth + ChipSpacing;
}
chip->show();
moveWidgetTo(chip, QPoint(x, chipY()), animate, ChipAnimationDuration);
x += chip->width();
const bool hasTrailingVisibleChip = [&]() {
for (int next = index + 1; next < m_chipItems.size(); ++next) {
if (!m_chipItems[next].chip ||
(m_dragging && m_chipItems[next].chip == m_draggedChip)) {
continue;
}
return true;
}
return false;
}();
if (hasTrailingVisibleChip || (showDropSlot && m_indicatorIndex > visibleIndex)) {
x += ChipSpacing;
}
++visibleIndex;
}
if (showDropSlot && !indicatorPlaced) {
m_dropIndicator->setFixedSize(slotWidth, ChipHeight);
m_dropIndicator->show();
moveWidgetTo(m_dropIndicator, QPoint(x, indicatorY()), animate,
ChipAnimationDuration);
m_dropIndicator->raise();
indicatorPlaced = true;
}
if (!indicatorPlaced) {
stopPosAnimations(m_dropIndicator);
m_dropIndicator->hide();
}
if (m_dragPreview && m_dragPreview->isVisible()) {
m_dragPreview->raise();
}
}
void DashboardSectionOrderStrip::moveWidgetTo(QWidget* widget,
const QPoint& targetPos,
bool animate, int duration)
{
if (!widget) {
return;
}
stopPosAnimations(widget);
if (!animate || widget->pos() == targetPos) {
widget->move(targetPos);
return;
}
auto* animation = new QPropertyAnimation(widget, "pos", widget);
animation->setDuration(duration);
animation->setEasingCurve(QEasingCurve::OutCubic);
animation->setEndValue(targetPos);
animation->start(QAbstractAnimation::DeleteWhenStopped);
}
void DashboardSectionOrderStrip::stopPosAnimations(QWidget* widget) const
{
if (!widget) {
return;
}
const auto children = widget->children();
for (auto* child : children) {
auto* animation = qobject_cast<QPropertyAnimation*>(child);
if (!animation || animation->propertyName() != "pos") {
continue;
}
animation->stop();
animation->deleteLater();
}
}
int DashboardSectionOrderStrip::fullContentWidth() const
{
if (m_chipItems.isEmpty()) {
return 0;
}
int totalWidth = 0;
for (const ChipItem& item : m_chipItems) {
if (item.chip) {
totalWidth += item.chip->width();
}
}
totalWidth += ChipSpacing * qMax(0, m_chipItems.size() - 1);
return totalWidth;
}
int DashboardSectionOrderStrip::contentWidthForCurrentState() const
{
int totalWidth = 0;
int visibleCount = 0;
for (const ChipItem& item : m_chipItems) {
if (!item.chip || (m_dragging && item.chip == m_draggedChip)) {
continue;
}
totalWidth += item.chip->width();
++visibleCount;
}
totalWidth += ChipSpacing * qMax(0, visibleCount - 1);
return totalWidth;
}
int DashboardSectionOrderStrip::chipY() const
{
return qMax(0, (height() - ChipHeight) / 2);
}
int DashboardSectionOrderStrip::indicatorY() const
{
return chipY();
}
@@ -1,78 +0,0 @@
#ifndef DASHBOARDSECTIONORDERSTRIP_H
#define DASHBOARDSECTIONORDERSTRIP_H
#include <QFrame>
#include <QHash>
#include <QSize>
#include <QPointer>
#include <QStringList>
class QGraphicsOpacityEffect;
class QLabel;
class QMouseEvent;
class QResizeEvent;
class DashboardSectionOrderStrip : public QFrame {
Q_OBJECT
public:
explicit DashboardSectionOrderStrip(QWidget* parent = nullptr);
void setSectionOrder(const QStringList& order,
const QHash<QString, QString>& titles);
QStringList sectionOrder() const;
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
signals:
void sectionOrderChanged(const QStringList& order);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private:
struct ChipItem {
QString sectionId;
QFrame* chip = nullptr;
};
QFrame* createChip(const QString& title);
void rebuildChips();
int indexOfChip(const QFrame* chip) const;
int insertionIndexForPosition(int localX) const;
void beginDrag(QFrame* chip);
void updateDrag(const QPoint& globalPos);
void updateDragPreview(const QPoint& globalPos);
void finishDrag();
void resetDragState(bool animateDraggedChip = false);
void setDraggingChip(QFrame* chip, bool dragging);
void setIndicatorIndex(int index, bool animate = true);
void relayoutChips(bool animate);
void moveWidgetTo(QWidget* widget, const QPoint& targetPos, bool animate,
int duration = 180);
void stopPosAnimations(QWidget* widget) const;
int fullContentWidth() const;
int contentWidthForCurrentState() const;
int chipY() const;
int indicatorY() const;
QFrame* m_dropIndicator = nullptr;
QFrame* m_dragPreview = nullptr;
QLabel* m_dragPreviewLabel = nullptr;
QGraphicsOpacityEffect* m_dragPreviewOpacity = nullptr;
QList<ChipItem> m_chipItems;
QStringList m_order;
QHash<QString, QString> m_titles;
QPointer<QFrame> m_pressedChip;
QPointer<QFrame> m_draggedChip;
QPoint m_pressGlobalPos;
QPoint m_pressChipOffset;
int m_dragSourceIndex = -1;
int m_indicatorIndex = -1;
bool m_dragging = false;
};
#endif
@@ -1,97 +0,0 @@
#include "dashboardsectionorderwidget.h"
#include "dashboardsectionorderstrip.h"
#include "../utils/dashboardsectionorderutils.h"
#include <QHash>
#include <QLabel>
#include <QVBoxLayout>
DashboardSectionOrderWidget::DashboardSectionOrderWidget(QWidget* parent)
: QWidget(parent)
{
setObjectName("DashboardSectionOrderWidget");
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(8);
m_hintLabel = new QLabel(
tr("Drag the items below to change the order of home screen sections. "
"Left to right matches top to bottom on the home screen."),
this);
m_hintLabel->setObjectName("SettingsCardDesc");
m_hintLabel->setWordWrap(true);
layout->addWidget(m_hintLabel);
m_orderStrip = new DashboardSectionOrderStrip(this);
layout->addWidget(m_orderStrip);
connect(m_orderStrip, &DashboardSectionOrderStrip::sectionOrderChanged, this,
&DashboardSectionOrderWidget::sectionOrderChanged);
setSectionOrder(DashboardSectionOrderUtils::defaultSectionOrder());
}
void DashboardSectionOrderWidget::setSectionOrder(const QStringList& order)
{
updateSectionOrder(DashboardSectionOrderUtils::normalizeSectionOrder(order));
}
QStringList DashboardSectionOrderWidget::sectionOrder() const
{
if (!m_orderStrip) {
return {};
}
return DashboardSectionOrderUtils::normalizeSectionOrder(
m_orderStrip->sectionOrder());
}
QString DashboardSectionOrderWidget::titleForSectionId(
const QString& sectionId) const
{
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::ContinueWatchingSectionId)) {
return tr("Continue Watching");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) {
return tr("Latest Media");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) {
return tr("Recommended");
}
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::CompletedWatchingSectionId)) {
return tr("Completed Watching");
}
if (sectionId ==
QLatin1String(DashboardSectionOrderUtils::AllLibrariesSectionId)) {
return tr("All Libraries");
}
if (sectionId == QLatin1String(
DashboardSectionOrderUtils::
EachLibrarySectionsSectionId)) {
return tr("Each Library Sections");
}
return sectionId;
}
void DashboardSectionOrderWidget::updateSectionOrder(const QStringList& order)
{
if (!m_orderStrip) {
return;
}
QHash<QString, QString> titles;
const QStringList normalizedOrder =
DashboardSectionOrderUtils::normalizeSectionOrder(order);
for (const QString& sectionId : normalizedOrder) {
titles.insert(sectionId, titleForSectionId(sectionId));
}
m_orderStrip->setSectionOrder(normalizedOrder, titles);
}
@@ -1,30 +0,0 @@
#ifndef DASHBOARDSECTIONORDERWIDGET_H
#define DASHBOARDSECTIONORDERWIDGET_H
#include <QStringList>
#include <QWidget>
class DashboardSectionOrderStrip;
class QLabel;
class DashboardSectionOrderWidget : public QWidget {
Q_OBJECT
public:
explicit DashboardSectionOrderWidget(QWidget* parent = nullptr);
void setSectionOrder(const QStringList& order);
QStringList sectionOrder() const;
signals:
void sectionOrderChanged(const QStringList& order);
private:
QString titleForSectionId(const QString& sectionId) const;
void updateSectionOrder(const QStringList& order);
QLabel* m_hintLabel = nullptr;
DashboardSectionOrderStrip* m_orderStrip = nullptr;
};
#endif
@@ -1,450 +0,0 @@
#include "detailactionwidget.h"
#include "../managers/externalplayerdetector.h"
#include "../managers/thememanager.h"
#include "../utils/mediasourcepreferenceutils.h"
#include "../utils/playerpreferenceutils.h"
#include "modernmenubutton.h"
#include "splitplayerbutton.h"
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QSet>
#include <QStyle>
#include <QVBoxLayout>
#include <config/config_keys.h>
#include <config/configstore.h>
DetailActionWidget::DetailActionWidget(QWidget *parent) : QWidget(parent) {
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(4);
auto *actionsLayout = new QHBoxLayout();
actionsLayout->setSpacing(12);
actionsLayout->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_resumeBtn = new QPushButton(tr("▶ Resume"), this);
m_resumeBtn->setObjectName("detail-resume-btn");
m_resumeBtn->setCursor(Qt::PointingHandCursor);
m_resumeBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
m_playBtn = new QPushButton(tr("▶ Play"), this);
m_playBtn->setObjectName("detail-play-btn");
m_playBtn->setCursor(Qt::PointingHandCursor);
m_playBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
m_favBtn = new QPushButton(this);
m_favBtn->setObjectName("detail-fav-btn");
m_favBtn->setIcon(QIcon(":/svg/light/heart.svg"));
m_favBtn->setIconSize(QSize(20, 20));
m_favBtn->setFixedSize(36, 36);
m_favBtn->setCursor(Qt::PointingHandCursor);
m_playedBtn = new QPushButton(this);
m_playedBtn->setObjectName("detail-played-btn");
m_playedBtn->setIconSize(QSize(20, 20));
m_playedBtn->setFixedSize(36, 36);
m_playedBtn->setCursor(Qt::PointingHandCursor);
m_progressWidget = new QWidget(this);
m_progressWidget->setMaximumWidth(450);
auto *progressLayout = new QHBoxLayout(m_progressWidget);
progressLayout->setContentsMargins(0, 0, 0, 0);
progressLayout->setSpacing(10);
m_progressBar = new QProgressBar(m_progressWidget);
m_progressBar->setObjectName("detail-progress-bar");
m_progressBar->setTextVisible(false);
m_remainingTimeLabel = new QLabel(m_progressWidget);
m_remainingTimeLabel->setObjectName("detail-remaining-time");
progressLayout->addWidget(m_progressBar, 1);
progressLayout->addWidget(m_remainingTimeLabel);
m_extPlayerBtn = new SplitPlayerButton(this);
m_extPlayerBtn->setObjectName("detail-ext-player-btn");
m_extPlayerBtn->setIconOnly(true);
m_extPlayerBtn->hide();
connect(m_extPlayerBtn, &SplitPlayerButton::playRequested, this,
&DetailActionWidget::externalPlayRequested);
connect(m_extPlayerBtn, &SplitPlayerButton::playerSelected, this,
&DetailActionWidget::externalPlayRequested);
actionsLayout->addWidget(m_resumeBtn);
actionsLayout->addWidget(m_playBtn);
actionsLayout->addWidget(m_extPlayerBtn);
actionsLayout->addWidget(m_favBtn);
actionsLayout->addWidget(m_playedBtn);
actionsLayout->addWidget(m_progressWidget);
actionsLayout->addStretch();
auto *streamSelectorsLayout = new QHBoxLayout();
streamSelectorsLayout->setSpacing(6);
streamSelectorsLayout->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_versionComboBox = new ModernMenuButton(this);
m_audioComboBox = new ModernMenuButton(this);
m_subtitleComboBox = new ModernMenuButton(this);
streamSelectorsLayout->addWidget(m_versionComboBox);
streamSelectorsLayout->addWidget(m_audioComboBox);
streamSelectorsLayout->addWidget(m_subtitleComboBox);
streamSelectorsLayout->addStretch();
mainLayout->addLayout(actionsLayout);
mainLayout->addLayout(streamSelectorsLayout);
connect(m_playBtn, &QPushButton::clicked, this,
&DetailActionWidget::playRequested);
connect(m_resumeBtn, &QPushButton::clicked, this,
&DetailActionWidget::resumeRequested);
connect(m_favBtn, &QPushButton::clicked, this,
&DetailActionWidget::favoriteRequested);
connect(m_playedBtn, &QPushButton::clicked, this,
&DetailActionWidget::playedToggleRequested);
connect(m_versionComboBox, &ModernMenuButton::currentIndexChanged, this,
&DetailActionWidget::sourceVersionChanged);
clear();
}
void DetailActionWidget::clear() {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_progressWidget->hide();
m_versionComboBox->blockSignals(true);
m_versionComboBox->clear();
m_versionComboBox->hide();
m_versionComboBox->blockSignals(false);
m_audioComboBox->blockSignals(true);
m_audioComboBox->clear();
m_audioComboBox->hide();
m_audioComboBox->blockSignals(false);
m_subtitleComboBox->blockSignals(true);
m_subtitleComboBox->clear();
m_subtitleComboBox->hide();
m_subtitleComboBox->blockSignals(false);
}
void DetailActionWidget::setupNormalMode(const MediaItem &item) {
m_playBtn->setEnabled(true);
if (item.userData.playedPercentage > 0 &&
item.userData.playedPercentage < 100) {
m_resumeBtn->show();
m_playBtn->setText(tr("↺ Play from Beginning"));
m_progressBar->setValue(static_cast<int>(item.userData.playedPercentage));
long long remainingTicks =
item.runTimeTicks - item.userData.playbackPositionTicks;
if (remainingTicks > 0) {
m_remainingTimeLabel->setText(
tr("%1 remaining").arg(formatRunTime(remainingTicks)));
} else {
m_remainingTimeLabel->clear();
}
m_progressWidget->show();
} else {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_progressWidget->hide();
}
}
void DetailActionWidget::setSeriesLoadingMode() {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play"));
m_playBtn->setEnabled(false);
m_progressWidget->hide();
}
void DetailActionWidget::setupSeriesMode(const MediaItem &nextUpItem,
const QString &epTag) {
m_playBtn->setEnabled(true);
if (nextUpItem.userData.playbackPositionTicks > 0) {
m_resumeBtn->show();
m_resumeBtn->setText(tr("▶ Resume %1").arg(epTag));
m_playBtn->setText(tr("↺ Restart %1").arg(epTag));
if (nextUpItem.userData.playedPercentage > 0) {
m_progressBar->setValue(
static_cast<int>(nextUpItem.userData.playedPercentage));
long long remainingTicks =
nextUpItem.runTimeTicks - nextUpItem.userData.playbackPositionTicks;
if (remainingTicks > 0) {
m_remainingTimeLabel->setText(
tr("%1 remaining").arg(formatRunTime(remainingTicks)));
} else {
m_remainingTimeLabel->clear();
}
m_progressWidget->show();
}
} else {
m_resumeBtn->hide();
m_playBtn->setText(tr("▶ Play %1").arg(epTag));
m_progressWidget->hide();
}
}
void DetailActionWidget::setFavoriteState(bool isFavorite) {
m_favBtn->setProperty("isFavorite", isFavorite);
m_favBtn->setIcon(QIcon(isFavorite ? ":/svg/light/heart-fill.svg"
: ":/svg/light/heart-outline.svg"));
m_favBtn->style()->unpolish(m_favBtn);
m_favBtn->style()->polish(m_favBtn);
}
void DetailActionWidget::setPlayedState(bool played) {
m_playedBtn->setProperty("played", played);
const QString themeDir =
ThemeManager::instance()->isDarkMode() ? "dark" : "light";
if (played) {
m_playedBtn->setIcon(QIcon(":/svg/dark/played-check.svg"));
} else {
m_playedBtn->setIcon(
QIcon(QString(":/svg/%1/unplayed-check.svg").arg(themeDir)));
}
m_playedBtn->style()->unpolish(m_playedBtn);
m_playedBtn->style()->polish(m_playedBtn);
}
void DetailActionWidget::setSources(const QList<MediaSourceInfo> &sources,
int currentIndex) {
m_versionComboBox->blockSignals(true);
m_versionComboBox->clear();
if (!sources.isEmpty()) {
for (int i = 0; i < sources.size(); ++i) {
const auto &src = sources[i];
QString versionName =
src.name.isEmpty() ? tr("Version %1").arg(i + 1) : src.name;
QString videoInfo = tr("Unknown Video");
for (const auto &stream : src.mediaStreams) {
if (stream.type == "Video") {
QStringList parts;
if (stream.width > 0)
parts << QString("%1x%2").arg(stream.width).arg(stream.height);
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.realFrameRate > 0)
parts << QString::number(stream.realFrameRate, 'f', 1) + " fps";
if (stream.bitRate > 0)
parts << QString("%1 Mbps").arg(stream.bitRate / 1000000.0, 0, 'f',
1);
if (!parts.isEmpty())
videoInfo = parts.join(" · ");
break;
}
}
m_versionComboBox->addItem("🎞 " + versionName, videoInfo, QString(),
src.id);
}
int bestIndex = currentIndex;
if (sources.size() > 1) {
bestIndex = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex(
sources,
ConfigStore::instance()
->get<QString>(ConfigKeys::PlayerPreferredVersion)
.trimmed());
}
m_versionComboBox->setCurrentIndex(bestIndex);
m_versionComboBox->show();
} else {
m_versionComboBox->hide();
}
m_versionComboBox->blockSignals(false);
}
void DetailActionWidget::setStreams(const MediaSourceInfo &source) {
m_audioComboBox->blockSignals(true);
m_subtitleComboBox->blockSignals(true);
m_audioComboBox->clear();
m_subtitleComboBox->clear();
m_subtitleComboBox->addItem("🚫 " + tr("No Subtitles"), QString(),
tr("Disable subtitle display"), -1);
QString prefAudioLang = ConfigStore::instance()->get<QString>(
ConfigKeys::PlayerAudioLang, "auto");
QString prefSubLang =
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSubLang, "auto");
const int preferredAudioStreamIndex =
PlayerPreferenceUtils::findPreferredStreamIndex(
source.mediaStreams, "Audio", prefAudioLang);
const int preferredSubtitleStreamIndex =
PlayerPreferenceUtils::findPreferredStreamIndex(
source.mediaStreams, "Subtitle", prefSubLang);
const bool subtitleDisabled =
PlayerPreferenceUtils::isSubtitleDisabled(prefSubLang);
int defaultAudioIdx = 0;
int defaultSubIdx = 0;
bool subMatchedByPref = false;
bool subMatchedByDefault = false;
for (const auto &stream : source.mediaStreams) {
if (stream.type == "Audio") {
QString title =
stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle;
if (title.isEmpty())
title = tr("Audio Track");
QStringList parts;
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.channels > 0)
parts << QString("%1 ch").arg(stream.channels);
if (stream.sampleRate > 0)
parts << QString("%1 kHz").arg(stream.sampleRate / 1000.0, 0, 'f', 1);
if (stream.bitRate > 0)
parts << QString("%1 kbps").arg(stream.bitRate / 1000);
QStringList secondLineParts;
if (!stream.title.isEmpty())
secondLineParts << stream.title;
if (!parts.isEmpty())
secondLineParts << parts.join(" · ");
m_audioComboBox->addItem("🔊 " + title, QString(),
secondLineParts.join(" · "), stream.index);
int curIdx = m_audioComboBox->count() - 1;
if (preferredAudioStreamIndex >= 0 &&
stream.index == preferredAudioStreamIndex) {
defaultAudioIdx = curIdx;
} else if (preferredAudioStreamIndex < 0 && stream.isDefault) {
defaultAudioIdx = curIdx;
}
} else if (stream.type == "Subtitle") {
QString title =
stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle;
if (title.isEmpty())
title = tr("Subtitle");
QStringList parts;
if (!stream.codec.isEmpty())
parts << stream.codec.toUpper();
if (stream.isForced)
parts << tr("Forced");
if (stream.isExternal)
parts << tr("External");
if (stream.isHearingImpaired)
parts << tr("SDH");
QStringList secondLineParts;
if (!stream.title.isEmpty())
secondLineParts << stream.title;
if (!parts.isEmpty())
secondLineParts << parts.join(" · ");
m_subtitleComboBox->addItem("💬 " + title, QString(),
secondLineParts.join(" · "), stream.index);
int curIdx = m_subtitleComboBox->count() - 1;
if (preferredSubtitleStreamIndex >= 0 &&
stream.index == preferredSubtitleStreamIndex) {
defaultSubIdx = curIdx;
subMatchedByPref = true;
} else if (preferredSubtitleStreamIndex < 0 && !subMatchedByPref &&
stream.isDefault) {
defaultSubIdx = curIdx;
subMatchedByDefault = true;
}
}
}
if (subtitleDisabled) {
defaultSubIdx = 0;
} else if (!subMatchedByPref && !subMatchedByDefault &&
m_subtitleComboBox->count() > 1) {
defaultSubIdx = 1;
}
if (m_audioComboBox->count() > 0) {
m_audioComboBox->setCurrentIndex(defaultAudioIdx);
m_audioComboBox->show();
} else {
m_audioComboBox->hide();
}
if (m_subtitleComboBox->count() > 1) {
m_subtitleComboBox->setCurrentIndex(defaultSubIdx);
m_subtitleComboBox->show();
} else {
m_subtitleComboBox->hide();
}
m_audioComboBox->blockSignals(false);
m_subtitleComboBox->blockSignals(false);
}
int DetailActionWidget::currentSourceIndex() const {
return qMax(0, m_versionComboBox->currentIndex());
}
int DetailActionWidget::currentAudioIndex() const {
return m_audioComboBox->isVisible() ? m_audioComboBox->currentData().toInt()
: -1;
}
int DetailActionWidget::currentSubtitleIndex() const {
return m_subtitleComboBox->isVisible()
? m_subtitleComboBox->currentData().toInt()
: -1;
}
QString DetailActionWidget::formatRunTime(long long ticks) {
long long totalSeconds = ticks / 10000000;
long long hours = totalSeconds / 3600;
long long minutes = (totalSeconds % 3600) / 60;
if (hours > 0)
return QString(tr("%1 hr %2 min")).arg(hours).arg(minutes);
return QString(tr("%1 min")).arg(minutes);
}
void DetailActionWidget::refreshExtPlayerButton() {
bool extEnabled =
ConfigStore::instance()->get<bool>(ConfigKeys::ExtPlayerEnable, false);
if (!extEnabled) {
m_extPlayerBtn->hide();
return;
}
QString currentPath =
ConfigStore::instance()->get<QString>(ConfigKeys::ExtPlayerPath);
QList<DetectedPlayer> allPlayers = ExternalPlayerDetector::loadFromConfig();
QSet<QString> knownPaths;
for (const auto &p : allPlayers)
knownPaths.insert(p.path);
if (!currentPath.isEmpty() && currentPath != "custom" &&
!knownPaths.contains(currentPath) && QFileInfo::exists(currentPath)) {
allPlayers.prepend({QFileInfo(currentPath).baseName(), currentPath});
}
if (allPlayers.isEmpty()) {
m_extPlayerBtn->hide();
return;
}
QString activePlayerPath = currentPath;
if (activePlayerPath.isEmpty() || activePlayerPath == "custom") {
activePlayerPath = allPlayers.first().path;
}
m_extPlayerBtn->setPlayers(allPlayers, activePlayerPath);
}
@@ -1,63 +0,0 @@
#ifndef DETAILACTIONWIDGET_H
#define DETAILACTIONWIDGET_H
#include <QWidget>
#include "models/media/mediaitem.h"
class QPushButton;
class QProgressBar;
class QLabel;
class ModernMenuButton;
class SplitPlayerButton;
class DetailActionWidget : public QWidget {
Q_OBJECT
public:
explicit DetailActionWidget(QWidget* parent = nullptr);
void setupNormalMode(const MediaItem& item);
void setupSeriesMode(const MediaItem& nextUpItem, const QString& epTag);
void setSeriesLoadingMode();
void setFavoriteState(bool isFavorite);
void setPlayedState(bool played);
void setSources(const QList<MediaSourceInfo>& sources, int currentIndex = 0);
void setStreams(const MediaSourceInfo& source);
void clear();
int currentSourceIndex() const;
int currentAudioIndex() const;
int currentSubtitleIndex() const;
void refreshExtPlayerButton();
signals:
void playRequested();
void resumeRequested();
void favoriteRequested();
void playedToggleRequested();
void sourceVersionChanged(int index);
void externalPlayRequested(const QString &playerPath);
private:
QString formatRunTime(long long ticks);
QPushButton* m_resumeBtn;
QPushButton* m_playBtn;
QPushButton* m_favBtn;
QPushButton* m_playedBtn;
QWidget* m_progressWidget;
QProgressBar* m_progressBar;
QLabel* m_remainingTimeLabel;
ModernMenuButton* m_versionComboBox;
ModernMenuButton* m_audioComboBox;
ModernMenuButton* m_subtitleComboBox;
SplitPlayerButton* m_extPlayerBtn;
};
#endif
@@ -1,494 +0,0 @@
#include "detailbottominfowidget.h"
#include "detailtagbutton.h"
#include "flowlayout.h"
#include "horizontalwidgetgallery.h"
#include <QDesktopServices>
#include <QEvent>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QLabel>
#include <QMargins>
#include <QPushButton>
#include <QShowEvent>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
DetailBottomInfoWidget::DetailBottomInfoWidget(QWidget *parent)
: QWidget(parent) {
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(40, 8, 40, 0);
mainLayout->setSpacing(12);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
m_tagsBottomTitle = new QLabel(tr("Tags"), this);
m_tagsBottomTitle->setObjectName("detail-section-title");
m_tagsBottomWidget = new QWidget(this);
m_tagsBottomLayout = new FlowLayout(m_tagsBottomWidget, 0, 8, 8);
m_studiosTitle = new QLabel(tr("Studios"), this);
m_studiosTitle->setObjectName("detail-section-title");
m_studiosWidget = new QWidget(this);
m_studiosLayout = new FlowLayout(m_studiosWidget, 0, 8, 8);
m_externalLinksTitle = new QLabel(tr("External Links"), this);
m_externalLinksTitle->setObjectName("detail-section-title");
m_externalLinksWidget = new QWidget(this);
m_externalLinksLayout = new FlowLayout(m_externalLinksWidget, 0, 8, 8);
m_mediaInfoTitle = new QLabel(tr("Media Info"), this);
m_mediaInfoTitle->setObjectName("detail-section-title");
m_fileInfoWidget = new QWidget(this);
auto *fileInfoLayout = new QVBoxLayout(m_fileInfoWidget);
fileInfoLayout->setContentsMargins(0, 0, 0, 4);
fileInfoLayout->setSpacing(6);
m_filePathLabel = new QLabel(m_fileInfoWidget);
m_filePathLabel->setObjectName("detail-filepath");
m_filePathLabel->setWordWrap(true);
m_filePathLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
m_fileMetaLabel = new QLabel(m_fileInfoWidget);
m_fileMetaLabel->setObjectName("detail-filemeta");
fileInfoLayout->addWidget(m_filePathLabel);
fileInfoLayout->addWidget(m_fileMetaLabel);
m_mediaInfoGallery = new HorizontalWidgetGallery(this);
m_mediaInfoGallery->setObjectName("detail-media-info-gallery");
m_tagsBottomWrapper = wrapMaxWidth(m_tagsBottomWidget, 1200);
mainLayout->addWidget(m_tagsBottomTitle);
mainLayout->addWidget(m_tagsBottomWrapper);
m_studiosWrapper = wrapMaxWidth(m_studiosWidget, 1200);
mainLayout->addWidget(m_studiosTitle);
mainLayout->addWidget(m_studiosWrapper);
m_externalLinksWrapper = wrapMaxWidth(m_externalLinksWidget, 1200);
mainLayout->addWidget(m_externalLinksTitle);
mainLayout->addWidget(m_externalLinksWrapper);
mainLayout->addWidget(m_mediaInfoTitle);
mainLayout->addWidget(m_fileInfoWidget);
mainLayout->addWidget(m_mediaInfoGallery);
auto watchFlowGeometry = [this](QWidget *widget, QWidget *wrapper) {
if (widget)
widget->installEventFilter(this);
if (wrapper)
wrapper->installEventFilter(this);
};
watchFlowGeometry(m_tagsBottomWidget, m_tagsBottomWrapper);
watchFlowGeometry(m_studiosWidget, m_studiosWrapper);
watchFlowGeometry(m_externalLinksWidget, m_externalLinksWrapper);
clear();
}
QWidget *DetailBottomInfoWidget::wrapMaxWidth(QWidget *child, int maxW) {
child->setMaximumWidth(maxW);
child->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
QWidget *wrapper = new QWidget(this);
wrapper->setObjectName("detail-maxWidth-wrapper");
wrapper->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum);
auto *hl = new QHBoxLayout(wrapper);
hl->setContentsMargins(0, 0, 0, 0);
hl->setSpacing(0);
hl->addWidget(child, 1);
hl->addStretch(0);
return wrapper;
}
void DetailBottomInfoWidget::clear() {
clearLayout(m_tagsBottomLayout);
clearLayout(m_studiosLayout);
clearLayout(m_externalLinksLayout);
m_mediaInfoGallery->clear();
m_tagsBottomTitle->hide();
m_tagsBottomWrapper->hide();
m_studiosTitle->hide();
m_studiosWrapper->hide();
m_externalLinksTitle->hide();
m_externalLinksWrapper->hide();
m_mediaInfoTitle->hide();
m_fileInfoWidget->hide();
m_mediaInfoGallery->hide();
updateFlowLayoutHeights();
}
void DetailBottomInfoWidget::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
scheduleFlowLayoutHeightUpdate();
}
void DetailBottomInfoWidget::showEvent(QShowEvent *event) {
QWidget::showEvent(event);
scheduleFlowLayoutHeightUpdate();
}
bool DetailBottomInfoWidget::eventFilter(QObject *watched, QEvent *event) {
const bool watchesFlowGeometry =
watched == m_tagsBottomWidget || watched == m_tagsBottomWrapper ||
watched == m_studiosWidget || watched == m_studiosWrapper ||
watched == m_externalLinksWidget ||
watched == m_externalLinksWrapper;
if (watchesFlowGeometry) {
switch (event->type()) {
case QEvent::LayoutRequest:
case QEvent::Resize:
case QEvent::Show:
scheduleFlowLayoutHeightUpdate();
break;
default:
break;
}
}
return QWidget::eventFilter(watched, event);
}
void DetailBottomInfoWidget::scheduleFlowLayoutHeightUpdate() {
if (m_flowLayoutHeightUpdatePending)
return;
m_flowLayoutHeightUpdatePending = true;
QTimer::singleShot(0, this, [this]() {
m_flowLayoutHeightUpdatePending = false;
updateFlowLayoutHeights();
});
}
int DetailBottomInfoWidget::resolveFlowLayoutWidth(QWidget *widget) const {
if (!widget)
return 0;
const int maxWidth =
widget->maximumWidth() < QWIDGETSIZE_MAX ? widget->maximumWidth() : 0;
auto clampWidth = [maxWidth](int width) {
if (maxWidth > 0)
width = qMin(width, maxWidth);
return qMax(0, width);
};
auto horizontalMargins = [this]() {
if (QLayout *mainLayout = this->layout()) {
const QMargins margins = mainLayout->contentsMargins();
return margins.left() + margins.right();
}
return 0;
};
auto contentWidthFrom = [&horizontalMargins](const QWidget *source) {
if (!source)
return 0;
return qMax(0, source->contentsRect().width() - horizontalMargins());
};
QWidget *wrapper = widget->parentWidget();
const int wrapperWidth = wrapper ? wrapper->contentsRect().width() : 0;
if (isVisible() && wrapper && wrapper->isVisible() && wrapperWidth > 0)
return clampWidth(wrapperWidth);
if (!isVisible() && maxWidth > 0) {
const int parentContentWidth = contentWidthFrom(parentWidget());
if (parentContentWidth > 160)
return clampWidth(parentContentWidth);
return maxWidth;
}
int targetWidth = contentWidthFrom(this);
if (targetWidth <= 0 && parentWidget())
targetWidth = contentWidthFrom(parentWidget());
if (targetWidth <= 0)
targetWidth = widget->width();
if (targetWidth <= 0 && maxWidth > 0)
targetWidth = maxWidth;
return clampWidth(targetWidth);
}
void DetailBottomInfoWidget::updateFlowLayoutHeight(QWidget *widget,
FlowLayout *layout) {
if (!widget || !layout)
return;
int targetHeight = 0;
if (layout->count() > 0) {
const int targetWidth = resolveFlowLayoutWidth(widget);
if (targetWidth > 0) {
layout->invalidate();
targetHeight = layout->heightForWidth(targetWidth);
}
}
if (widget->minimumHeight() != targetHeight) {
widget->setMinimumHeight(targetHeight);
}
if (widget->maximumHeight() != targetHeight) {
widget->setMaximumHeight(targetHeight);
}
widget->updateGeometry();
if (QWidget *wrapper = widget->parentWidget()) {
if (wrapper->minimumHeight() != targetHeight) {
wrapper->setMinimumHeight(targetHeight);
}
if (wrapper->maximumHeight() != targetHeight) {
wrapper->setMaximumHeight(targetHeight);
}
wrapper->updateGeometry();
}
}
void DetailBottomInfoWidget::updateFlowLayoutHeights() {
updateFlowLayoutHeight(m_tagsBottomWidget, m_tagsBottomLayout);
updateFlowLayoutHeight(m_studiosWidget, m_studiosLayout);
updateFlowLayoutHeight(m_externalLinksWidget, m_externalLinksLayout);
updateGeometry();
}
void DetailBottomInfoWidget::clearLayout(QLayout *layout) {
if (!layout)
return;
QLayoutItem *child;
while ((child = layout->takeAt(0)) != nullptr) {
if (child->widget()) {
child->widget()->hide();
child->widget()->setParent(nullptr);
child->widget()->deleteLater();
} else if (child->layout()) {
clearLayout(child->layout());
}
delete child;
}
}
QString DetailBottomInfoWidget::formatSize(long long bytes) {
if (bytes < 1024)
return QString::number(bytes) + " B";
if (bytes < 1024 * 1024)
return QString::number(bytes / 1024.0, 'f', 1) + " KB";
if (bytes < 1024 * 1024 * 1024)
return QString::number(bytes / 1048576.0, 'f', 1) + " MB";
return QString::number(bytes / 1073741824.0, 'f', 1) + " GB";
}
void DetailBottomInfoWidget::addInfoRow(QGridLayout *layout, int &row,
const QString &key,
const QString &value) {
if (value.isEmpty())
return;
QLabel *kLabel = new QLabel(key);
kLabel->setObjectName("stream-info-key");
QLabel *vLabel = new QLabel(value);
vLabel->setObjectName("stream-info-value");
vLabel->setWordWrap(true);
layout->addWidget(kLabel, row, 0, Qt::AlignTop | Qt::AlignLeft);
layout->addWidget(vLabel, row, 1, Qt::AlignTop | Qt::AlignLeft);
row++;
}
void DetailBottomInfoWidget::setInfo(const MediaItem &item,
const QList<MediaSourceInfo> &sources) {
clear();
if (!item.tags.isEmpty()) {
m_tagsBottomTitle->show();
m_tagsBottomWrapper->show();
for (const QString &tag : item.tags) {
auto *btn = new DetailTagButton(tag, m_tagsBottomWidget);
connect(btn, &QPushButton::clicked, this,
[this, tag]() { Q_EMIT filterClicked("Tag", tag); });
m_tagsBottomLayout->addWidget(btn);
}
}
if (!item.studios.isEmpty()) {
m_studiosTitle->show();
m_studiosWrapper->show();
for (const auto &studio : item.studios) {
auto *btn = new DetailTagButton(studio.name, m_studiosWidget);
connect(btn, &QPushButton::clicked, this,
[this, name = studio.name]() { Q_EMIT filterClicked("Studio", name); });
m_studiosLayout->addWidget(btn);
}
}
if (!item.externalUrls.isEmpty()) {
m_externalLinksTitle->show();
m_externalLinksWrapper->show();
for (const auto &link : item.externalUrls) {
auto *btn = new DetailTagButton(link.name, m_externalLinksWidget);
connect(btn, &QPushButton::clicked, this,
[link]() { QDesktopServices::openUrl(QUrl(link.url)); });
m_externalLinksLayout->addWidget(btn);
}
}
updateFlowLayoutHeights();
scheduleFlowLayoutHeightUpdate();
if (sources.isEmpty())
return;
m_mediaInfoTitle->show();
m_fileInfoWidget->show();
m_mediaInfoGallery->show();
const auto &source = sources.first();
QString actualPath = source.path.isEmpty() ? item.path : source.path;
m_filePathLabel->setText(QUrl::fromPercentEncoding(actualPath.toUtf8()));
QStringList metaParts;
QString container =
source.container.isEmpty() ? item.container : source.container;
if (!container.isEmpty())
metaParts << container.toUpper();
long long size = source.size > 0 ? source.size : item.size;
if (size > 0)
metaParts << formatSize(size);
if (!item.dateCreated.isEmpty())
metaParts << tr("Added %1").arg(item.dateCreated);
m_fileMetaLabel->setText(metaParts.join(" • "));
for (const auto &stream : source.mediaStreams) {
QWidget *card = new QWidget();
card->setObjectName("stream-card");
card->setMinimumWidth(280);
auto *layout = new QVBoxLayout(card);
layout->setContentsMargins(16, 16, 16, 16);
layout->setSpacing(8);
QString typeText = stream.type;
if (typeText == "Video")
typeText = tr("Video");
else if (typeText == "Audio")
typeText = tr("Audio");
else if (typeText == "Subtitle")
typeText = tr("Subtitle");
QLabel *typeLabel = new QLabel(typeText, card);
typeLabel->setObjectName("stream-type");
layout->addWidget(typeLabel);
QGridLayout *grid = new QGridLayout();
grid->setContentsMargins(0, 4, 0, 0);
grid->setSpacing(8);
grid->setColumnMinimumWidth(0, 80);
int row = 0;
addInfoRow(grid, row, tr("Title"), stream.displayTitle);
addInfoRow(grid, row, tr("Codec"), stream.codec.toUpper());
if (stream.type == "Video") {
if (!stream.language.isEmpty())
addInfoRow(grid, row, tr("Language"), stream.language);
if (!stream.codecTag.isEmpty())
addInfoRow(grid, row, tr("Codec Tag"), stream.codecTag);
if (!stream.profile.isEmpty())
addInfoRow(grid, row, tr("Profile"), stream.profile);
if (stream.level > 0)
addInfoRow(grid, row, tr("Level"), QString::number(stream.level));
if (stream.width > 0 && stream.height > 0)
addInfoRow(grid, row, tr("Resolution"),
QString("%1x%2").arg(stream.width).arg(stream.height));
if (!stream.aspectRatio.isEmpty())
addInfoRow(grid, row, tr("Aspect Ratio"), stream.aspectRatio);
if (stream.isInterlaced)
addInfoRow(grid, row, tr("Interlaced"), tr("Yes"));
if (stream.realFrameRate > 0)
addInfoRow(grid, row, tr("Framerate"),
QString::number(stream.realFrameRate, 'f', 2));
if (stream.bitRate > 0)
addInfoRow(grid, row, tr("Bitrate"),
QString("%1 kbps").arg(stream.bitRate / 1000));
if (stream.bitDepth > 0)
addInfoRow(grid, row, tr("Bit Depth"),
QString("%1 bit").arg(stream.bitDepth));
if (!stream.pixelFormat.isEmpty())
addInfoRow(grid, row, tr("Pixel Format"), stream.pixelFormat);
if (stream.refFrames > 0)
addInfoRow(grid, row, tr("Ref Frames"),
QString::number(stream.refFrames));
} else if (stream.type == "Audio") {
if (!stream.language.isEmpty())
addInfoRow(grid, row, tr("Language"), stream.language);
if (!stream.codecTag.isEmpty())
addInfoRow(grid, row, tr("Codec Tag"), stream.codecTag);
if (!stream.profile.isEmpty())
addInfoRow(grid, row, tr("Profile"), stream.profile);
if (!stream.channelLayout.isEmpty())
addInfoRow(grid, row, tr("Layout"), stream.channelLayout);
if (stream.channels > 0)
addInfoRow(grid, row, tr("Channels"),
QString("%1 ch").arg(stream.channels));
if (stream.bitRate > 0)
addInfoRow(grid, row, tr("Bitrate"),
QString("%1 kbps").arg(stream.bitRate / 1000));
if (stream.sampleRate > 0)
addInfoRow(grid, row, tr("Sample Rate"),
QString("%1 Hz").arg(stream.sampleRate));
if (stream.isDefault)
addInfoRow(grid, row, tr("Default"), tr("Yes"));
} else if (stream.type == "Subtitle") {
QString displayLang = stream.displayLanguage.isEmpty()
? stream.language
: stream.displayLanguage;
if (!displayLang.isEmpty())
addInfoRow(grid, row, tr("Language"), displayLang);
if (stream.index >= 0)
addInfoRow(grid, row, tr("Index"), QString::number(stream.index));
if (stream.isDefault)
addInfoRow(grid, row, tr("Default"), tr("Yes"));
if (stream.isForced)
addInfoRow(grid, row, tr("Forced"), tr("Yes"));
if (stream.isHearingImpaired)
addInfoRow(grid, row, tr("Hearing Impaired"), tr("Yes"));
if (stream.isExternal)
addInfoRow(grid, row, tr("External"), tr("Yes"));
if (stream.isTextSubtitleStream)
addInfoRow(grid, row, tr("Text Stream"), tr("Yes"));
if (stream.supportsExternalStream)
addInfoRow(grid, row, tr("Ext. Stream Supported"), tr("Yes"));
if (stream.isExternalUrl)
addInfoRow(grid, row, tr("External URL"), tr("Yes"));
if (!stream.deliveryMethod.isEmpty())
addInfoRow(grid, row, tr("Delivery Method"), stream.deliveryMethod);
if (!stream.protocol.isEmpty() && stream.protocol != "None")
addInfoRow(grid, row, tr("Protocol"), stream.protocol);
if (!stream.extendedVideoType.isEmpty() &&
stream.extendedVideoType != "None")
addInfoRow(grid, row, tr("Ext. Video Type"), stream.extendedVideoType);
if (!stream.extendedVideoSubType.isEmpty() &&
stream.extendedVideoSubType != "None")
addInfoRow(grid, row, tr("Ext. Video SubType"),
stream.extendedVideoSubType);
if (stream.attachmentSize > 0)
addInfoRow(grid, row, tr("Size"), formatSize(stream.attachmentSize));
QFileInfo fi(stream.path);
if (!stream.path.isEmpty())
addInfoRow(grid, row, tr("Path"), fi.completeBaseName());
if (!stream.deliveryUrl.isEmpty())
addInfoRow(grid, row, tr("Delivery URL"), stream.deliveryUrl);
}
layout->addLayout(grid);
layout->addStretch();
m_mediaInfoGallery->addWidget(card);
}
m_mediaInfoGallery->adjustHeightToContent();
}
@@ -1,67 +0,0 @@
#ifndef DETAILBOTTOMINFOWIDGET_H
#define DETAILBOTTOMINFOWIDGET_H
#include <QWidget>
#include "models/media/mediaitem.h"
class FlowLayout;
class HorizontalWidgetGallery;
class QLabel;
class QVBoxLayout;
class QGridLayout;
class QEvent;
class QResizeEvent;
class QShowEvent;
class DetailBottomInfoWidget : public QWidget {
Q_OBJECT
public:
explicit DetailBottomInfoWidget(QWidget* parent = nullptr);
void setInfo(const MediaItem& item, const QList<MediaSourceInfo>& sources);
void clear();
signals:
void filterClicked(const QString& filterType, const QString& filterValue);
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void showEvent(QShowEvent* event) override;
private:
void clearLayout(QLayout* layout);
QString formatSize(long long bytes);
void addInfoRow(QGridLayout* layout, int& row, const QString& key, const QString& value);
QWidget* wrapMaxWidth(QWidget* child, int maxW);
int resolveFlowLayoutWidth(QWidget* widget) const;
void updateFlowLayoutHeight(QWidget* widget, FlowLayout* layout);
void updateFlowLayoutHeights();
void scheduleFlowLayoutHeightUpdate();
QLabel* m_tagsBottomTitle;
QWidget* m_tagsBottomWidget;
QWidget* m_tagsBottomWrapper;
FlowLayout* m_tagsBottomLayout;
QLabel* m_studiosTitle;
QWidget* m_studiosWidget;
QWidget* m_studiosWrapper;
FlowLayout* m_studiosLayout;
QLabel* m_externalLinksTitle;
QWidget* m_externalLinksWidget;
QWidget* m_externalLinksWrapper;
FlowLayout* m_externalLinksLayout;
QLabel* m_mediaInfoTitle;
QWidget* m_fileInfoWidget;
QLabel* m_filePathLabel;
QLabel* m_fileMetaLabel;
HorizontalWidgetGallery* m_mediaInfoGallery;
bool m_flowLayoutHeightUpdatePending = false;
};
#endif
@@ -1,91 +0,0 @@
#include "detailcontentwidget.h"
#include <QPainter>
#include <QStyleOption>
#include <QLinearGradient>
DetailContentWidget::DetailContentWidget(QWidget* parent)
: QWidget(parent),
m_cachedWidth(-1),
m_baseColor(QColor("#FFFFFF")),
m_gradientColor(QColor(249, 250, 251))
{
setAttribute(Qt::WA_StyledBackground, true);
}
void DetailContentWidget::setBackdrop(const QPixmap& pix) {
m_backdrop = pix;
m_cachedWidth = -1;
update();
}
QColor DetailContentWidget::baseColor() const {
return m_baseColor;
}
void DetailContentWidget::setBaseColor(const QColor& color) {
if (m_baseColor != color) {
m_baseColor = color;
update();
}
}
QColor DetailContentWidget::gradientColor() const {
return m_gradientColor;
}
void DetailContentWidget::setGradientColor(const QColor& color) {
if (m_gradientColor != color) {
m_gradientColor = color;
update();
}
}
void DetailContentWidget::paintEvent(QPaintEvent* event) {
Q_UNUSED(event);
QStyleOption opt;
opt.initFrom(this);
QPainter painter(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::SmoothPixmapTransform);
painter.fillRect(rect(), m_baseColor);
if (m_backdrop.isNull()) return;
int w = width();
int imgW = m_backdrop.width();
int imgH = m_backdrop.height();
if (imgW <= 0) return;
int targetHeight = w * imgH / imgW;
if (w != m_cachedWidth) {
m_scaledBackdrop = m_backdrop.scaled(w, targetHeight, Qt::KeepAspectRatio, Qt::SmoothTransformation);
m_cachedWidth = w;
}
painter.drawPixmap(0, 0, m_scaledBackdrop);
QLinearGradient gradient(0, 0, 0, targetHeight);
int r = m_gradientColor.red();
int g = m_gradientColor.green();
int b = m_gradientColor.blue();
gradient.setColorAt(0.0, QColor(r, g, b, 0));
gradient.setColorAt(0.15, QColor(r, g, b, 150));
gradient.setColorAt(0.4, QColor(r, g, b, 180));
gradient.setColorAt(1.0, QColor(r, g, b, 255));
painter.fillRect(0, 0, w, targetHeight, gradient);
}
@@ -1,39 +0,0 @@
#ifndef DETAILCONTENTWIDGET_H
#define DETAILCONTENTWIDGET_H
#include <QWidget>
#include <QPixmap>
#include <QColor>
class DetailContentWidget : public QWidget {
Q_OBJECT
Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor)
Q_PROPERTY(QColor gradientColor READ gradientColor WRITE setGradientColor)
public:
explicit DetailContentWidget(QWidget* parent = nullptr);
void setBackdrop(const QPixmap& pix);
QColor baseColor() const;
void setBaseColor(const QColor& color);
QColor gradientColor() const;
void setGradientColor(const QColor& color);
protected:
void paintEvent(QPaintEvent* event) override;
private:
QPixmap m_backdrop;
QPixmap m_scaledBackdrop;
int m_cachedWidth;
QColor m_baseColor;
QColor m_gradientColor;
};
#endif
@@ -1,27 +0,0 @@
#include "detailtagbutton.h"
#include <QSizePolicy>
DetailTagButton::DetailTagButton(const QString &text, QWidget *parent)
: QPushButton(text, parent) {
setObjectName("detail-genre-tag");
setAttribute(Qt::WA_LayoutUsesWidgetRect, true);
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setFlat(true);
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed);
setFixedHeight(kTagHeight);
ensurePolished();
}
QSize DetailTagButton::sizeHint() const {
QSize size = QPushButton::sizeHint();
size.setHeight(kTagHeight);
return size;
}
QSize DetailTagButton::minimumSizeHint() const {
QSize size = QPushButton::minimumSizeHint();
size.setHeight(kTagHeight);
return size;
}
-19
View File
@@ -1,19 +0,0 @@
#ifndef DETAILTAGBUTTON_H
#define DETAILTAGBUTTON_H
#include <QPushButton>
#include <QSize>
#include <QString>
class DetailTagButton : public QPushButton {
public:
explicit DetailTagButton(const QString &text, QWidget *parent = nullptr);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
private:
static constexpr int kTagHeight = 28;
};
#endif
-225
View File
@@ -1,225 +0,0 @@
#include "flowlayout.h"
#include <QWidget>
FlowLayout::FlowLayout(QWidget *parent, int margin, int hSpacing, int vSpacing)
: QLayout(parent), m_hSpace(hSpacing), m_vSpace(vSpacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::FlowLayout(int margin, int hSpacing, int vSpacing)
: m_hSpace(hSpacing), m_vSpace(vSpacing)
{
setContentsMargins(margin, margin, margin, margin);
}
FlowLayout::~FlowLayout()
{
while (!itemList.isEmpty())
delete itemList.takeFirst();
}
void FlowLayout::addItem(QLayoutItem *item)
{
itemList.append(item);
invalidate();
}
int FlowLayout::horizontalSpacing() const
{
if (m_hSpace >= 0) {
return m_hSpace;
} else {
return smartSpacing(QStyle::PM_LayoutHorizontalSpacing);
}
}
int FlowLayout::verticalSpacing() const
{
if (m_vSpace >= 0) {
return m_vSpace;
} else {
return smartSpacing(QStyle::PM_LayoutVerticalSpacing);
}
}
int FlowLayout::count() const
{
return itemList.size();
}
QLayoutItem *FlowLayout::itemAt(int index) const
{
return itemList.value(index);
}
QLayoutItem *FlowLayout::takeAt(int index)
{
if (index >= 0 && index < itemList.size()) {
QLayoutItem *item = itemList.takeAt(index);
invalidate();
return item;
}
return nullptr;
}
Qt::Orientations FlowLayout::expandingDirections() const
{
return { };
}
bool FlowLayout::hasHeightForWidth() const
{
return true;
}
int FlowLayout::heightForWidth(int width) const
{
int height = doLayout(QRect(0, 0, width, 0), true);
return height;
}
void FlowLayout::invalidate()
{
QLayout::invalidate();
updateParentGeometry();
}
void FlowLayout::setGeometry(const QRect &rect)
{
QLayout::setGeometry(rect);
doLayout(rect, false);
}
void FlowLayout::setFirstLineTrailingSpacing(int spacing)
{
const int safeSpacing = qMax(0, spacing);
if (m_firstLineTrailingSpacing == safeSpacing) {
return;
}
m_firstLineTrailingSpacing = safeSpacing;
invalidate();
}
int FlowLayout::firstLineTrailingSpacing() const
{
return m_firstLineTrailingSpacing;
}
QSize FlowLayout::sizeHint() const
{
QSize size = minimumSize();
int width = -1;
if (const auto *parentWidget = qobject_cast<const QWidget *>(parent())) {
width = parentWidget->contentsRect().width();
const QWidget *p = parentWidget->parentWidget();
while (width <= 0 && p) {
width = p->contentsRect().width();
p = p->parentWidget();
}
} else if (geometry().isValid()) {
width = geometry().width();
}
if (width > 0) {
size.setWidth(qMax(size.width(), width));
size.setHeight(heightForWidth(width));
}
return size;
}
QSize FlowLayout::minimumSize() const
{
QSize size;
for (const QLayoutItem *item : itemList) {
if (const QWidget *wid = item->widget(); wid && wid->isHidden()) {
continue;
}
size = size.expandedTo(item->minimumSize());
}
const QMargins margins = contentsMargins();
size += QSize(margins.left() + margins.right(), margins.top() + margins.bottom());
return size;
}
int FlowLayout::doLayout(const QRect &rect, bool testOnly) const
{
int left, top, right, bottom;
getContentsMargins(&left, &top, &right, &bottom);
QRect effectiveRect = rect.adjusted(+left, +top, -right, -bottom);
int x = effectiveRect.x();
int y = effectiveRect.y();
int lineHeight = 0;
for (QLayoutItem *item : itemList) {
const QWidget *wid = item->widget();
if (wid && wid->isHidden()) {
continue;
}
int spaceX = horizontalSpacing();
if (spaceX == -1) {
spaceX = wid ? wid->style()->layoutSpacing(
QSizePolicy::PushButton,
QSizePolicy::PushButton,
Qt::Horizontal)
: 0;
}
int spaceY = verticalSpacing();
if (spaceY == -1) {
spaceY = wid ? wid->style()->layoutSpacing(
QSizePolicy::PushButton,
QSizePolicy::PushButton,
Qt::Vertical)
: 0;
}
const int lineRight = (y == effectiveRect.y())
? qMax(effectiveRect.x(),
effectiveRect.right() - m_firstLineTrailingSpacing)
: effectiveRect.right();
int nextX = x + item->sizeHint().width() + spaceX;
if (nextX - spaceX > lineRight && lineHeight > 0) {
x = effectiveRect.x();
y = y + lineHeight + spaceY;
nextX = x + item->sizeHint().width() + spaceX;
lineHeight = 0;
}
if (!testOnly)
item->setGeometry(QRect(QPoint(x, y), item->sizeHint()));
x = nextX;
lineHeight = qMax(lineHeight, item->sizeHint().height());
}
return y + lineHeight - rect.y() + bottom;
}
int FlowLayout::smartSpacing(QStyle::PixelMetric pm) const
{
QObject *parent = this->parent();
if (!parent) {
return -1;
} else if (parent->isWidgetType()) {
QWidget *pw = static_cast<QWidget *>(parent);
return pw->style()->pixelMetric(pm, nullptr, pw);
} else {
return static_cast<QLayout *>(parent)->spacing();
}
}
void FlowLayout::updateParentGeometry() const
{
auto *parentWidget = qobject_cast<QWidget *>(parent());
if (!parentWidget) {
return;
}
parentWidget->updateGeometry();
if (QWidget *outerWidget = parentWidget->parentWidget()) {
outerWidget->updateGeometry();
}
}
-43
View File
@@ -1,43 +0,0 @@
#ifndef FLOWLAYOUT_H
#define FLOWLAYOUT_H
#include <QLayout>
#include <QRect>
#include <QStyle>
#include <QList>
class FlowLayout : public QLayout
{
public:
explicit FlowLayout(QWidget *parent, int margin = -1, int hSpacing = -1, int vSpacing = -1);
explicit FlowLayout(int margin = -1, int hSpacing = -1, int vSpacing = -1);
~FlowLayout();
void addItem(QLayoutItem *item) override;
int horizontalSpacing() const;
int verticalSpacing() const;
Qt::Orientations expandingDirections() const override;
bool hasHeightForWidth() const override;
int heightForWidth(int) const override;
void invalidate() override;
int count() const override;
QLayoutItem *itemAt(int index) const override;
QSize minimumSize() const override;
void setGeometry(const QRect &rect) override;
void setFirstLineTrailingSpacing(int spacing);
int firstLineTrailingSpacing() const;
QSize sizeHint() const override;
QLayoutItem *takeAt(int index) override;
private:
int doLayout(const QRect &rect, bool testOnly) const;
int smartSpacing(QStyle::PixelMetric pm) const;
void updateParentGeometry() const;
QList<QLayoutItem *> itemList;
int m_hSpace;
int m_vSpace;
int m_firstLineTrailingSpacing = 0;
};
#endif
@@ -1,27 +0,0 @@
#include "galleryoverlayfilter.h"
#include <QListView>
#include <QPushButton>
#include <QEvent>
#include <QResizeEvent>
#include <QScrollBar>
GalleryOverlayFilter::GalleryOverlayFilter(QListView *list, QPushButton *left, QPushButton *right, QObject *parent)
: QObject(parent), m_list(list), m_left(left), m_right(right) {}
bool GalleryOverlayFilter::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::Resize) {
int btnWidth = 40;
int btnHeight = 80;
int y = m_list->y() + (m_list->height() - btnHeight) / 2 - 20;
if (y < 0) y = 0;
m_left->setGeometry(m_list->x() + 10, y, btnWidth, btnHeight);
m_right->setGeometry(m_list->x() + m_list->width() - btnWidth - 10, y, btnWidth, btnHeight);
}
return QObject::eventFilter(obj, event);
}
@@ -1,19 +0,0 @@
#include <QObject>
class QListView;
class QPushButton;
class GalleryOverlayFilter : public QObject {
public:
GalleryOverlayFilter(QListView* list, QPushButton* left, QPushButton* right, QObject* parent = nullptr);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
private:
QListView* m_list;
QPushButton* m_left;
QPushButton* m_right;
};
@@ -1,515 +0,0 @@
#include "horizontallistviewgallery.h"
#include "shimmerwidget.h"
#include "../utils/textwraputils.h"
#include "../views/media/medialistmodel.h"
#include <QListView>
#include <QVBoxLayout>
#include <QPushButton>
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QEvent>
#include <QWheelEvent>
#include <QCursor>
#include <QScroller>
#include <QScrollerProperties>
#include <QStyleOptionViewItem>
#include <QTimer>
#include <QDebug>
#include <QSet>
#include <algorithm>
HorizontalListViewGallery::HorizontalListViewGallery(QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core), m_hScrollAnim(nullptr), m_hScrollTarget(0), m_cardStyle(MediaCardDelegate::Poster)
{
setObjectName("horizontal-listview-gallery");
setAttribute(Qt::WA_StyledBackground, true);
setMouseTracking(true);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_listView = new QListView(this);
m_listView->setViewMode(QListView::IconMode);
m_listView->setFlow(QListView::LeftToRight);
m_listView->setWrapping(false);
m_listView->setSpacing(0);
m_listView->setMovement(QListView::Static);
m_listView->setResizeMode(QListView::Adjust);
m_listView->setUniformItemSizes(true);
m_listView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setMouseTracking(true);
m_listView->viewport()->setAttribute(Qt::WA_Hover);
m_listView->setStyleSheet("QListView { background: transparent; outline: none; border: none; }");
m_listView->setSelectionMode(QAbstractItemView::NoSelection);
m_listView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
QScroller::grabGesture(m_listView->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_listView->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_hScrollAnim = new QPropertyAnimation(m_listView->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
mainLayout->addWidget(m_listView);
m_shimmer = new ShimmerWidget(this);
m_shimmer->hide();
m_listModel = new MediaListModel(400, m_core, this);
m_listDelegate = new MediaCardDelegate(MediaCardDelegate::Poster, this);
m_listView->setModel(m_listModel);
m_listView->setItemDelegate(m_listDelegate);
connect(m_listDelegate, &MediaCardDelegate::playRequested, this, &HorizontalListViewGallery::playRequested);
connect(m_listDelegate, &MediaCardDelegate::favoriteRequested, this, &HorizontalListViewGallery::favoriteRequested);
connect(m_listDelegate, &MediaCardDelegate::moreMenuRequested, this, &HorizontalListViewGallery::moreMenuRequested);
connect(m_listView, &QListView::clicked, this, [this](const QModelIndex& index) {
if (m_listModel) {
Q_EMIT itemClicked(m_listModel->getItem(index));
}
});
m_btnLeft = new QPushButton("❮", this);
m_btnRight = new QPushButton("❯", this);
QString btnStyle = "QPushButton { background-color: rgba(0,0,0,120); color: white; font-size: 20px; border: none; border-radius: 8px; }"
"QPushButton:hover { background-color: rgba(60,60,60,200); }";
m_btnLeft->setStyleSheet(btnStyle);
m_btnRight->setStyleSheet(btnStyle);
m_btnLeft->setFixedSize(40, 60);
m_btnRight->setFixedSize(40, 60);
m_btnLeft->setCursor(Qt::PointingHandCursor);
m_btnRight->setCursor(Qt::PointingHandCursor);
m_btnLeft->setFocusPolicy(Qt::NoFocus);
m_btnRight->setFocusPolicy(Qt::NoFocus);
m_btnLeft->hide();
m_btnRight->hide();
auto scrollAction = [this](int directionMultiplier) {
QScrollBar* bar = m_listView->horizontalScrollBar();
int step = this->width() / 2;
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
};
connect(m_btnLeft, &QPushButton::clicked, [scrollAction]() { scrollAction(-1); });
connect(m_btnRight, &QPushButton::clicked, [scrollAction]() { scrollAction(1); });
connect(m_listView->horizontalScrollBar(), &QScrollBar::valueChanged, this,
[this]() {
updateButtonsVisibility();
updateVisibleImagePriority();
});
connect(m_listView->horizontalScrollBar(), &QScrollBar::rangeChanged, this, &HorizontalListViewGallery::updateButtonsVisibility);
m_listView->viewport()->installEventFilter(this);
this->installEventFilter(this);
}
HorizontalListViewGallery::~HorizontalListViewGallery()
{
if (m_hScrollAnim) {
m_hScrollAnim->stop();
}
if (m_listView && m_listView->viewport()) {
m_listView->viewport()->removeEventFilter(this);
QScroller::ungrabGesture(m_listView->viewport());
}
removeEventFilter(this);
}
void HorizontalListViewGallery::setItems(const QList<MediaItem>& items)
{
if (m_listModel) {
m_listModel->setItems(items);
QTimer::singleShot(0, this,
[this]() { updateVisibleImagePriority(); });
}
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void HorizontalListViewGallery::updateItem(const MediaItem& item)
{
if (m_listModel) {
m_listModel->updateItem(item);
}
}
void HorizontalListViewGallery::prependOrUpdateItem(const MediaItem& item,
int maxItems)
{
if (m_listModel) {
m_listModel->prependOrUpdateItem(item, maxItems);
QTimer::singleShot(0, this, [this]() {
updateVisibleImagePriority();
updateButtonsVisibility();
});
}
}
void HorizontalListViewGallery::removeItem(const QString& itemId)
{
if (m_listModel) {
const int previousCount = m_listModel->rowCount();
m_listModel->removeItem(itemId);
if (m_listView && m_listModel->rowCount() < previousCount) {
m_listView->doItemsLayout();
m_listView->updateGeometry();
m_listView->update();
m_listView->viewport()->update();
QTimer::singleShot(0, this, [this]() {
if (!m_listView) {
return;
}
m_listView->doItemsLayout();
m_listView->viewport()->update();
updateButtonsVisibility();
});
}
}
}
int HorizontalListViewGallery::itemCount() const
{
return m_listModel ? m_listModel->rowCount() : 0;
}
void HorizontalListViewGallery::clearImageCache()
{
if (m_listModel) {
m_listModel->clearImageCache();
}
}
void HorizontalListViewGallery::setCardStyle(MediaCardDelegate::CardStyle style)
{
bool styleChanged = (m_cardStyle != style);
m_cardStyle = style;
if (m_listDelegate) {
m_listDelegate->setStyle(style);
if (style == MediaCardDelegate::LibraryTile) {
int imgHeight = 160;
int imgWidth = qRound(imgHeight * 16.0 / 9.0);
int cardWidth = imgWidth + 16;
const int hoverExpandH = qRound(imgHeight * 0.035);
const int cardHeight =
8 + imgHeight + hoverExpandH + 4 + 20 + 1 + 18 + 8;
m_listDelegate->setTileSize(QSize(cardWidth, cardHeight));
} else if (style == MediaCardDelegate::Poster) {
m_listDelegate->setTileSize(QSize(160, 270));
}
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
if (m_listModel) {
m_listModel->setPreferThumb(style == MediaCardDelegate::LibraryTile || style == MediaCardDelegate::EpisodeList);
if (styleChanged) {
m_listModel->clearImageCache();
}
}
updateButtonPositions();
}
void HorizontalListViewGallery::setTileSize(const QSize &size)
{
if (m_listDelegate) {
m_listDelegate->setTileSize(size);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
updateButtonPositions();
}
void HorizontalListViewGallery::setTextPixelSizes(int titlePx, int subTitlePx)
{
if (m_listDelegate) {
m_listDelegate->setTextPixelSizes(titlePx, subTitlePx);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::setContentPadding(int padding)
{
if (m_listDelegate) {
m_listDelegate->setContentPadding(padding);
m_listView->doItemsLayout();
m_listView->viewport()->update();
}
updateButtonPositions();
}
void HorizontalListViewGallery::setHoverControls(
MediaCardDelegate::HoverControls controls)
{
if (m_listDelegate) {
m_listDelegate->setHoverControls(controls);
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::scrollToItemId(const QString &itemId)
{
const QString targetItemId = itemId.trimmed();
if (targetItemId.isEmpty() || !m_listView || !m_listModel) {
return;
}
QTimer::singleShot(0, this, [this, targetItemId]() {
if (!m_listView || !m_listModel) {
return;
}
m_listView->doItemsLayout();
const int rowCount = m_listModel->rowCount();
for (int row = 0; row < rowCount; ++row) {
const QModelIndex index = m_listModel->index(row, 0);
const MediaItem item =
index.data(MediaListModel::ItemDataRole).value<MediaItem>();
if (item.id != targetItemId) {
continue;
}
qDebug() << "[HorizontalListViewGallery] Scroll to item"
<< "| itemId=" << targetItemId
<< "| row=" << row;
m_listView->scrollTo(index, QAbstractItemView::PositionAtCenter);
return;
}
qDebug() << "[HorizontalListViewGallery] Scroll target not found"
<< "| itemId=" << targetItemId
<< "| rowCount=" << rowCount;
});
}
void HorizontalListViewGallery::setHighlightedItemId(const QString &id)
{
if (m_listDelegate) {
m_listDelegate->setHighlightedItemId(id);
m_listView->viewport()->update();
}
}
void HorizontalListViewGallery::setLoading(bool loading)
{
if (!m_shimmer) {
return;
}
if (loading) {
QStyleOptionViewItem opt;
const QSize cardSize = m_listDelegate->sizeHint(opt, QModelIndex());
m_shimmer->setCardSize(cardSize);
m_shimmer->setShowSubtitle(
m_cardStyle == MediaCardDelegate::Poster ||
m_cardStyle == MediaCardDelegate::Cast);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
m_shimmer->startAnimation();
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void HorizontalListViewGallery::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
updateButtonPositions();
updateButtonsVisibility();
updateVisibleImagePriority();
if (m_shimmer && m_shimmer->isVisible()) {
m_shimmer->setGeometry(m_listView->geometry());
}
}
void HorizontalListViewGallery::updateButtonPositions()
{
int currentWidth = this->width();
QStyleOptionViewItem dummyOption;
QModelIndex dummyIndex;
QSize itemSize = m_listDelegate->sizeHint(dummyOption, dummyIndex);
int padding = m_listDelegate ? m_listDelegate->contentPadding() : 8;
int imgWidth = itemSize.width() - padding * 2;
int imgHeight = 0;
if (m_cardStyle == MediaCardDelegate::LibraryTile) {
imgHeight = qRound(imgWidth * 9.0 / 16.0);
} else {
imgHeight = qRound(imgWidth * 1.5);
}
int imageCenterY = padding + (imgHeight / 2);
int btnY = imageCenterY - (m_btnLeft->height() / 2);
m_btnLeft->move(10, btnY);
m_btnRight->move(currentWidth - m_btnRight->width() - 10, btnY);
m_btnLeft->raise();
m_btnRight->raise();
}
bool HorizontalListViewGallery::eventFilter(QObject* obj, QEvent* event)
{
if (event->type() == QEvent::Enter || event->type() == QEvent::MouseMove) {
updateButtonsVisibility();
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
}
} else if (obj == m_listView->viewport() && event->type() == QEvent::ToolTip) {
return TextWrapUtils::showWrappedMediaItemToolTip(m_listView, event);
} else if (event->type() == QEvent::Wheel) {
QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
if (qAbs(wheelEvent->angleDelta().x()) > qAbs(wheelEvent->angleDelta().y())) {
if (!m_listView || !m_hScrollAnim || !m_hScrollAnim->targetObject()) {
wheelEvent->ignore();
return false;
}
QScrollBar* hBar = m_listView->horizontalScrollBar();
if (hBar) {
int currentVal = hBar->value();
if (m_hScrollAnim->state() == QAbstractAnimation::Running) {
currentVal = m_hScrollTarget;
}
int step = wheelEvent->angleDelta().x();
int newTarget = currentVal - step;
newTarget = qBound(hBar->minimum(), newTarget, hBar->maximum());
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
return true;
} else {
wheelEvent->ignore();
return false;
}
}
return QWidget::eventFilter(obj, event);
}
void HorizontalListViewGallery::updateButtonsVisibility()
{
QPoint globalPos = QCursor::pos();
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
return;
}
int currentWidth = this->width();
QScrollBar* bar = m_listView->horizontalScrollBar();
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
}
void HorizontalListViewGallery::updateVisibleImagePriority()
{
if (!m_listView || !m_listModel) {
return;
}
QWidget* viewport = m_listView->viewport();
if (!viewport) {
return;
}
QStyleOptionViewItem option;
const QSize cellSize = m_listDelegate->sizeHint(option, QModelIndex());
const int stepX = qMax(1, cellSize.width() / 2);
const int stepY = qMax(1, cellSize.height() / 2);
QSet<int> rowSet;
const QRect rect = viewport->rect();
for (int y = rect.top(); y <= rect.bottom(); y += stepY) {
for (int x = rect.left(); x <= rect.right(); x += stepX) {
const QModelIndex idx = m_listView->indexAt(QPoint(x, y));
if (idx.isValid()) {
rowSet.insert(idx.row());
}
}
}
QList<int> rows = rowSet.values();
std::sort(rows.begin(), rows.end());
m_listModel->setPriorityRows(rows);
}
@@ -1,97 +0,0 @@
#ifndef HORIZONTALLISTVIEWGALLERY_H
#define HORIZONTALLISTVIEWGALLERY_H
#include <QWidget>
#include <QPoint>
#include <QList>
#include <QSize>
#include <models/media/mediaitem.h>
class ShimmerWidget;
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class QListView;
class QPushButton;
class QPropertyAnimation;
class MediaListModel;
class HorizontalListViewGallery : public QWidget
{
Q_OBJECT
public:
explicit HorizontalListViewGallery(QEmbyCore* core, QWidget* parent = nullptr);
~HorizontalListViewGallery() override;
QListView* listView() const { return m_listView; }
void setItems(const QList<MediaItem>& items);
void updateItem(const MediaItem& item);
void prependOrUpdateItem(const MediaItem& item, int maxItems = 0);
void removeItem(const QString& itemId);
int itemCount() const;
void clearImageCache();
void setCardStyle(MediaCardDelegate::CardStyle style);
void setTileSize(const QSize &size);
void setTextPixelSizes(int titlePx, int subTitlePx);
void setContentPadding(int padding);
void setHoverControls(MediaCardDelegate::HoverControls controls);
void scrollToItemId(const QString &itemId);
void setHighlightedItemId(const QString &id);
void setLoading(bool loading);
QSize minimumSizeHint() const override {
return QSize(1, QWidget::minimumSizeHint().height());
}
Q_SIGNALS:
void itemClicked(const MediaItem& item);
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& globalPos);
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void updateButtonsVisibility();
void updateButtonPositions();
void updateVisibleImagePriority();
QEmbyCore* m_core;
QListView* m_listView;
QPushButton* m_btnLeft;
QPushButton* m_btnRight;
MediaListModel* m_listModel;
MediaCardDelegate* m_listDelegate;
QPropertyAnimation* m_hScrollAnim;
int m_hScrollTarget;
MediaCardDelegate::CardStyle m_cardStyle;
ShimmerWidget* m_shimmer = nullptr;
};
#endif
@@ -1,255 +0,0 @@
#include "horizontalwidgetgallery.h"
#include <QScrollArea>
#include <QHBoxLayout>
#include <QPushButton>
#include <QScrollBar>
#include <QPropertyAnimation>
#include <QEvent>
#include <QWheelEvent>
#include <QTimer>
#include <QCursor>
#include <QScroller>
#include <QScrollerProperties>
HorizontalWidgetGallery::HorizontalWidgetGallery(QWidget* parent)
: QWidget(parent), m_hScrollAnim(nullptr), m_hScrollTarget(0)
{
setObjectName("horizontal-widget-gallery");
setAttribute(Qt::WA_StyledBackground, true);
setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
this->setMinimumWidth(1);
this->setMouseTracking(true);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(0);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setMinimumWidth(1);
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; }");
m_scrollArea->viewport()->setObjectName("gallery-viewport");
m_scrollArea->viewport()->setStyleSheet("#gallery-viewport { background: transparent; }");
m_scrollArea->viewport()->setMouseTracking(true);
QScroller::grabGesture(m_scrollArea->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_scrollArea->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_hScrollAnim = new QPropertyAnimation(m_scrollArea->horizontalScrollBar(), "value", this);
m_hScrollAnim->setEasingCurve(QEasingCurve::OutCubic);
m_hScrollAnim->setDuration(450);
m_contentWidget = new QWidget(m_scrollArea);
m_contentWidget->setObjectName("gallery-content-widget");
m_contentWidget->setStyleSheet("#gallery-content-widget { background: transparent; }");
m_contentWidget->setMouseTracking(true);
m_contentLayout = new QHBoxLayout(m_contentWidget);
m_contentLayout->setSizeConstraint(QLayout::SetMinimumSize);
m_contentLayout->setContentsMargins(0, 0, 100, 0);
m_contentLayout->setSpacing(16);
m_contentLayout->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_scrollArea->setWidget(m_contentWidget);
mainLayout->addWidget(m_scrollArea);
m_btnLeft = new QPushButton("❮", this);
m_btnRight = new QPushButton("❯", this);
QString btnStyle = "QPushButton { background-color: rgba(0,0,0,120); color: white; font-size: 20px; border: none; border-radius: 8px; }"
"QPushButton:hover { background-color: rgba(60,60,60,200); }";
m_btnLeft->setStyleSheet(btnStyle);
m_btnRight->setStyleSheet(btnStyle);
m_btnLeft->setFixedSize(40, 60);
m_btnRight->setFixedSize(40, 60);
m_btnLeft->setCursor(Qt::PointingHandCursor);
m_btnRight->setCursor(Qt::PointingHandCursor);
m_btnLeft->setFocusPolicy(Qt::NoFocus);
m_btnRight->setFocusPolicy(Qt::NoFocus);
m_btnLeft->hide();
m_btnRight->hide();
auto scrollAction = [this](int directionMultiplier) {
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
int step = this->width() / 2;
int targetValue = bar->value() + directionMultiplier * step;
targetValue = qBound(0, targetValue, bar->maximum());
auto* anim = new QPropertyAnimation(bar, "value", this);
anim->setDuration(400);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->setStartValue(bar->value());
anim->setEndValue(targetValue);
anim->start(QAbstractAnimation::DeleteWhenStopped);
};
connect(m_btnLeft, &QPushButton::clicked, [scrollAction]() { scrollAction(-1); });
connect(m_btnRight, &QPushButton::clicked, [scrollAction]() { scrollAction(1); });
connect(m_scrollArea->horizontalScrollBar(), &QScrollBar::valueChanged, this, &HorizontalWidgetGallery::updateButtonsVisibility);
connect(m_scrollArea->horizontalScrollBar(), &QScrollBar::rangeChanged, this, &HorizontalWidgetGallery::updateButtonsVisibility);
m_scrollArea->viewport()->installEventFilter(this);
this->installEventFilter(this);
}
void HorizontalWidgetGallery::addWidget(QWidget* widget)
{
if (!widget) return;
m_contentLayout->addWidget(widget);
}
void HorizontalWidgetGallery::clear()
{
QLayoutItem *child;
while ((child = m_contentLayout->takeAt(0)) != nullptr) {
if (child->widget()) {
child->widget()->hide();
child->widget()->deleteLater();
}
delete child;
}
}
void HorizontalWidgetGallery::setSpacing(int spacing)
{
m_contentLayout->setSpacing(spacing);
}
void HorizontalWidgetGallery::updateButtonPositions()
{
int currentWidth = this->width();
int btnY = (this->height() - m_btnLeft->height()) / 2;
m_btnLeft->move(10, btnY);
m_btnRight->move(currentWidth - m_btnRight->width() - 10, btnY);
m_btnLeft->raise();
m_btnRight->raise();
}
void HorizontalWidgetGallery::applyContentHeight()
{
m_contentWidget->adjustSize();
const int contentHeight = m_contentWidget->sizeHint().height();
const int targetHeight = qMax(0, contentHeight + 16);
if (height() != targetHeight ||
minimumHeight() != targetHeight ||
maximumHeight() != targetHeight) {
setFixedHeight(targetHeight);
}
updateButtonPositions();
updateGeometry();
}
void HorizontalWidgetGallery::adjustHeightToContent()
{
applyContentHeight();
QTimer::singleShot(0, this, [this]() { applyContentHeight(); });
}
void HorizontalWidgetGallery::resizeEvent(QResizeEvent* event)
{
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
bool wasAtEnd = (bar->maximum() > 0 && bar->value() >= bar->maximum() - 5);
QWidget::resizeEvent(event);
updateButtonPositions();
updateButtonsVisibility();
if (wasAtEnd) {
QTimer::singleShot(0, this, [bar]() {
bar->setValue(bar->maximum());
});
}
}
bool HorizontalWidgetGallery::eventFilter(QObject* obj, QEvent* event)
{
if (event->type() == QEvent::Enter || event->type() == QEvent::MouseMove) {
updateButtonsVisibility();
} else if (event->type() == QEvent::Leave) {
QPoint globalPos = QCursor::pos();
if (!this->rect().contains(this->mapFromGlobal(globalPos))) {
m_btnLeft->hide();
m_btnRight->hide();
}
} else if (event->type() == QEvent::Wheel) {
QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
if (qAbs(wheelEvent->angleDelta().x()) > qAbs(wheelEvent->angleDelta().y())) {
QScrollBar* hBar = m_scrollArea->horizontalScrollBar();
if (hBar) {
int currentVal = hBar->value();
if (m_hScrollAnim->state() == QAbstractAnimation::Running) {
currentVal = m_hScrollTarget;
}
int step = wheelEvent->angleDelta().x();
int newTarget = currentVal - step;
newTarget = qBound(hBar->minimum(), newTarget, hBar->maximum());
if (newTarget != hBar->value()) {
m_hScrollTarget = newTarget;
m_hScrollAnim->stop();
m_hScrollAnim->setStartValue(hBar->value());
m_hScrollAnim->setEndValue(m_hScrollTarget);
m_hScrollAnim->start();
}
}
return true;
} else {
wheelEvent->ignore();
return false;
}
}
return QWidget::eventFilter(obj, event);
}
void HorizontalWidgetGallery::wheelEvent(QWheelEvent *event) {
if (qAbs(event->angleDelta().x()) <= qAbs(event->angleDelta().y())) {
event->ignore();
}
}
void HorizontalWidgetGallery::updateButtonsVisibility()
{
QPoint globalPos = QCursor::pos();
QPoint localPos = this->mapFromGlobal(globalPos);
if (!this->rect().contains(localPos)) {
m_btnLeft->hide();
m_btnRight->hide();
return;
}
int currentWidth = this->width();
QScrollBar* bar = m_scrollArea->horizontalScrollBar();
bool isLeftHalf = localPos.x() < (currentWidth / 2);
m_btnLeft->setVisible(isLeftHalf && bar->value() > 0);
m_btnRight->setVisible(!isLeftHalf && bar->value() < bar->maximum());
}
@@ -1,57 +0,0 @@
#ifndef HORIZONTALWIDGETGALLERY_H
#define HORIZONTALWIDGETGALLERY_H
#include <QWidget>
class QScrollArea;
class QHBoxLayout;
class QPushButton;
class QPropertyAnimation;
class HorizontalWidgetGallery : public QWidget
{
Q_OBJECT
public:
explicit HorizontalWidgetGallery(QWidget* parent = nullptr);
~HorizontalWidgetGallery() override = default;
void addWidget(QWidget* widget);
void clear();
void setSpacing(int spacing);
void adjustHeightToContent();
QSize minimumSizeHint() const override {
return QSize(1, QWidget::minimumSizeHint().height());
}
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
private:
void applyContentHeight();
void updateButtonsVisibility();
void updateButtonPositions();
QScrollArea* m_scrollArea;
QWidget* m_contentWidget;
QHBoxLayout* m_contentLayout;
QPushButton* m_btnLeft;
QPushButton* m_btnRight;
QPropertyAnimation* m_hScrollAnim;
int m_hScrollTarget;
};
#endif
+23
View File
@@ -0,0 +1,23 @@
#ifndef IDANMAKUSURFACE_H
#define IDANMAKUSURFACE_H
#include <models/danmaku/danmakumodels.h>
// Shared surface contract for the QML danmaku surface (DanmakuPaintedItem).
// PlayerDanmakuController talks only to this interface + MpvController.
class IDanmakuSurface
{
public:
virtual ~IDanmakuSurface() = default;
virtual void setRenderOptions(DanmakuRenderOptions options) = 0;
virtual void setComments(QList<DanmakuComment> comments) = 0;
virtual void clearDanmaku() = 0;
virtual bool hasComments() const = 0;
virtual void setDanmakuVisible(bool visible) = 0;
virtual void setBottomSubtitleProtected(bool enabled) = 0;
virtual void setExternalFrameClockEnabled(bool enabled) = 0;
virtual void advanceFrame() = 0;
};
#endif
@@ -1,88 +0,0 @@
#include "libraryactionmenu.h"
#include "../managers/thememanager.h"
#include <QAction>
#include <QFile>
#include <QIcon>
#include <QVariant>
LibraryActionMenu::LibraryActionMenu(const VirtualFolder& folder,
bool scanActive, QWidget* parent)
: QMenu(parent), m_folder(folder), m_scanActive(scanActive)
{
setObjectName("media-action-menu");
setWindowFlags(windowFlags() | Qt::FramelessWindowHint |
Qt::NoDropShadowWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setupMenu();
}
LibraryContextMenuRequest LibraryActionMenu::execRequest(const QPoint& globalPos)
{
return requestFromAction(exec(globalPos));
}
QAction* LibraryActionMenu::addMenuAction(LibraryContextMenuAction action,
const QIcon& icon,
const QString& text,
const QString& stringValue)
{
auto* menuAction = new QAction(icon, text, this);
LibraryContextMenuRequest request;
request.action = action;
request.stringValue = stringValue;
menuAction->setData(QVariant::fromValue(request));
addAction(menuAction);
return menuAction;
}
LibraryContextMenuRequest
LibraryActionMenu::requestFromAction(const QAction* action)
{
if (!action) {
return {};
}
const QVariant requestData = action->data();
if (!requestData.canConvert<LibraryContextMenuRequest>()) {
return {};
}
return requestData.value<LibraryContextMenuRequest>();
}
void LibraryActionMenu::setupMenu()
{
const QString themeDir =
ThemeManager::instance()->isDarkMode() ? "dark" : "light";
auto adaptiveIcon = [&themeDir](const QString& baseName) -> QIcon {
const QString themePath = QString(":/svg/%1/%2").arg(themeDir, baseName);
if (QFile::exists(themePath)) {
return QIcon(themePath);
}
return ThemeManager::getAdaptiveIcon(
QString(":/svg/player/%1").arg(baseName));
};
const bool hasItemId = !m_folder.itemId.trimmed().isEmpty();
QAction* editImagesAction = addMenuAction(LibraryContextMenuAction::EditImages,
adaptiveIcon("edit-images.svg"),
tr("Edit Images"));
editImagesAction->setEnabled(hasItemId && !m_scanActive);
QAction* refreshAction = addMenuAction(LibraryContextMenuAction::RefreshMetadata,
adaptiveIcon("refresh.svg"),
tr("Refresh Metadata"));
refreshAction->setEnabled(hasItemId && !m_scanActive);
QAction* scanAction = addMenuAction(LibraryContextMenuAction::ScanLibraryFiles,
adaptiveIcon("scan-library.svg"),
tr("Scan Library Files"));
scanAction->setEnabled(hasItemId && !m_scanActive);
}
@@ -1,31 +0,0 @@
#ifndef LIBRARYACTIONMENU_H
#define LIBRARYACTIONMENU_H
#include "librarycontextmenurequest.h"
#include <QMenu>
#include <models/admin/adminmodels.h>
class QAction;
class QIcon;
class QPoint;
class LibraryActionMenu : public QMenu
{
Q_OBJECT
public:
explicit LibraryActionMenu(const VirtualFolder& folder, bool scanActive,
QWidget* parent = nullptr);
LibraryContextMenuRequest execRequest(const QPoint& globalPos);
private:
QAction* addMenuAction(LibraryContextMenuAction action, const QIcon& icon,
const QString& text,
const QString& stringValue = QString());
void setupMenu();
static LibraryContextMenuRequest requestFromAction(const QAction* action);
VirtualFolder m_folder;
bool m_scanActive = false;
};
#endif
@@ -1,26 +0,0 @@
#ifndef LIBRARYCONTEXTMENUREQUEST_H
#define LIBRARYCONTEXTMENUREQUEST_H
#include <QMetaType>
#include <QString>
enum class LibraryContextMenuAction {
None,
EditImages,
RefreshMetadata,
ScanLibraryFiles
};
struct LibraryContextMenuRequest {
LibraryContextMenuAction action = LibraryContextMenuAction::None;
QString stringValue;
bool isValid() const
{
return action != LibraryContextMenuAction::None;
}
};
Q_DECLARE_METATYPE(LibraryContextMenuRequest)
#endif
+19 -4
View File
@@ -1,8 +1,9 @@
#include "loadingoverlay.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QEasingCurve>
#include <QConicalGradient>
#include <QLinearGradient>
#include <QMouseEvent>
@@ -14,17 +15,26 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent) {
m_rotateAnim = new QPropertyAnimation(this, "angle", this);
m_rotateAnim->setStartValue(0.0);
m_rotateAnim->setEndValue(360.0);
m_rotateAnim->setDuration(1000);
m_rotateAnim->setDuration(AnimationTokens::DurationMs::kGentle);
m_rotateAnim->setLoopCount(-1);
m_fadeAnim = new QPropertyAnimation(this, "opacity", this);
m_fadeAnim->setDuration(300);
m_fadeAnim->setEasingCurve(QEasingCurve::InOutQuad);
m_fadeAnim->setDuration(AnimationTokens::DurationMs::kNormal);
m_fadeAnim->setEasingCurve(AnimationTokens::Curve::fade());
}
void LoadingOverlay::start() {
show();
raise();
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: static indicator, no spin, no fade.
m_fadeAnim->stop();
m_opacity = 1.0;
update();
return;
}
m_rotateAnim->start();
m_fadeAnim->disconnect();
@@ -34,6 +44,11 @@ void LoadingOverlay::start() {
}
void LoadingOverlay::stop() {
if (MotionPrefs::reduceMotion()) {
forceStop();
return;
}
m_fadeAnim->disconnect();
connect(m_fadeAnim, &QAbstractAnimation::finished, this, [this]() {
hide();
+4 -1
View File
@@ -4,6 +4,7 @@
#include "../utils/mediaidentifyutils.h"
#include "../utils/mediaitemutils.h"
#include "../utils/playlistutils.h"
#include "../utils/popupfade.h"
#include <qembycore.h>
#include <services/manager/servermanager.h>
#include <models/profile/serverprofile.h>
@@ -30,9 +31,11 @@ MediaActionMenu::MediaActionMenu(const MediaItem& item, QEmbyCore* core,
setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
PopupFade::attach(this);
setupMenu();
}
-402
View File
@@ -1,402 +0,0 @@
#include "mediagridwidget.h"
#include "shimmerwidget.h"
#include "../utils/smoothscrollcontroller.h"
#include "../utils/textwraputils.h"
#include "../views/media/medialistmodel.h"
#include <QVBoxLayout>
#include <QListView>
#include <QResizeEvent>
#include <QApplication>
#include <QStyle>
#include <QScroller>
#include <QScrollerProperties>
#include <QWheelEvent>
#include <QScrollBar>
#include <QSet>
#include <QStyleOptionViewItem>
#include <algorithm>
MediaGridWidget::MediaGridWidget(QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core), m_basePadding(20), m_currentStyle(MediaCardDelegate::Poster),
m_vScrollController(nullptr)
{
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
m_listView = new QListView(this);
m_listView->setSelectionMode(QAbstractItemView::NoSelection);
m_listView->setViewMode(QListView::IconMode);
m_listView->setResizeMode(QListView::Adjust);
m_listView->setMovement(QListView::Static);
m_listView->setSpacing(0);
m_listView->setUniformItemSizes(true);
m_listView->setWrapping(true);
m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_listView->setMouseTracking(true);
m_listView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
m_listView->viewport()->setAttribute(Qt::WA_Hover);
m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
QScroller::grabGesture(m_listView->viewport(), QScroller::LeftMouseButtonGesture);
QScroller* scroller = QScroller::scroller(m_listView->viewport());
QScrollerProperties props = scroller->scrollerProperties();
props.setScrollMetric(QScrollerProperties::VerticalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::HorizontalOvershootPolicy, QScrollerProperties::OvershootAlwaysOff);
props.setScrollMetric(QScrollerProperties::DragStartDistance, 0.001);
scroller->setScrollerProperties(props);
m_vScrollController =
new SmoothScrollController(m_listView->verticalScrollBar(), this);
m_vScrollController->setDuration(160);
m_listView->viewport()->installEventFilter(this);
m_listModel = new MediaListModel(400, m_core, this);
m_listDelegate = new MediaCardDelegate(m_currentStyle, this);
m_listView->setModel(m_listModel);
m_listView->setItemDelegate(m_listDelegate);
layout->addWidget(m_listView);
m_shimmer = new ShimmerWidget(this);
m_shimmer->hide();
connect(m_listView, &QListView::clicked, this, [this](const QModelIndex& index) {
Q_EMIT itemClicked(m_listModel->getItem(index));
});
connect(m_listView->verticalScrollBar(), &QScrollBar::valueChanged, this,
[this](int) {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
});
connect(m_listDelegate, &MediaCardDelegate::playRequested, this, &MediaGridWidget::playRequested);
connect(m_listDelegate, &MediaCardDelegate::favoriteRequested, this, &MediaGridWidget::favoriteRequested);
connect(m_listDelegate, &MediaCardDelegate::moreMenuRequested, this, &MediaGridWidget::moreMenuRequested);
}
void MediaGridWidget::setBasePadding(int padding) {
m_basePadding = padding;
adjustGrid();
}
void MediaGridWidget::setCardStyle(MediaCardDelegate::CardStyle style) {
if (m_currentStyle == style) return;
m_currentStyle = style;
m_listDelegate->setStyle(style);
const bool preferThumb =
style == MediaCardDelegate::LibraryTile ||
style == MediaCardDelegate::EpisodeList;
m_listModel->setPreferThumb(preferThumb);
m_listModel->clearImageCache();
if (style == MediaCardDelegate::EpisodeList) {
m_listView->setViewMode(QListView::ListMode);
m_listView->setFlow(QListView::TopToBottom);
m_listView->setWrapping(false);
m_listView->setSpacing(5);
} else {
m_listView->setViewMode(QListView::IconMode);
m_listView->setFlow(QListView::LeftToRight);
m_listView->setWrapping(true);
m_listView->setSpacing(0);
}
adjustGrid();
updateVisibleImagePriority();
}
void MediaGridWidget::setLoading(bool loading)
{
if (!m_shimmer) {
return;
}
if (loading) {
QStyleOptionViewItem opt;
const QSize cardSize = m_listDelegate->sizeHint(opt, QModelIndex());
m_shimmer->setCardSize(cardSize);
m_shimmer->setShowSubtitle(false);
m_shimmer->setGeometry(m_listView->geometry());
m_shimmer->raise();
m_shimmer->show();
m_shimmer->startAnimation();
} else {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
}
void MediaGridWidget::setItems(const QList<MediaItem>& items) {
m_listModel->setItems(items);
adjustGrid();
if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) {
m_shimmer->stopAnimation();
m_shimmer->hide();
}
if (!items.isEmpty()) {
QMetaObject::invokeMethod(
this,
[this]() {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
},
Qt::QueuedConnection);
}
}
void MediaGridWidget::updateItem(const MediaItem& item) {
if (m_listModel) {
m_listModel->updateItem(item);
}
}
void MediaGridWidget::prependOrUpdateItem(const MediaItem& item, int maxItems) {
if (m_listModel) {
m_listModel->prependOrUpdateItem(item, maxItems);
QMetaObject::invokeMethod(
this,
[this]() {
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
},
Qt::QueuedConnection);
}
}
void MediaGridWidget::removeItem(const QString& itemId) {
if (m_listModel) {
m_listModel->removeItem(itemId);
}
}
int MediaGridWidget::itemCount() const {
return m_listModel ? m_listModel->rowCount() : 0;
}
int MediaGridWidget::saveScrollPosition() const {
if (m_listView && m_listView->verticalScrollBar())
return m_listView->verticalScrollBar()->value();
return 0;
}
void MediaGridWidget::restoreScrollPosition(int pos) {
if (m_listView && m_listView->verticalScrollBar()) {
if (m_vScrollController) {
m_vScrollController->scrollTo(pos, false);
} else {
m_listView->verticalScrollBar()->setValue(pos);
}
}
}
void MediaGridWidget::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
adjustGrid();
if (m_shimmer && m_shimmer->isVisible()) {
m_shimmer->setGeometry(m_listView->geometry());
}
notifyLoadMoreIfNeeded();
updateVisibleImagePriority();
}
bool MediaGridWidget::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_listView->viewport() && event->type() == QEvent::ToolTip) {
return TextWrapUtils::showWrappedMediaItemToolTip(m_listView, event);
}
if (event->type() == QEvent::Wheel && obj == m_listView->viewport()) {
QWheelEvent* we = static_cast<QWheelEvent*>(event);
if (qAbs(we->angleDelta().y()) >= qAbs(we->angleDelta().x())) {
if (m_vScrollController) {
m_vScrollController->scrollByWheelEvent(we, Qt::Vertical);
}
return true;
}
}
return QWidget::eventFilter(obj, event);
}
void MediaGridWidget::notifyLoadMoreIfNeeded()
{
if (!m_listView || !m_listModel || m_listModel->rowCount() <= 0) {
return;
}
QScrollBar* vBar = m_listView->verticalScrollBar();
if (!vBar) {
return;
}
constexpr int kLoadMoreThreshold = 160;
const int remaining = vBar->maximum() - vBar->value();
if (vBar->maximum() <= 0 || remaining <= kLoadMoreThreshold) {
Q_EMIT loadMoreRequested();
}
}
void MediaGridWidget::updateVisibleImagePriority()
{
if (!m_listView || !m_listModel) {
return;
}
QWidget* viewport = m_listView->viewport();
if (!viewport) {
return;
}
QStyleOptionViewItem option;
const QSize cellSize = m_listDelegate->sizeHint(option, QModelIndex());
const int stepX = qMax(1, cellSize.width() / 2);
const int stepY = qMax(1, cellSize.height() / 2);
QSet<int> rowSet;
const QRect rect = viewport->rect();
for (int y = rect.top(); y <= rect.bottom(); y += stepY) {
for (int x = rect.left(); x <= rect.right(); x += stepX) {
const QModelIndex idx = m_listView->indexAt(QPoint(x, y));
if (idx.isValid()) {
rowSet.insert(idx.row());
}
}
}
QList<int> rows = rowSet.values();
std::sort(rows.begin(), rows.end());
m_listModel->setPriorityRows(rows);
}
void MediaGridWidget::adjustGrid() {
if (!m_listView || !m_listModel) return;
int scrollBarWidth = qApp->style()->pixelMetric(QStyle::PM_ScrollBarExtent);
int availableWidth = this->width() - scrollBarWidth;
if (availableWidth < 100) availableWidth = 100;
if (m_currentStyle == MediaCardDelegate::EpisodeList) {
int padding = m_basePadding;
m_listView->setStyleSheet(QString("QListView { background: transparent; border: none; outline: none; padding-left: %1px; padding-right: 0px; }").arg(padding));
int cellWidth = availableWidth - padding - padding;
if (cellWidth < 100) cellWidth = 100;
m_listDelegate->setTileSize(QSize(cellWidth, 160));
} else {
availableWidth -= (m_basePadding * 2);
int defaultCellWidth = 150;
if (m_currentStyle == MediaCardDelegate::LibraryTile) {
defaultCellWidth = 250;
} else if (m_currentStyle == MediaCardDelegate::Cast) {
defaultCellWidth = 140;
}
int tolerance = 5;
int cols = (availableWidth + tolerance) / defaultCellWidth;
if (cols < 1) cols = 1;
int cellWidth = availableWidth / cols;
int remainder = availableWidth - (cols * cellWidth);
int leftPad = m_basePadding + remainder / 2;
m_listView->setStyleSheet(QString("QListView { background: transparent; border: none; outline: none; padding-left: %1px; padding-right: 0px; }").arg(leftPad));
int imgWidth = cellWidth - 16;
int imgHeight = imgWidth;
if (m_currentStyle == MediaCardDelegate::Poster || m_currentStyle == MediaCardDelegate::Cast) {
imgHeight = qRound(imgWidth * 1.5);
} else if (m_currentStyle == MediaCardDelegate::LibraryTile) {
imgHeight = qRound(imgWidth * 9.0 / 16.0);
}
int cellHeight = imgHeight + 60;
m_listDelegate->setTileSize(QSize(cellWidth, cellHeight));
}
m_listView->doItemsLayout();
}
-76
View File
@@ -1,76 +0,0 @@
#ifndef MEDIAGRIDWIDGET_H
#define MEDIAGRIDWIDGET_H
#include <QWidget>
#include <QPoint>
#include <models/media/mediaitem.h>
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class QListView;
class MediaListModel;
class ShimmerWidget;
class SmoothScrollController;
class MediaGridWidget : public QWidget {
Q_OBJECT
public:
explicit MediaGridWidget(QEmbyCore* core, QWidget* parent = nullptr);
void setItems(const QList<MediaItem>& items);
void setBasePadding(int padding);
void setCardStyle(MediaCardDelegate::CardStyle style);
void setLoading(bool loading);
void updateItem(const MediaItem& item);
void prependOrUpdateItem(const MediaItem& item, int maxItems = 0);
void removeItem(const QString& itemId);
int itemCount() const;
int saveScrollPosition() const;
void restoreScrollPosition(int pos);
Q_SIGNALS:
void itemClicked(const MediaItem& item);
void loadMoreRequested();
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& globalPos);
protected:
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void adjustGrid();
void notifyLoadMoreIfNeeded();
void updateVisibleImagePriority();
int m_basePadding;
MediaCardDelegate::CardStyle m_currentStyle;
QEmbyCore* m_core;
QListView* m_listView;
MediaListModel* m_listModel;
MediaCardDelegate* m_listDelegate;
SmoothScrollController* m_vScrollController;
ShimmerWidget* m_shimmer = nullptr;
};
#endif
@@ -1,126 +0,0 @@
#include "mediasectionwidget.h"
#include "horizontallistviewgallery.h"
#include <qembycore.h>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPointer>
#include <QListView>
MediaSectionWidget::MediaSectionWidget(const QString& title, QEmbyCore* core, QWidget* parent)
: QWidget(parent), m_core(core)
{
setObjectName("media-section");
setStyleSheet("QWidget#media-section { background: transparent; }");
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(40, 20, 40, 0);
layout->setSpacing(0);
auto *headerContainer = new QWidget(this);
headerContainer->setObjectName("section-header");
headerContainer->setStyleSheet("#section-header { background: transparent; }");
m_headerLayout = new QHBoxLayout(headerContainer);
m_headerLayout->setContentsMargins(0, 0, 0, 0);
m_headerLayout->setSpacing(12);
m_titleLabel = new QLabel(title, headerContainer);
m_titleLabel->setObjectName("detail-section-title");
m_headerLayout->addWidget(m_titleLabel);
m_headerLayout->addStretch();
m_gallery = new HorizontalListViewGallery(core, this);
m_gallery->listView()->setProperty("isHorizontalListView", true);
connect(m_gallery, &HorizontalListViewGallery::itemClicked, this, &MediaSectionWidget::itemClicked);
connect(m_gallery, &HorizontalListViewGallery::playRequested, this, &MediaSectionWidget::playRequested);
connect(m_gallery, &HorizontalListViewGallery::favoriteRequested, this, &MediaSectionWidget::favoriteRequested);
connect(m_gallery, &HorizontalListViewGallery::moreMenuRequested, this, &MediaSectionWidget::moreMenuRequested);
layout->addWidget(headerContainer);
layout->addWidget(m_gallery);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
hide();
}
HorizontalListViewGallery* MediaSectionWidget::gallery() const {
return m_gallery;
}
void MediaSectionWidget::setTitle(const QString& title) {
m_titleLabel->setText(title);
}
void MediaSectionWidget::setHeaderWidget(QWidget* widget) {
if (m_headerWidget && m_headerWidget != widget) {
m_headerWidget->hide();
m_headerWidget = nullptr;
}
if (widget) {
m_headerWidget = widget;
if (m_headerLayout->indexOf(m_headerWidget) < 0) {
m_headerLayout->insertWidget(1, m_headerWidget);
}
m_headerWidget->show();
}
}
void MediaSectionWidget::setCardStyle(MediaCardDelegate::CardStyle style) {
m_gallery->setCardStyle(style);
}
void MediaSectionWidget::setGalleryHeight(int height) {
m_gallery->setFixedHeight(height);
}
void MediaSectionWidget::setTileSize(const QSize &size) {
m_gallery->setTileSize(size);
}
void MediaSectionWidget::clear() {
m_gallery->setItems(QList<MediaItem>());
hide();
}
void MediaSectionWidget::setItems(const QList<MediaItem>& items) {
if (items.isEmpty()) {
hide();
} else {
m_gallery->setItems(items);
show();
}
}
void MediaSectionWidget::updateItem(const MediaItem& item) {
m_gallery->updateItem(item);
}
QCoro::Task<void> MediaSectionWidget::loadAsync(FetchFunction fetcher) {
if (!fetcher) co_return;
QPointer<MediaSectionWidget> safeThis(this);
try {
QList<MediaItem> items = co_await fetcher();
if (safeThis) {
safeThis->setItems(items);
Q_EMIT safeThis->dataLoaded(items.size());
}
} catch(...) {
if (safeThis) {
safeThis->hide();
}
}
}
@@ -1,59 +0,0 @@
#ifndef MEDIASECTIONWIDGET_H
#define MEDIASECTIONWIDGET_H
#include <QWidget>
#include <functional>
#include <qcorotask.h>
#include "models/media/mediaitem.h"
#include "../views/media/mediacarddelegate.h"
class QEmbyCore;
class HorizontalListViewGallery;
class QLabel;
class QHBoxLayout;
class MediaSectionWidget : public QWidget {
Q_OBJECT
public:
explicit MediaSectionWidget(const QString& title, QEmbyCore* core, QWidget* parent = nullptr);
HorizontalListViewGallery* gallery() const;
void setTitle(const QString& title);
void setCardStyle(MediaCardDelegate::CardStyle style);
void setGalleryHeight(int height);
void setTileSize(const QSize &size);
void setHeaderWidget(QWidget* widget);
void clear();
void setItems(const QList<MediaItem>& items);
void updateItem(const MediaItem& item);
using FetchFunction = std::function<QCoro::Task<QList<MediaItem>>()>;
QCoro::Task<void> loadAsync(FetchFunction fetcher);
signals:
void itemClicked(const MediaItem& item);
void playRequested(const MediaItem& item);
void favoriteRequested(const MediaItem& item);
void moreMenuRequested(const MediaItem& item, const QPoint& pos);
void dataLoaded(int itemCount);
private:
QEmbyCore* m_core;
QLabel* m_titleLabel;
QHBoxLayout* m_headerLayout;
QWidget* m_headerWidget = nullptr;
HorizontalListViewGallery* m_gallery;
};
#endif
+7 -1
View File
@@ -1,4 +1,6 @@
#include "moderncombobox.h"
#include "../utils/fadehelper.h"
#include "../utils/popupfade.h"
#include <QAbstractItemView>
#include <QApplication>
#include <QEvent>
@@ -132,6 +134,8 @@ void ModernComboBox::showPopup() {
return;
}
PopupFade::run(popupContainer);
auto *vScrollBar = popupView->verticalScrollBar();
@@ -263,10 +267,12 @@ void ModernComboBox::showEmbeddedPopup() {
listFrame->setGeometry(popupX, popupY, popupW, popupH);
overlay->show();
overlay->raise();
listFrame->show();
// Child overlay (not a top-level window) — FadeHelper, not PopupFade.
FadeHelper::fadeIn(listFrame, AnimationTokens::DurationMs::kQuick);
listWidget->setFocus();
@@ -1,198 +0,0 @@
#include "modernmenubutton.h"
#include <QAction>
#include <QLabel>
#include <QStyle>
#include <QVBoxLayout>
#include <QWidget>
#include <QWidgetAction>
namespace {
QWidget *createStreamMenuItemWidget(const QString &firstLineText,
const QString &secondLine) {
auto *host = new QWidget();
host->setObjectName("stream-menu-item-host");
host->setAttribute(Qt::WA_StyledBackground, true);
auto *hostLayout = new QVBoxLayout(host);
hostLayout->setContentsMargins(0, 1, 0, 1);
hostLayout->setSpacing(0);
auto *container = new QWidget(host);
container->setObjectName("stream-menu-item");
container->setAttribute(Qt::WA_StyledBackground, true);
auto *layout = new QVBoxLayout(container);
layout->setContentsMargins(14, 6, 28, 6);
layout->setSpacing(secondLine.isEmpty() ? 0 : 2);
auto *line1 = new QLabel(firstLineText, container);
line1->setObjectName("stream-menu-line1");
layout->addWidget(line1);
if (!secondLine.isEmpty()) {
auto *line2 = new QLabel(secondLine, container);
line2->setObjectName("stream-menu-line2");
layout->addWidget(line2);
}
hostLayout->addWidget(container);
return host;
}
QWidget *streamMenuVisualWidget(QWidgetAction *action) {
if (!action || !action->defaultWidget()) {
return nullptr;
}
return action->defaultWidget()->findChild<QWidget *>(
"stream-menu-item", Qt::FindChildrenRecursively);
}
void refreshStreamMenuItemState(QAction *action, bool selected) {
action->setChecked(selected);
auto *widgetAction = qobject_cast<QWidgetAction *>(action);
if (!widgetAction) {
return;
}
auto *visualWidget = streamMenuVisualWidget(widgetAction);
if (!visualWidget) {
return;
}
visualWidget->setProperty("checked", selected);
visualWidget->style()->unpolish(visualWidget);
visualWidget->style()->polish(visualWidget);
visualWidget->update();
const auto labels = visualWidget->findChildren<QLabel *>();
for (auto *label : labels) {
label->style()->unpolish(label);
label->style()->polish(label);
label->update();
}
}
}
ModernMenuButton::ModernMenuButton(QWidget *parent)
: QPushButton(parent), m_currentIndex(-1)
{
m_menu = new QMenu(this);
setObjectName("immersive-stream-btn");
m_menu->setObjectName("immersive-stream-menu");
m_menu->setProperty("customItemHighlight", true);
setStyleSheet("QPushButton::menu-indicator { image: none; }");
m_menu->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
m_menu->setAttribute(Qt::WA_TranslucentBackground, true);
m_menu->setAutoFillBackground(false);
setCursor(Qt::PointingHandCursor);
connect(m_menu, &QMenu::triggered, this, &ModernMenuButton::onActionTriggered);
connect(this, &QPushButton::clicked, this, [this]() {
if (m_actions.isEmpty()) {
return;
}
m_menu->adjustSize();
QPoint pos = mapToGlobal(QPoint(width() / 2, height() + 4));
pos.setX(pos.x() - m_menu->sizeHint().width() / 2);
m_menu->exec(pos);
});
}
void ModernMenuButton::clear()
{
m_menu->clear();
m_actions.clear();
m_currentIndex = -1;
setText("");
setToolTip("");
}
void ModernMenuButton::addItem(const QString& title, const QString& subtitle,
const QString& secondLine, const QVariant& userData)
{
QString firstLineText = title;
if (!subtitle.isEmpty()) {
firstLineText += " | " + subtitle;
}
auto *widgetAction = new QWidgetAction(m_menu);
widgetAction->setDefaultWidget(
createStreamMenuItemWidget(firstLineText, secondLine));
widgetAction->setProperty("shortTitle", title);
widgetAction->setData(m_actions.size());
widgetAction->setProperty("realData", userData);
widgetAction->setProperty(
"fullText", secondLine.isEmpty() ? firstLineText
: firstLineText + "\n" + secondLine);
widgetAction->setCheckable(true);
m_menu->addAction(widgetAction);
m_actions.append(widgetAction);
if (m_actions.size() == 1) {
setCurrentIndex(0);
}
}
void ModernMenuButton::setCurrentIndex(int index)
{
if (index >= 0 && index < m_actions.size()) {
m_currentIndex = index;
updateAppearance();
} else if (index == -1) {
m_currentIndex = -1;
updateAppearance();
}
}
QVariant ModernMenuButton::currentData() const
{
if (m_currentIndex >= 0 && m_currentIndex < m_actions.size()) {
return m_actions.at(m_currentIndex)->property("realData");
}
return QVariant();
}
void ModernMenuButton::onActionTriggered(QAction *action)
{
int index = action->data().toInt();
if (index != m_currentIndex) {
m_currentIndex = index;
updateAppearance();
Q_EMIT currentIndexChanged(m_currentIndex);
} else {
action->setChecked(true);
}
}
void ModernMenuButton::updateAppearance()
{
for (int i = 0; i < m_actions.size(); ++i) {
refreshStreamMenuItemState(m_actions.at(i), i == m_currentIndex);
}
if (m_currentIndex >= 0 && m_currentIndex < m_actions.size()) {
QAction* currentAct = m_actions.at(m_currentIndex);
QString shortText = currentAct->property("shortTitle").toString();
setText(shortText + " ⯆");
setToolTip(currentAct->property("fullText").toString());
} else {
setText("");
setToolTip("");
}
}
@@ -1,38 +0,0 @@
#ifndef MODERNMENUBUTTON_H
#define MODERNMENUBUTTON_H
#include <QPushButton>
#include <QMenu>
#include <QVariant>
#include <QList>
class ModernMenuButton : public QPushButton {
Q_OBJECT
public:
explicit ModernMenuButton(QWidget *parent = nullptr);
void clear();
void addItem(const QString& title, const QString& subtitle,
const QString& secondLine = QString(),
const QVariant& userData = QVariant());
void setCurrentIndex(int index);
int currentIndex() const { return m_currentIndex; }
QVariant currentData() const;
int count() const { return m_actions.size(); }
Q_SIGNALS:
void currentIndexChanged(int index);
private Q_SLOTS:
void onActionTriggered(QAction *action);
private:
void updateAppearance();
QMenu* m_menu;
QList<QAction*> m_actions;
int m_currentIndex;
};
#endif
@@ -1,116 +0,0 @@
#include "modernmenuscrollbar.h"
#include <QPainter>
#include <algorithm>
namespace {
constexpr int kHandleNormalWidth = 4;
constexpr int kHandleHoverWidth = 8;
constexpr int kHandleMinHeight = 28;
constexpr int kTrackVerticalPadding = 3;
constexpr int kHandleAlphaNormal = 115;
constexpr int kHandleAlphaActive = 178;
}
ModernMenuScrollBar::ModernMenuScrollBar(Qt::Orientation orientation, QWidget *parent)
: QScrollBar(orientation, parent) {
setAttribute(Qt::WA_OpaquePaintEvent, false);
}
ModernMenuScrollBar::ModernMenuScrollBar(QWidget *parent)
: QScrollBar(parent) {
setAttribute(Qt::WA_OpaquePaintEvent, false);
}
void ModernMenuScrollBar::enterEvent(QEnterEvent *event) {
m_hovered = true;
update();
QScrollBar::enterEvent(event);
}
void ModernMenuScrollBar::leaveEvent(QEvent *event) {
m_hovered = false;
update();
QScrollBar::leaveEvent(event);
}
QRect ModernMenuScrollBar::computeHandleRect() const {
if (orientation() != Qt::Vertical) {
return {};
}
const int range = maximum() - minimum();
const int trackTop = kTrackVerticalPadding;
const int trackHeight = height() - 2 * kTrackVerticalPadding;
if (range <= 0 || trackHeight <= 0) {
return {};
}
const int page = pageStep();
int handleHeight = (page > 0)
? static_cast<int>(static_cast<qint64>(trackHeight) * page / (range + page))
: kHandleMinHeight;
handleHeight = std::clamp(handleHeight, kHandleMinHeight, trackHeight);
const int availableTrack = trackHeight - handleHeight;
int handleY = trackTop;
if (availableTrack > 0) {
handleY = trackTop + static_cast<int>(
static_cast<qint64>(sliderPosition() - minimum()) * availableTrack / range);
}
const bool active = m_hovered || isSliderDown();
const int handleW = active ? kHandleHoverWidth : kHandleNormalWidth;
const int handleX = (width() - handleW) / 2;
return {handleX, handleY, handleW, handleHeight};
}
void ModernMenuScrollBar::paintEvent(QPaintEvent *event) {
Q_UNUSED(event);
const QRect r = computeHandleRect();
if (r.isEmpty()) {
return;
}
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(Qt::NoPen);
const bool active = isSliderDown() || m_hovered;
const QColor handleColor(255, 255, 255, active ? kHandleAlphaActive : kHandleAlphaNormal);
painter.setBrush(handleColor);
const qreal radius = r.width() / 2.0;
painter.drawRoundedRect(QRectF(r), radius, radius);
}
@@ -1,45 +0,0 @@
#ifndef MODERNMENUSCROLLBAR_H
#define MODERNMENUSCROLLBAR_H
#include <QScrollBar>
#include <QPaintEvent>
class ModernMenuScrollBar : public QScrollBar {
Q_OBJECT
public:
explicit ModernMenuScrollBar(Qt::Orientation orientation, QWidget *parent = nullptr);
explicit ModernMenuScrollBar(QWidget *parent = nullptr);
~ModernMenuScrollBar() override = default;
protected:
void paintEvent(QPaintEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
private:
QRect computeHandleRect() const;
bool m_hovered = false;
};
#endif
@@ -1,14 +0,0 @@
#include "modernnumberinput.h"
#include <QAbstractSpinBox>
ModernNumberInput::ModernNumberInput(QWidget *parent)
: QSpinBox(parent)
{
setObjectName("ModernNumberInput");
setRange(0, 10000);
setAlignment(Qt::AlignCenter);
setButtonSymbols(QAbstractSpinBox::NoButtons);
setKeyboardTracking(false);
setFixedSize(118, 34);
}
@@ -1,14 +0,0 @@
#ifndef MODERNNUMBERINPUT_H
#define MODERNNUMBERINPUT_H
#include <QSpinBox>
class ModernNumberInput : public QSpinBox
{
Q_OBJECT
public:
explicit ModernNumberInput(QWidget *parent = nullptr);
};
#endif
@@ -1,248 +0,0 @@
#include "modernscrollpanel.h"
#include "modernmenuscrollbar.h"
#include <QGraphicsDropShadowEffect>
#include <QPainter>
#include <QEvent>
#include <QFont>
#include <QFontMetrics>
#include <QScrollBar>
namespace {
constexpr int kMenuItemMinHeight = 42;
constexpr int kMenuItemVerticalPadding = 18;
constexpr int kMenuItemSpacing = 2;
constexpr int kScrollAreaFrameReserve = 2;
int menuItemHeight(const QFont &font)
{
const QFontMetrics fm(font);
return qMax(kMenuItemMinHeight, fm.height() + kMenuItemVerticalPadding);
}
}
ModernScrollPanel::ModernScrollPanel(QWidget *parent) : QFrame(parent), m_maxContentWidth(0) {
setObjectName("modernScrollMenuOuter");
setWindowFlags(Qt::Widget | Qt::FramelessWindowHint);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_StyledBackground, true);
auto *shadow = new QGraphicsDropShadowEffect(this);
shadow->setBlurRadius(20);
shadow->setColor(QColor(0, 0, 0, 150));
shadow->setOffset(0, 4);
setGraphicsEffect(shadow);
m_mainLayout = new QVBoxLayout(this);
m_mainLayout->setContentsMargins(0, 0, 0, 0);
m_mainLayout->setSpacing(0);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setObjectName("modernMenuScrollArea");
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
if (auto *vp = m_scrollArea->viewport()) {
vp->setObjectName("modernMenuViewport");
vp->setAutoFillBackground(false);
vp->setAttribute(Qt::WA_StyledBackground, true);
}
auto *vBar = new ModernMenuScrollBar(Qt::Vertical, m_scrollArea);
vBar->setObjectName("modernMenuScrollBar");
m_scrollArea->setVerticalScrollBar(vBar);
m_container = new QWidget(m_scrollArea);
m_container->setObjectName("modernMenuContainer");
m_container->setAttribute(Qt::WA_StyledBackground, true);
m_layout = new QVBoxLayout(m_container);
m_layout->setContentsMargins(4, 4, 4, 4);
m_layout->setSpacing(kMenuItemSpacing);
m_scrollArea->setWidget(m_container);
m_mainLayout->addWidget(m_scrollArea);
}
void ModernScrollPanel::addItem(const QString &text, const QVariant &userData, bool isSelected) {
auto *btn = new QPushButton(m_container);
btn->setObjectName("modernMenuItemBtn");
QPixmap pixmap(18, 18);
pixmap.fill(Qt::transparent);
if (isSelected) {
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing);
painter.setPen(QColor(255, 255, 255, 255));
QFont font = painter.font();
font.setPixelSize(14);
font.setBold(true);
painter.setFont(font);
painter.drawText(pixmap.rect(), Qt::AlignCenter, "✓");
}
btn->setIcon(QIcon(pixmap));
btn->setText(text);
btn->setToolTip(text);
btn->setCursor(Qt::PointingHandCursor);
btn->setFocusPolicy(Qt::NoFocus);
btn->setFixedHeight(menuItemHeight(btn->font()));
QFontMetrics fm(btn->font());
m_maxContentWidth = qMax(m_maxContentWidth, fm.horizontalAdvance(text));
m_items.append({btn, text});
connect(btn, &QPushButton::clicked, this, [this, userData, text]() {
emit itemTriggered(userData, text);
});
m_layout->addWidget(btn);
}
void ModernScrollPanel::finalizeLayout(int maxHeight, int maxWidth) {
m_layout->addStretch();
m_container->adjustSize();
int contentHeight = m_container->sizeHint().height();
if (contentHeight < 10) {
contentHeight = 40;
}
m_container->setMinimumHeight(contentHeight);
const int panelContentHeight = contentHeight + kScrollAreaFrameReserve;
int finalHeight = qMin(panelContentHeight, maxHeight);
bool needsVScroll = (panelContentHeight > maxHeight);
if (needsVScroll) {
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
} else {
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
}
int scrollBarBuffer = needsVScroll ? 12 : 0;
int requiredWidth = m_maxContentWidth + 45 + scrollBarBuffer;
int minWidth = 130;
int finalWidth = qBound(minWidth, requiredWidth, maxWidth);
int textAvailableWidth = finalWidth - 45 - scrollBarBuffer;
if (textAvailableWidth < 20) {
textAvailableWidth = 20;
}
for (const auto& item : m_items) {
QFontMetrics fm(item.btn->font());
QString elidedText = fm.elidedText(item.fullText, Qt::ElideRight, textAvailableWidth);
item.btn->setText(elidedText);
}
m_scrollArea->setFixedSize(finalWidth, finalHeight);
this->setFixedSize(finalWidth, finalHeight);
}
void ModernScrollPanel::wheelEvent(QWheelEvent *event) {
QFrame::wheelEvent(event);
event->accept();
}
void ModernScrollPanel::paintEvent(QPaintEvent *event) {
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setBrush(QColor(30, 30, 30, 242));
QPen pen(QColor(255, 255, 255, 26));
pen.setWidth(1);
painter.setPen(pen);
QRectF rf(rect().x() + 0.5, rect().y() + 0.5,
rect().width() - 1.0, rect().height() - 1.0);
painter.drawRoundedRect(rf, 6.0, 6.0);
}
@@ -1,49 +0,0 @@
#ifndef MODERNSCROLLPANEL_H
#define MODERNSCROLLPANEL_H
#include <QFrame>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QPushButton>
#include <QVariant>
#include <QString>
#include <QList>
#include <QWheelEvent>
#include <QPaintEvent>
class ModernScrollPanel : public QFrame {
Q_OBJECT
public:
explicit ModernScrollPanel(QWidget *parent = nullptr);
~ModernScrollPanel() override = default;
void addItem(const QString &text, const QVariant &userData, bool isSelected = false);
void finalizeLayout(int maxHeight, int maxWidth = 250);
signals:
void itemTriggered(const QVariant &userData, const QString &text);
protected:
void paintEvent(QPaintEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
struct MenuItem {
QPushButton* btn;
QString fullText;
};
QVBoxLayout *m_mainLayout;
QScrollArea *m_scrollArea;
QWidget *m_container;
QVBoxLayout *m_layout;
QList<MenuItem> m_items;
int m_maxContentWidth;
};
#endif
+17 -9
View File
@@ -1,4 +1,5 @@
#include "modernslider.h"
#include "../utils/hoveranimator.h"
#include <QPainter>
#include <QPainterPath>
@@ -6,7 +7,10 @@ ModernSlider::ModernSlider(Qt::Orientation orientation, QWidget *parent)
: QSlider(orientation, parent) {
setCursor(Qt::PointingHandCursor);
setMouseTracking(true);
setAttribute(Qt::WA_Hover, true);
setAttribute(Qt::WA_Hover, true);
m_hover = new HoverAnimator(this);
m_hover->setCallback([this](qreal) { update(); });
}
void ModernSlider::setBufferValue(int value) {
@@ -56,17 +60,19 @@ void ModernSlider::mouseReleaseEvent(QMouseEvent *ev) {
}
void ModernSlider::mouseMoveEvent(QMouseEvent *ev) {
if (!m_isPressed) {
emit hoverMoved(ev->pos());
}
QSlider::mouseMoveEvent(ev);
}
bool ModernSlider::event(QEvent *ev) {
if (ev->type() == QEvent::Enter) {
m_isHovered = true;
update();
m_hover->setHovered(true);
} else if (ev->type() == QEvent::Leave) {
m_isHovered = false;
update();
m_hover->setHovered(false);
emit hoverLeft();
}
return QSlider::event(ev);
}
@@ -77,10 +83,12 @@ void ModernSlider::paintEvent(QPaintEvent *ev) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int trackHeight =
(m_isHovered || m_isPressed) ? m_hoverTrackHeight : m_normalTrackHeight;
const int handleRadius =
(m_isHovered || m_isPressed) ? m_activeHandleRadius : m_normalHandleRadius;
// Pressed shows the active size instantly; hover grows/shrinks smoothly.
const qreal hp = m_isPressed ? 1.0 : m_hover->progress();
const qreal trackHeight =
m_normalTrackHeight + hp * (m_hoverTrackHeight - m_normalTrackHeight);
const qreal handleRadius =
m_normalHandleRadius + hp * (m_activeHandleRadius - m_normalHandleRadius);
const int edgePadding = qMax(m_normalHandleRadius, m_activeHandleRadius);
QRectF trackRect;
+9 -1
View File
@@ -8,6 +8,8 @@
#include <QColor>
#include <QtGlobal>
class HoverAnimator;
class ModernSlider : public QSlider {
Q_OBJECT
@@ -62,6 +64,12 @@ public:
update();
}
Q_SIGNALS:
// Cursor position while hovering the (un-pressed) slider — drives hover
// affordances like the player's seek time bubble.
void hoverMoved(const QPoint &pos);
void hoverLeft();
protected:
void mousePressEvent(QMouseEvent *ev) override;
void mouseReleaseEvent(QMouseEvent *ev) override;
@@ -71,8 +79,8 @@ protected:
private:
int m_bufferValue = 0;
bool m_isHovered = false;
bool m_isPressed = false;
HoverAnimator *m_hover = nullptr;
QColor m_themeColor = QColor("#3B82F6");
@@ -1,119 +0,0 @@
#include "modernsortbutton.h"
#include <QAction>
ModernSortButton::ModernSortButton(QWidget *parent)
: QPushButton(parent), m_currentIndex(-1), m_descending(true)
{
m_menu = new QMenu(this);
setObjectName("modern-sort-btn");
setCursor(Qt::PointingHandCursor);
connect(m_menu, &QMenu::triggered, this, &ModernSortButton::onActionTriggered);
connect(this, &QPushButton::clicked, this, [this]() {
m_menu->adjustSize();
QPoint pos = mapToGlobal(QPoint(width() / 2, height() + 6));
pos.setX(pos.x() - m_menu->sizeHint().width() / 2);
m_menu->exec(pos);
});
}
void ModernSortButton::setSortItems(const QStringList& items)
{
m_options.clear();
m_menu->clear();
for (const QString& item : items) {
addSortOption(item, item);
}
}
void ModernSortButton::addSortOption(const QString& name, const QString& key)
{
m_options.append({name, key});
QAction* action = m_menu->addAction(name);
action->setData(static_cast<int>(m_options.size() - 1));
action->setCheckable(true);
if (m_options.size() == 1) {
m_currentIndex = 0;
m_descending = true;
updateAppearance();
}
}
QString ModernSortButton::currentSortKey() const
{
if (m_currentIndex >= 0 && m_currentIndex < m_options.size()) {
return m_options.at(m_currentIndex).key;
}
return QString();
}
bool ModernSortButton::isAscending() const
{
return !m_descending;
}
void ModernSortButton::setCurrentIndex(int index)
{
if (index >= 0 && index < m_options.size() && m_currentIndex != index) {
m_currentIndex = index;
updateAppearance();
}
}
void ModernSortButton::setDescending(bool descending)
{
if (m_descending != descending) {
m_descending = descending;
updateAppearance();
}
}
void ModernSortButton::onActionTriggered(QAction *action)
{
int index = action->data().toInt();
if (index == m_currentIndex) {
m_descending = !m_descending;
} else {
m_currentIndex = index;
m_descending = true;
}
updateAppearance();
Q_EMIT sortChanged(currentSortKey(), isAscending());
}
void ModernSortButton::updateAppearance()
{
const auto actionsList = m_menu->actions();
for (int i = 0; i < actionsList.size(); ++i) {
QAction* act = actionsList.at(i);
if (i == m_currentIndex) {
act->setChecked(true);
act->setText(m_options.at(i).name + (m_descending ? " ↓" : " ↑"));
} else {
act->setChecked(false);
act->setText(m_options.at(i).name);
}
}
if (m_currentIndex >= 0 && m_currentIndex < m_options.size()) {
setText(m_options.at(m_currentIndex).name + (m_descending ? " ↓" : " ↑"));
}
}
@@ -1,53 +0,0 @@
#ifndef MODERNSORTBUTTON_H
#define MODERNSORTBUTTON_H
#include <QPushButton>
#include <QMenu>
#include <QStringList>
#include <QList>
struct SortOption {
QString name;
QString key;
};
class ModernSortButton : public QPushButton {
Q_OBJECT
public:
explicit ModernSortButton(QWidget *parent = nullptr);
void setSortItems(const QStringList& items);
void addSortOption(const QString& name, const QString& key);
QString currentSortKey() const;
bool isAscending() const;
int currentIndex() const { return m_currentIndex; }
bool isDescending() const { return m_descending; }
void setCurrentIndex(int index);
void setDescending(bool descending);
Q_SIGNALS:
void sortChanged(const QString& sortKey, bool ascending);
private Q_SLOTS:
void onActionTriggered(QAction *action);
private:
void updateAppearance();
QMenu* m_menu;
QList<SortOption> m_options;
int m_currentIndex;
bool m_descending;
};
#endif
+10 -2
View File
@@ -1,4 +1,6 @@
#include "modernswitch.h"
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
#include <QPainterPath>
ModernSwitch::ModernSwitch(QWidget* parent)
@@ -14,8 +16,10 @@ ModernSwitch::ModernSwitch(QWidget* parent)
m_animation = new QPropertyAnimation(this, "offset", this);
m_animation->setDuration(150);
m_animation->setEasingCurve(QEasingCurve::InOutSine);
m_animation->setDuration(AnimationTokens::DurationMs::kMicro);
// A binary toggle wants a decisive settle, not the flat slow-in/slow-out of
// InOutSine; OutCubic reads as a proper switch.
m_animation->setEasingCurve(AnimationTokens::Curve::enter());
connect(this, &ModernSwitch::toggled, this, &ModernSwitch::updateAnimation);
}
@@ -110,6 +114,10 @@ void ModernSwitch::leaveEvent(QEvent* event) {
void ModernSwitch::updateAnimation(bool checked) {
m_animation->stop();
if (MotionPrefs::reduceMotion()) {
setOffset(checked ? 1.0 : 0.0);
return;
}
m_animation->setStartValue(m_offset);
m_animation->setEndValue(checked ? 1.0 : 0.0);
m_animation->start();
+49 -17
View File
@@ -7,6 +7,8 @@
#include <QPainter>
#include <QStyleOption>
#include <QEasingCurve>
#include "../tokens/AnimationTokens.h"
#include "../utils/motionprefs.h"
namespace {
@@ -66,12 +68,12 @@ ModernToast::ModernToast(QWidget *parent)
m_showOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_showOpacity->setDuration(200);
m_showOpacity->setEasingCurve(QEasingCurve::OutQuad);
m_showOpacity->setDuration(AnimationTokens::DurationMs::kFast);
m_showOpacity->setEasingCurve(AnimationTokens::Curve::fade());
m_showSlide = new QPropertyAnimation(this, "geometry", this);
m_showSlide->setDuration(350);
QEasingCurve slideCurve(QEasingCurve::OutBack);
m_showSlide->setDuration(AnimationTokens::DurationMs::kSlow);
QEasingCurve slideCurve(AnimationTokens::Curve::bounce());
slideCurve.setOvershoot(1.2);
m_showSlide->setEasingCurve(slideCurve);
@@ -83,16 +85,16 @@ ModernToast::ModernToast(QWidget *parent)
m_crtSquashY = new QPropertyAnimation(this, "geometry", this);
m_crtSquashY->setDuration(160);
m_crtSquashY->setEasingCurve(QEasingCurve::InCubic);
m_crtSquashY->setDuration(AnimationTokens::DurationMs::kFast);
m_crtSquashY->setEasingCurve(AnimationTokens::Curve::exit());
m_crtSquashX = new QPropertyAnimation(this, "geometry", this);
m_crtSquashX->setDuration(120);
m_crtSquashX->setEasingCurve(QEasingCurve::InExpo);
m_crtSquashX->setDuration(AnimationTokens::DurationMs::kMicro);
m_crtSquashX->setEasingCurve(AnimationTokens::Curve::exit());
m_hideOpacity = new QPropertyAnimation(this, "windowOpacity", this);
m_hideOpacity->setDuration(120);
m_hideOpacity->setEasingCurve(QEasingCurve::InCubic);
m_hideOpacity->setDuration(AnimationTokens::DurationMs::kMicro);
m_hideOpacity->setEasingCurve(AnimationTokens::Curve::exit());
m_crtPhase2Group = new QParallelAnimationGroup(this);
m_crtPhase2Group->addAnimation(m_crtSquashX);
@@ -116,8 +118,21 @@ ModernToast::ModernToast(QWidget *parent)
m_stayTimer = new QTimer(this);
m_stayTimer->setSingleShot(true);
connect(m_stayTimer, &QTimer::timeout, this, [this]() {
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: dismiss instantly, no CRT squash.
m_hideGroup->stop();
m_isCrtFadingOut = false;
m_comboCount = 0;
setWindowOpacity(0.0);
hide();
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
return;
}
m_isCrtFadingOut = true;
update();
update();
if (graphicsEffect()) {
@@ -143,29 +158,29 @@ ModernToast::ModernToast(QWidget *parent)
});
QEasingCurve expandCurve(QEasingCurve::OutBack);
QEasingCurve expandCurve(AnimationTokens::Curve::bounce());
expandCurve.setOvershoot(2.5);
m_shellExpand = new QPropertyAnimation(this, "geometry", this);
m_shellExpand->setDuration(160);
m_shellExpand->setDuration(AnimationTokens::DurationMs::kFast);
m_shellExpand->setEasingCurve(expandCurve);
m_textExpand = new QPropertyAnimation(this, "textScale", this);
m_textExpand->setDuration(160);
m_textExpand->setDuration(AnimationTokens::DurationMs::kFast);
m_textExpand->setEasingCurve(expandCurve);
m_expandGroup = new QParallelAnimationGroup(this);
m_expandGroup->addAnimation(m_shellExpand);
m_expandGroup->addAnimation(m_textExpand);
QEasingCurve shrinkCurve(QEasingCurve::OutCubic);
QEasingCurve shrinkCurve(AnimationTokens::Curve::enter());
m_shellShrink = new QPropertyAnimation(this, "geometry", this);
m_shellShrink->setDuration(220);
m_shellShrink->setDuration(AnimationTokens::DurationMs::kNormal);
m_shellShrink->setEasingCurve(shrinkCurve);
m_textShrink = new QPropertyAnimation(this, "textScale", this);
m_textShrink->setDuration(220);
m_textShrink->setDuration(AnimationTokens::DurationMs::kNormal);
m_textShrink->setEasingCurve(shrinkCurve);
m_shrinkGroup = new QParallelAnimationGroup(this);
@@ -284,6 +299,23 @@ void ModernToast::showWithAnimation(const QString &msg, int durationMs) {
show();
if (MotionPrefs::reduceMotion()) {
// Reduce-motion: present instantly at final geometry, no slide/bounce/combo pop.
m_showGroup->stop();
m_popSequentialGroup->stop();
m_hideGroup->stop();
m_isCrtFadingOut = false;
m_comboCount = 0;
if (graphicsEffect()) {
graphicsEffect()->setEnabled(true);
}
setTextScale(1.0);
setGeometry(m_baseGeometry);
setWindowOpacity(1.0);
m_stayTimer->start(durationMs);
return;
}
bool isFullyHidden = (windowOpacity() == 0.0 && m_showGroup->state() != QAbstractAnimation::Running);
bool isFadingOut = (m_hideGroup->state() == QAbstractAnimation::Running);
+20 -5
View File
@@ -72,7 +72,6 @@ void MpvController::warmupOnce() {
QThreadPool::globalInstance()->start([]() {
@@ -131,7 +130,7 @@ bool MpvController::init() {
mpv_set_option_string(m_mpv, "sub-hinting", "none");
mpv_set_option_string(m_mpv, "sub-ass-hinting", "none");
mpv_request_log_messages(m_mpv, "info");
mpv_request_log_messages(m_mpv, "debug");
@@ -146,8 +145,16 @@ bool MpvController::init() {
<< "| interpolation=" << (vsyncMode == "display-resample");
mpv_set_option_string(m_mpv, "target-colorspace-hint", "auto");
mpv_set_option_string(m_mpv, "target-trc", "auto");
const QString toneMapping = ConfigStore::instance()->get<QString>(
ConfigKeys::PlayerToneMapping, "auto");
mpv_set_option_string(m_mpv, "tone-mapping", toneMapping.toUtf8().constData());
qDebug() << "[MpvController] Applied HDR tone mapping"
<< "| target-colorspace-hint=auto"
<< "| target-trc=auto"
<< "| tone-mapping=" << toneMapping;
QString hwdec = ConfigStore::instance()->get<QString>(ConfigKeys::PlayerHwDec, "auto-copy");
mpv_set_option_string(m_mpv, "hwdec", hwdec.toUtf8().constData());
@@ -217,12 +224,17 @@ bool MpvController::init() {
observeProperty("eof-reached", MPV_FORMAT_FLAG);
observeProperty("cache-speed", MPV_FORMAT_INT64);
observeProperty("demuxer-cache-time", MPV_FORMAT_DOUBLE);
observeProperty("volume", MPV_FORMAT_DOUBLE);
observeProperty("mute", MPV_FORMAT_FLAG);
observeProperty("speed", MPV_FORMAT_DOUBLE);
observeProperty("track-list", MPV_FORMAT_NODE);
observeProperty("sid", MPV_FORMAT_NODE);
observeProperty("secondary-sid", MPV_FORMAT_NODE);
observeProperty("video-params/dw", MPV_FORMAT_DOUBLE);
observeProperty("video-params/dh", MPV_FORMAT_DOUBLE);
observeProperty("width", MPV_FORMAT_INT64);
observeProperty("height", MPV_FORMAT_INT64);
observeProperty("paused-for-cache", MPV_FORMAT_FLAG);
@@ -289,7 +301,10 @@ void MpvController::eventHandler() {
auto *msg = static_cast<mpv_event_log_message *>(event->data);
if (msg) {
qDebug().noquote() << QString("[MPV %1] %2").arg(msg->prefix, QString::fromUtf8(msg->text).trimmed());
qWarning().noquote()
<< QString("[MPV %1] %2")
.arg(msg->prefix,
QString::fromUtf8(msg->text).trimmed());
}
break;
}
+53 -9
View File
@@ -9,6 +9,7 @@
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QStringList>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTimer>
@@ -55,7 +56,6 @@ QUrl MpvHttpStreamRelay::prepare(const QUrl &targetUrl, const QString &serverId,
return {};
}
stop();
if (!m_server->isListening() && !m_server->listen(QHostAddress::LocalHost, 0))
{
qWarning() << "[MpvHttpStreamRelay] failed to listen"
@@ -66,9 +66,12 @@ QUrl MpvHttpStreamRelay::prepare(const QUrl &targetUrl, const QString &serverId,
m_targetUrl = targetUrl;
m_serverId = serverId;
m_streamToken = QUuid::createUuid().toString(QUuid::WithoutBraces);
m_network->setProxy(proxy);
m_routes.insert(m_streamToken, RouteState{targetUrl, serverId, proxy});
m_bytesRelayedSinceLastTick = 0;
m_speedTimer->start();
if (!m_speedTimer->isActive())
{
m_speedTimer->start();
}
QUrl localUrl;
localUrl.setScheme(QStringLiteral("http"));
@@ -102,6 +105,7 @@ void MpvHttpStreamRelay::stop()
Q_EMIT upstreamSpeedChanged(0);
}
m_connections.clear();
m_routes.clear();
m_targetUrl.clear();
m_serverId.clear();
m_streamToken.clear();
@@ -176,7 +180,7 @@ void MpvHttpStreamRelay::onSocketDisconnected(QTcpSocket *socket)
void MpvHttpStreamRelay::processRequest(QTcpSocket *socket, const QByteArray &requestData)
{
if (m_targetUrl.isEmpty() || m_streamToken.isEmpty())
if (m_routes.isEmpty())
{
writeError(socket, 503, "Relay target is not ready");
return;
@@ -198,14 +202,24 @@ void MpvHttpStreamRelay::processRequest(QTcpSocket *socket, const QByteArray &re
const QByteArray method = requestParts.at(0).toUpper();
const QByteArray path = requestParts.at(1);
const QByteArray expectedPrefix = QByteArray("/") + m_streamToken.toUtf8() + QByteArray("/");
if (!(method == "GET" || method == "HEAD") || !path.startsWith(expectedPrefix))
QByteArray requestPath = path;
const int queryStart = requestPath.indexOf('?');
if (queryStart >= 0)
{
requestPath.truncate(queryStart);
}
const QList<QByteArray> pathParts = requestPath.split('/');
const QByteArray tokenBytes = pathParts.size() >= 2 ? pathParts.at(1) : QByteArray();
const QString routeToken = QString::fromUtf8(tokenBytes);
const auto routeIt = m_routes.constFind(routeToken);
if (!(method == "GET" || method == "HEAD") || routeToken.isEmpty() || routeIt == m_routes.constEnd())
{
writeError(socket, 404, "Not found");
return;
}
QNetworkRequest request(m_targetUrl);
const RouteState route = routeIt.value();
QNetworkRequest request(route.targetUrl);
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
for (int i = 1; i < lines.size(); ++i)
@@ -239,12 +253,16 @@ void MpvHttpStreamRelay::processRequest(QTcpSocket *socket, const QByteArray &re
{
return;
}
it->routeToken = routeToken;
it->targetUrl = route.targetUrl;
it->serverId = route.serverId;
it->headOnly = method == "HEAD";
m_network->setProxy(route.proxy);
it->reply = it->headOnly ? m_network->head(request) : m_network->get(request);
it->reply->setReadBufferSize(kReplyReadBufferBytes);
qDebug() << "[MpvHttpStreamRelay] request"
<< "| method:" << method << "| target:" << LogRedactionUtils::url(m_targetUrl)
<< "| method:" << method << "| target:" << LogRedactionUtils::url(route.targetUrl)
<< "| range:" << request.rawHeader("Range");
QNetworkReply *reply = it->reply;
@@ -293,7 +311,7 @@ void MpvHttpStreamRelay::processRequest(QTcpSocket *socket, const QByteArray &re
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 0)
{
qWarning() << "[MpvHttpStreamRelay] upstream failed"
<< "| target:" << LogRedactionUtils::url(m_targetUrl)
<< "| target:" << LogRedactionUtils::url(it->targetUrl)
<< "| error:" << reply->errorString();
}
@@ -475,6 +493,7 @@ void MpvHttpStreamRelay::closeConnection(QTcpSocket *socket)
socket->disconnectFromHost();
}
socket->deleteLater();
cleanupUnusedRoutes();
}
void MpvHttpStreamRelay::recordRelayedBytes(qint64 bytes)
@@ -485,6 +504,31 @@ void MpvHttpStreamRelay::recordRelayedBytes(qint64 bytes)
}
}
void MpvHttpStreamRelay::cleanupUnusedRoutes()
{
QStringList activeTokens;
activeTokens.reserve(m_connections.size());
for (auto it = m_connections.cbegin(); it != m_connections.cend(); ++it)
{
if (!it->routeToken.isEmpty())
{
activeTokens.append(it->routeToken);
}
}
for (auto it = m_routes.begin(); it != m_routes.end();)
{
if (it.key() != m_streamToken && !activeTokens.contains(it.key()))
{
it = m_routes.erase(it);
}
else
{
++it;
}
}
}
QByteArray MpvHttpStreamRelay::reasonPhrase(int statusCode)
{
switch (statusCode)
@@ -29,11 +29,20 @@ private:
struct ConnectionState {
QByteArray buffer;
QNetworkReply *reply = nullptr;
QString routeToken;
QUrl targetUrl;
QString serverId;
bool headersSent = false;
bool headOnly = false;
bool upstreamFinished = false;
};
struct RouteState {
QUrl targetUrl;
QString serverId;
QNetworkProxy proxy;
};
void onNewConnection();
void onSocketReadyRead(QTcpSocket *socket);
void onSocketDisconnected(QTcpSocket *socket);
@@ -43,6 +52,7 @@ private:
void writeError(QTcpSocket *socket, int statusCode, const QByteArray &message);
void closeConnection(QTcpSocket *socket);
void recordRelayedBytes(qint64 bytes);
void cleanupUnusedRoutes();
static QByteArray reasonPhrase(int statusCode);
static bool isHopByHopHeader(QByteArray name);
@@ -51,6 +61,7 @@ private:
QNetworkAccessManager *m_network = nullptr;
QTimer *m_speedTimer = nullptr;
QHash<QTcpSocket *, ConnectionState> m_connections;
QHash<QString, RouteState> m_routes;
QUrl m_targetUrl;
QString m_serverId;
QString m_streamToken;
-296
View File
@@ -1,296 +0,0 @@
#include "mpvwidget.h"
#include "mpvhttpstreamrelay.h"
#include "../utils/logredactionutils.h"
#include <QOpenGLContext>
#include <QMetaObject>
#include <QCoreApplication>
#include <QSurfaceFormat>
#include <QUrl>
#include <QVariantMap>
#include "api/proxymanager.h"
MpvWidget::MpvWidget(QWidget *parent)
: QOpenGLWidget(parent), m_mpv_gl(nullptr) {
QSurfaceFormat format = this->format();
format.setSwapInterval(1);
this->setFormat(format);
m_controller = new MpvController(this);
m_streamRelay = new MpvHttpStreamRelay(this);
connect(m_streamRelay, &MpvHttpStreamRelay::upstreamSpeedChanged, this,
&MpvWidget::networkSpeedChanged);
connect(m_controller, &MpvController::positionChanged, this, &MpvWidget::positionChanged);
connect(m_controller, &MpvController::durationChanged, this, &MpvWidget::durationChanged);
connect(m_controller, &MpvController::playbackStateChanged, this, &MpvWidget::playbackStateChanged);
connect(m_controller, &MpvController::errorOccurred, this, &MpvWidget::errorOccurred);
m_controller->init();
}
MpvWidget::~MpvWidget() {
shutdown();
if (m_controller) {
m_controller->deleteLater();
m_controller = nullptr;
}
}
void MpvWidget::shutdown() {
if (!m_controller) return;
this->blockSignals(true);
m_controller->blockSignals(true);
m_controller->command(QVariantList{"stop"});
if (m_streamRelay) {
m_streamRelay->stop();
}
if (m_usingStreamRelay) {
m_usingStreamRelay = false;
Q_EMIT relayActiveChanged(false);
}
cleanupGL();
m_controller->forceCleanup();
}
void MpvWidget::cleanupGL() {
if (m_mpv_gl) {
if (isValid()) {
makeCurrent();
mpv_render_context_set_update_callback(m_mpv_gl, nullptr, nullptr);
mpv_render_context_free(m_mpv_gl);
doneCurrent();
} else {
qWarning() << "[MpvWidget] OpenGL context is not valid. Bypassing render context free.";
}
m_mpv_gl = nullptr;
}
}
void *MpvWidget::getProcAddress(void *ctx, const char *name) {
Q_UNUSED(ctx);
QOpenGLContext *glctx = QOpenGLContext::currentContext();
return glctx ? reinterpret_cast<void *>(glctx->getProcAddress(QByteArray(name))) : nullptr;
}
void MpvWidget::onMpvRenderUpdate(void *ctx) {
auto *self = static_cast<MpvWidget *>(ctx);
QMetaObject::invokeMethod(self, "maybeUpdate", Qt::QueuedConnection);
}
void MpvWidget::maybeUpdate() {
update();
}
void MpvWidget::initializeGL() {
initializeOpenGLFunctions();
QOpenGLContext *ctx = QOpenGLContext::currentContext();
if (ctx && ctx->format().majorVersion() < 2) {
qCritical() << "Fatal Error: OpenGL version is too low.";
emit errorOccurred(tr("当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。"));
return;
}
if (m_mpv_gl) {
mpv_render_context_set_update_callback(m_mpv_gl, nullptr, nullptr);
mpv_render_context_free(m_mpv_gl);
m_mpv_gl = nullptr;
}
mpv_opengl_init_params gl_init_params{
getProcAddress,
nullptr
};
mpv_render_param params[]{
{MPV_RENDER_PARAM_API_TYPE, const_cast<char *>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init_params},
{MPV_RENDER_PARAM_INVALID, nullptr}
};
if (mpv_render_context_create(&m_mpv_gl, m_controller->mpv(), params) < 0) {
emit errorOccurred(tr("OpenGL rendering initialization failed."));
return;
}
mpv_render_context_set_update_callback(m_mpv_gl, onMpvRenderUpdate, this);
if (!m_pendingUrl.isEmpty()) {
loadMediaNow(m_pendingUrl, m_pendingServerId, true);
m_pendingUrl.clear();
m_pendingServerId.clear();
}
}
void MpvWidget::paintGL() {
if (!m_mpv_gl) return;
int w = static_cast<int>(width() * devicePixelRatio());
int h = static_cast<int>(height() * devicePixelRatio());
if (w == 0 || h == 0) return;
int fbo = defaultFramebufferObject();
int flip_y = 1;
mpv_opengl_fbo mpfbo{fbo, w, h, 0};
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &mpfbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
{MPV_RENDER_PARAM_INVALID, nullptr}
};
mpv_render_context_render(m_mpv_gl, params);
}
void MpvWidget::resizeGL(int w, int h) {
Q_UNUSED(w);
Q_UNUSED(h);
}
void MpvWidget::loadMediaNow(const QString &url, const QString &serverId, bool wasPending) {
const QUrl loadQUrl(url);
const QNetworkProxy proxy =
serverId.isEmpty()
? ProxyManager::instance()->resolveForUrl(loadQUrl)
: ProxyManager::instance()->resolveForServerId(serverId);
const QString scheme = loadQUrl.scheme().toLower();
const bool isHttpStream =
scheme == QStringLiteral("http") || scheme == QStringLiteral("https");
const bool shouldRelay =
isHttpStream && proxy.type() != QNetworkProxy::NoProxy;
QString playbackUrl = url;
bool usingRelay = false;
if (shouldRelay && m_streamRelay) {
const QUrl localUrl = m_streamRelay->prepare(loadQUrl, serverId, proxy);
if (localUrl.isValid()) {
playbackUrl = localUrl.toString(QUrl::FullyEncoded);
usingRelay = true;
}
} else if (m_streamRelay) {
m_streamRelay->stop();
}
if (m_usingStreamRelay != usingRelay) {
m_usingStreamRelay = usingRelay;
Q_EMIT relayActiveChanged(usingRelay);
}
const QString mpvProxyValue =
usingRelay ? QString() : ProxyManager::toMpvHttpProxy(proxy);
const bool forceSeekable =
!usingRelay && !mpvProxyValue.isEmpty() && scheme == QStringLiteral("http");
m_controller->setProperty(QStringLiteral("http-proxy"), mpvProxyValue);
m_controller->setProperty(QStringLiteral("force-seekable"), forceSeekable);
QVariantMap loadOptions;
if (!mpvProxyValue.isEmpty()) {
loadOptions.insert(QStringLiteral("http-proxy"), mpvProxyValue);
}
if (forceSeekable) {
loadOptions.insert(QStringLiteral("force-seekable"), QStringLiteral("yes"));
}
if (!usingRelay && !mpvProxyValue.isEmpty() && scheme == QStringLiteral("https")) {
qWarning() << "[MpvWidget] MPV http-proxy is not applied to HTTPS streams by libmpv"
<< "| url:" << LogRedactionUtils::url(loadQUrl)
<< "| serverId:" << (serverId.isEmpty()
? QStringLiteral("<none>")
: serverId);
}
qInfo() << (wasPending
? QStringLiteral("[MpvWidget] http-proxy applied (pending)")
: QStringLiteral("[MpvWidget] http-proxy applied"))
<< "| url:" << LogRedactionUtils::url(loadQUrl)
<< "| serverId:" << (serverId.isEmpty()
? QStringLiteral("<none>")
: serverId)
<< "| mpvProxyValue:" << LogRedactionUtils::proxy(mpvProxyValue)
<< "| relay:" << usingRelay
<< "| playbackUrl:" << (usingRelay
? playbackUrl
: QStringLiteral("<direct>"))
<< "| forceSeekable:" << forceSeekable;
QVariantList loadCommand;
if (loadOptions.isEmpty()) {
loadCommand = QVariantList{QStringLiteral("loadfile"), playbackUrl};
} else {
loadCommand = QVariantList{QStringLiteral("loadfile"), playbackUrl,
QStringLiteral("replace"), -1,
loadOptions};
}
const int err = m_controller->command(loadCommand, nullptr);
if (err < 0 && !loadOptions.isEmpty()) {
qWarning() << "[MpvWidget] loadfile with indexed per-file network options failed, retrying legacy form"
<< "| error:" << mpv_error_string(err);
const int legacyErr = m_controller->command(
QVariantList{QStringLiteral("loadfile"), playbackUrl,
QStringLiteral("replace"), loadOptions},
nullptr);
if (legacyErr < 0) {
qWarning() << "[MpvWidget] legacy loadfile per-file options failed, retrying plain loadfile"
<< "| error:" << mpv_error_string(legacyErr);
m_controller->command(QVariantList{QStringLiteral("loadfile"), playbackUrl}, nullptr);
}
}
}
void MpvWidget::loadMedia(const QString &url, const QString &serverId) {
if (!m_mpv_gl) {
m_pendingUrl = url;
m_pendingServerId = serverId;
return;
}
loadMediaNow(url, serverId, false);
}
void MpvWidget::play() {
m_controller->setProperty("pause", false);
}
void MpvWidget::pause() {
m_controller->setProperty("pause", true);
}
void MpvWidget::stop() {
m_controller->command(QVariantList{"stop"});
}
void MpvWidget::seek(double positionInSeconds) {
m_controller->command(QVariantList{"seek", positionInSeconds, "absolute"});
}
-62
View File
@@ -1,62 +0,0 @@
#ifndef MPVWIDGET_H
#define MPVWIDGET_H
#include <QOpenGLWidget>
#include <QOpenGLFunctions>
#include <QString>
#include <mpv/render_gl.h>
#include "mpvcontroller.h"
class MpvHttpStreamRelay;
class MpvWidget : public QOpenGLWidget, protected QOpenGLFunctions {
Q_OBJECT
public:
explicit MpvWidget(QWidget *parent = nullptr);
~MpvWidget() override;
void shutdown();
void loadMedia(const QString &url, const QString &serverId = QString());
void play();
void pause();
void stop();
void seek(double positionInSeconds);
MpvController* controller() const { return m_controller; }
signals:
void positionChanged(double position);
void durationChanged(double duration);
void playbackStateChanged(bool isPaused);
void networkSpeedChanged(qint64 bytesPerSecond);
void relayActiveChanged(bool active);
void errorOccurred(const QString &errorMsg);
protected:
void initializeGL() override;
void paintGL() override;
void resizeGL(int w, int h) override;
private slots:
void cleanupGL();
void maybeUpdate();
private:
static void onMpvRenderUpdate(void *ctx);
static void *getProcAddress(void *ctx, const char *name);
void loadMediaNow(const QString &url, const QString &serverId, bool wasPending);
MpvController *m_controller;
MpvHttpStreamRelay *m_streamRelay = nullptr;
bool m_usingStreamRelay = false;
mpv_render_context *m_mpv_gl;
QString m_pendingUrl;
QString m_pendingServerId;
};
#endif
-258
View File
@@ -1,258 +0,0 @@
#include "playercombobox.h"
#include "../managers/thememanager.h"
#include <QPainter>
#include <QStylePainter>
#include <QStyleOptionComboBox>
#include <QMouseEvent>
#include <QAbstractItemView>
#include <QFileInfo>
#include <QApplication>
static constexpr int kItemHeight = 36;
static constexpr int kIconSize = 20;
static constexpr int kBadgeSize = 14;
static constexpr int kDeleteSize = 14;
static constexpr int kPadding = 10;
static constexpr int kSpacing = 6;
PlayerItemDelegate::PlayerItemDelegate(QObject *parent)
: QStyledItemDelegate(parent) {}
QSize PlayerItemDelegate::sizeHint(const QStyleOptionViewItem & ,
const QModelIndex & ) const {
return QSize(200, kItemHeight);
}
QRect PlayerItemDelegate::deleteButtonRect(const QStyleOptionViewItem &option) const {
int x = option.rect.right() - kPadding - kDeleteSize;
int y = option.rect.top() + (option.rect.height() - kDeleteSize) / 2;
return QRect(x, y, kDeleteSize, kDeleteSize);
}
int PlayerItemDelegate::hitDeleteButton(const QStyleOptionViewItem &option,
const QPoint &pos) const {
QRect btnRect = deleteButtonRect(option);
btnRect.adjust(-4, -4, 4, 4);
return btnRect.contains(pos) ? 0 : -1;
}
void PlayerItemDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const {
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
bool isDark = ThemeManager::instance()->isDarkMode();
QString source = index.data(PlayerComboBox::SourceRole).toString();
QString path = index.data(Qt::UserRole).toString();
QString text = index.data(Qt::DisplayRole).toString();
bool isCustomEntry = (source == PlayerComboBox::SourceCustom);
if (option.state & QStyle::State_Selected) {
QColor sel = isDark ? QColor(59, 130, 246, 50) : QColor(59, 130, 246, 25);
painter->fillRect(option.rect, sel);
} else if (option.state & QStyle::State_MouseOver) {
QColor hov = isDark ? QColor(255, 255, 255, 18) : QColor(0, 0, 0, 12);
painter->fillRect(option.rect, hov);
}
int x = option.rect.left() + kPadding;
int centerY = option.rect.center().y();
if (!isCustomEntry && !path.isEmpty()) {
QFileInfo fi(path);
QIcon exeIcon = m_iconProvider.icon(fi);
if (!exeIcon.isNull()) {
QRect iconRect(x, centerY - kIconSize / 2, kIconSize, kIconSize);
exeIcon.paint(painter, iconRect);
}
x += kIconSize + kSpacing;
}
int rightX = option.rect.right() - kPadding;
if (!isCustomEntry) {
QRect delRect(rightX - kDeleteSize, centerY - kDeleteSize / 2,
kDeleteSize, kDeleteSize);
QIcon closeIcon = ThemeManager::getAdaptiveIcon(
":/svg/dark/close.svg",
(option.state & QStyle::State_MouseOver)
? (isDark ? QColor("#EF4444") : QColor("#DC2626"))
: (isDark ? QColor("#94A3B8") : QColor("#64748B")));
closeIcon.paint(painter, delRect);
rightX -= kDeleteSize + kSpacing;
}
if (!isCustomEntry) {
QString badgeSvg = (source == PlayerComboBox::SourceAuto)
? ":/svg/dark/search.svg"
: ":/svg/dark/external-player.svg";
QRect badgeRect(rightX - kBadgeSize, centerY - kBadgeSize / 2,
kBadgeSize, kBadgeSize);
QIcon badgeIcon = ThemeManager::getAdaptiveIcon(
badgeSvg,
isDark ? QColor("#64748B") : QColor("#94A3B8"));
badgeIcon.paint(painter, badgeRect);
rightX -= kBadgeSize + kSpacing;
}
QRect textRect(x, option.rect.top(), rightX - x - kSpacing, option.rect.height());
QColor textColor = isDark ? QColor("#E2E8F0") : QColor("#1E293B");
if (option.state & QStyle::State_Selected) {
textColor = isDark ? QColor("#93C5FD") : QColor("#2563EB");
}
painter->setPen(textColor);
QFont f = option.font;
f.setPointSizeF(9.5);
painter->setFont(f);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine,
painter->fontMetrics().elidedText(text, Qt::ElideMiddle, textRect.width()));
painter->restore();
}
PlayerComboBox::PlayerComboBox(QWidget *parent) : ModernComboBox(parent) {
m_delegate = new PlayerItemDelegate(this);
setItemDelegate(m_delegate);
view()->installEventFilter(this);
view()->viewport()->installEventFilter(this);
}
void PlayerComboBox::addPlayer(const QString &displayName, const QString &path,
const QString &source) {
int idx = count();
for (int i = 0; i < count(); ++i) {
if (itemData(i, SourceRole).toString() == SourceCustom) {
idx = i;
break;
}
}
insertItem(idx, displayName, path);
setItemData(idx, source, SourceRole);
updateGeometry();
}
void PlayerComboBox::removePlayer(int index) {
if (index < 0 || index >= count()) return;
QString source = itemData(index, SourceRole).toString();
if (source == SourceCustom) return;
QString path = itemData(index).toString();
bool wasCurrent = (currentIndex() == index);
blockSignals(true);
removeItem(index);
blockSignals(false);
updateGeometry();
if (wasCurrent && count() > 0) {
int newIdx = qMin(index, count() - 1);
if (itemData(newIdx, SourceRole).toString() == SourceCustom && newIdx > 0) {
newIdx--;
}
setCurrentIndex(newIdx);
}
emit playerRemoved(index, path);
}
void PlayerComboBox::removeAutoDetected() {
for (int i = count() - 1; i >= 0; --i) {
if (itemData(i, SourceRole).toString() == SourceAuto) {
removeItem(i);
}
}
updateGeometry();
}
QString PlayerComboBox::currentPlayerPath() const {
return currentData(Qt::UserRole).toString();
}
void PlayerComboBox::paintEvent(QPaintEvent *) {
QStylePainter painter(this);
QStyleOptionComboBox opt;
initStyleOption(&opt);
QString text = opt.currentText;
opt.currentText = "";
painter.drawComplexControl(QStyle::CC_ComboBox, opt);
const int arrowWidth = 32;
const int iconSz = 18;
const int iconPad = 6;
QRect availRect = rect().adjusted(0, 0, -arrowWidth, 0);
QString path = currentData(Qt::UserRole).toString();
QString source = currentData(SourceRole).toString();
int textLeft = availRect.left();
if (source != SourceCustom && !path.isEmpty()) {
QFileInfo fi(path);
QIcon exeIcon = m_iconProvider.icon(fi);
if (!exeIcon.isNull()) {
int iconY = availRect.center().y() - iconSz / 2;
int iconX = availRect.left() + iconPad;
QRect iconRect(iconX, iconY, iconSz, iconSz);
exeIcon.paint(&painter, iconRect);
textLeft = iconX + iconSz + iconPad;
}
}
QRect textRect(textLeft, availRect.top(), availRect.right() - textLeft, availRect.height());
painter.setPen(opt.palette.color(QPalette::ButtonText));
painter.setFont(this->font());
painter.drawText(textRect, Qt::AlignCenter | Qt::TextSingleLine, text);
}
bool PlayerComboBox::eventFilter(QObject *obj, QEvent *event) {
if (event->type() == QEvent::MouseButtonRelease) {
auto *me = static_cast<QMouseEvent *>(event);
QAbstractItemView *v = view();
QModelIndex idx = v->indexAt(me->pos());
if (idx.isValid()) {
QString source = idx.data(SourceRole).toString();
if (source != SourceCustom) {
QStyleOptionViewItem opt;
opt.rect = v->visualRect(idx);
opt.state = QStyle::State_Enabled;
if (m_delegate->hitDeleteButton(opt, me->pos()) >= 0) {
removePlayer(idx.row());
event->accept();
return true;
}
}
}
}
return ModernComboBox::eventFilter(obj, event);
}
-72
View File
@@ -1,72 +0,0 @@
#ifndef PLAYERCOMBOBOX_H
#define PLAYERCOMBOBOX_H
#include "moderncombobox.h"
#include <QStyledItemDelegate>
#include <QFileIconProvider>
class PlayerItemDelegate : public QStyledItemDelegate {
Q_OBJECT
public:
explicit PlayerItemDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
int hitDeleteButton(const QStyleOptionViewItem &option,
const QPoint &pos) const;
signals:
void deleteClicked(int index);
private:
QRect deleteButtonRect(const QStyleOptionViewItem &option) const;
mutable QFileIconProvider m_iconProvider;
};
class PlayerComboBox : public ModernComboBox {
Q_OBJECT
public:
static constexpr const char *SourceAuto = "auto";
static constexpr const char *SourceManual = "manual";
static constexpr const char *SourceCustom = "custom";
static constexpr int SourceRole = Qt::UserRole + 1;
explicit PlayerComboBox(QWidget *parent = nullptr);
void addPlayer(const QString &displayName, const QString &path,
const QString &source);
void removePlayer(int index);
void removeAutoDetected();
QString currentPlayerPath() const;
signals:
void playerRemoved(int index, const QString &path);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
private:
PlayerItemDelegate *m_delegate;
QFileIconProvider m_iconProvider;
};
#endif
@@ -1,7 +1,7 @@
#include "playerdanmakucontroller.h"
#include "mpvwidget.h"
#include "nativedanmakuoverlay.h"
#include "mpvcontroller.h"
#include "idanmakusurface.h"
#include "../utils/danmakurendererutils.h"
#include "../utils/subtitlestyleutils.h"
#include <config/config_keys.h>
@@ -81,32 +81,32 @@ bool shouldInterpolationBeEnabled()
}
PlayerDanmakuController::PlayerDanmakuController(QEmbyCore *core,
MpvWidget *mpvWidget,
NativeDanmakuOverlay *nativeDanmakuOverlay,
MpvController *mpvController,
IDanmakuSurface *danmakuSurface,
QObject *parent)
: QObject(parent),
m_core(core),
m_mpvWidget(mpvWidget),
m_nativeDanmakuOverlay(nativeDanmakuOverlay)
m_mpvController(mpvController),
m_danmakuSurface(danmakuSurface)
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return;
}
if (m_nativeDanmakuOverlay && m_core && m_core->danmakuService()) {
m_nativeDanmakuOverlay->setRenderOptions(
if (m_danmakuSurface && m_core && m_core->danmakuService()) {
m_danmakuSurface->setRenderOptions(
m_core->danmakuService()->renderOptions());
m_nativeDanmakuOverlay->setDanmakuVisible(false);
m_danmakuSurface->setDanmakuVisible(false);
}
connect(m_mpvWidget->controller(), &MpvController::fileLoaded, this,
connect(m_mpvController, &MpvController::fileLoaded, this,
[this]() {
m_fileLoaded = true;
syncSubtitleSelectionFromTrackList();
updateDanmakuPresentation();
});
connect(m_mpvWidget->controller(), &MpvController::propertyChanged, this,
connect(m_mpvController, &MpvController::propertyChanged, this,
[this](const QString &property, const QVariant &) {
if (property == QLatin1String("track-list")) {
onTrackListChanged();
@@ -132,6 +132,17 @@ PlayerDanmakuController::PlayerDanmakuController(QEmbyCore *core,
});
}
void PlayerDanmakuController::setDanmakuSurface(IDanmakuSurface *surface)
{
m_danmakuSurface = surface;
if (m_danmakuSurface && m_core && m_core->danmakuService()) {
m_danmakuSurface->setRenderOptions(
m_core->danmakuService()->renderOptions());
m_danmakuSurface->setDanmakuVisible(false);
updateDanmakuPresentation();
}
}
void PlayerDanmakuController::setPlaybackContext(const PlayerLaunchContext &context)
{
++m_requestSerial;
@@ -152,8 +163,8 @@ void PlayerDanmakuController::setPlaybackContext(const PlayerLaunchContext &cont
m_visible = true;
m_loading = false;
m_nativePayloadDirty = false;
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->clearDanmaku();
if (m_danmakuSurface) {
m_danmakuSurface->clearDanmaku();
}
qDebug().noquote()
@@ -202,8 +213,8 @@ void PlayerDanmakuController::clearPlaybackContext()
m_visible = true;
m_loading = false;
m_nativePayloadDirty = false;
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->clearDanmaku();
if (m_danmakuSurface) {
m_danmakuSurface->clearDanmaku();
}
qDebug() << "[Danmaku][Player] Cleared playback context";
emit stateChanged();
@@ -282,12 +293,12 @@ QString PlayerDanmakuController::activeEndpointId() const
QList<QVariantMap> PlayerDanmakuController::contentSubtitleTracks() const
{
QList<QVariantMap> tracks;
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return tracks;
}
const QVariantList trackList =
m_mpvWidget->controller()->getProperty(QStringLiteral("track-list")).toList();
m_mpvController->getProperty(QStringLiteral("track-list")).toList();
for (const QVariant &value : trackList) {
QVariantMap trackMap = value.toMap();
if (trackMap.value(QStringLiteral("type")).toString() !=
@@ -308,7 +319,7 @@ QList<QVariantMap> PlayerDanmakuController::contentSubtitleTracks() const
void PlayerDanmakuController::selectSubtitleTrack(const QVariant &data)
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return;
}
@@ -397,8 +408,8 @@ void PlayerDanmakuController::reload(const QString &manualKeyword)
m_commentCount = 0;
m_loading = false;
m_nativePayloadDirty = false;
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->clearDanmaku();
if (m_danmakuSurface) {
m_danmakuSurface->clearDanmaku();
}
qDebug().noquote()
<< "[Danmaku][Player] Reload requested"
@@ -433,8 +444,8 @@ void PlayerDanmakuController::loadFromCandidate(
m_commentCount = 0;
m_loading = false;
m_nativePayloadDirty = false;
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->clearDanmaku();
if (m_danmakuSurface) {
m_danmakuSurface->clearDanmaku();
}
qDebug().noquote()
<< "[Danmaku][Player] Load candidate requested"
@@ -549,8 +560,8 @@ void PlayerDanmakuController::attachDanmakuTrack()
return;
}
if (!m_fileLoaded || m_assFilePath.isEmpty() || !m_mpvWidget ||
!m_mpvWidget->controller()) {
if (!m_fileLoaded || m_assFilePath.isEmpty() || !m_mpvController ||
!m_mpvController) {
return;
}
@@ -558,7 +569,7 @@ void PlayerDanmakuController::attachDanmakuTrack()
qDebug().noquote()
<< "[Danmaku][Player] Attach danmaku track"
<< "| path:" << m_assFilePath;
m_mpvWidget->controller()->command(QVariantList{
m_mpvController->command(QVariantList{
QStringLiteral("sub-add"),
QDir::toNativeSeparators(m_assFilePath),
QStringLiteral("auto"),
@@ -569,12 +580,12 @@ void PlayerDanmakuController::attachDanmakuTrack()
void PlayerDanmakuController::refreshDanmakuTrackId(int remainingRetries)
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return;
}
const QVariantList trackList =
m_mpvWidget->controller()->getProperty(QStringLiteral("track-list")).toList();
m_mpvController->getProperty(QStringLiteral("track-list")).toList();
for (const QVariant &value : trackList) {
const QVariantMap trackMap = value.toMap();
if (trackMap.value(QStringLiteral("type")).toString() !=
@@ -607,7 +618,7 @@ void PlayerDanmakuController::refreshDanmakuTrackId(int remainingRetries)
void PlayerDanmakuController::removeDanmakuTrack()
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
m_danmakuTrackId = -1;
m_motionStabilityProfileApplied = false;
return;
@@ -617,7 +628,7 @@ void PlayerDanmakuController::removeDanmakuTrack()
qDebug().noquote()
<< "[Danmaku][Player] Remove danmaku track"
<< "| trackId:" << m_danmakuTrackId;
m_mpvWidget->controller()->command(
m_mpvController->command(
QVariantList{QStringLiteral("sub-remove"), m_danmakuTrackId});
}
m_danmakuTrackId = -1;
@@ -626,7 +637,7 @@ void PlayerDanmakuController::removeDanmakuTrack()
bool PlayerDanmakuController::prefersNativeRenderer() const
{
return m_nativeDanmakuOverlay &&
return m_danmakuSurface &&
DanmakuRendererUtils::isNativeRenderer(
ConfigStore::instance()->get<QString>(
ConfigKeys::PlayerDanmakuRenderer,
@@ -641,21 +652,21 @@ bool PlayerDanmakuController::shouldUseNativeRenderer() const
void PlayerDanmakuController::updateDanmakuPresentation()
{
const bool nativeRendererPreferred = prefersNativeRenderer();
if (nativeRendererPreferred && m_nativeDanmakuOverlay && m_core &&
if (nativeRendererPreferred && m_danmakuSurface && m_core &&
m_core->danmakuService()) {
m_nativeDanmakuOverlay->setRenderOptions(
m_danmakuSurface->setRenderOptions(
m_core->danmakuService()->renderOptions());
if (m_nativePayloadDirty) {
if (!m_commentPayload.isEmpty()) {
m_nativeDanmakuOverlay->setComments(m_commentPayload);
m_danmakuSurface->setComments(m_commentPayload);
} else {
m_nativeDanmakuOverlay->clearDanmaku();
m_danmakuSurface->clearDanmaku();
}
m_nativePayloadDirty = false;
}
} else if (m_nativeDanmakuOverlay && m_nativePayloadDirty &&
} else if (m_danmakuSurface && m_nativePayloadDirty &&
m_commentPayload.isEmpty()) {
m_nativeDanmakuOverlay->clearDanmaku();
m_danmakuSurface->clearDanmaku();
m_nativePayloadDirty = false;
}
@@ -669,8 +680,8 @@ void PlayerDanmakuController::updateDanmakuPresentation()
return;
}
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->setDanmakuVisible(false);
if (m_danmakuSurface) {
m_danmakuSurface->setDanmakuVisible(false);
}
if (isDanmakuVisible() && m_fileLoaded && !m_assFilePath.isEmpty() &&
@@ -684,11 +695,11 @@ void PlayerDanmakuController::updateDanmakuPresentation()
void PlayerDanmakuController::applyDanmakuMotionStabilityProfile()
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return;
}
auto *controller = m_mpvWidget->controller();
MpvController *controller = m_mpvController.data();
auto setOptionWithLog = [controller](const QString &name,
const QVariant &value) {
const int err = controller->setProperty(name, value);
@@ -724,12 +735,12 @@ void PlayerDanmakuController::applyDanmakuMotionStabilityProfile()
void PlayerDanmakuController::clearDanmakuMotionStabilityProfile()
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
m_motionStabilityProfileApplied = false;
return;
}
auto *controller = m_mpvWidget->controller();
MpvController *controller = m_mpvController.data();
auto setOptionWithLog = [controller](const QString &name,
const QVariant &value) {
const int err = controller->setProperty(name, value);
@@ -767,7 +778,7 @@ void PlayerDanmakuController::clearDanmakuMotionStabilityProfile()
void PlayerDanmakuController::applyTrackSelection()
{
if (!m_mpvWidget || !m_mpvWidget->controller()) {
if (!m_mpvController) {
return;
}
@@ -776,10 +787,10 @@ void PlayerDanmakuController::applyTrackSelection()
const bool useNativeRenderer =
shouldUseNativeRenderer() && isDanmakuVisible();
if (m_nativeDanmakuOverlay) {
m_nativeDanmakuOverlay->setBottomSubtitleProtected(
if (m_danmakuSurface) {
m_danmakuSurface->setBottomSubtitleProtected(
useNativeRenderer && dualSubtitle && m_selectedSubtitleTrackId > 0);
m_nativeDanmakuOverlay->setDanmakuVisible(useNativeRenderer);
m_danmakuSurface->setDanmakuVisible(useNativeRenderer);
}
if (useNativeRenderer) {
@@ -788,17 +799,17 @@ void PlayerDanmakuController::applyTrackSelection()
<< "| contentSubtitleTrackId:" << m_selectedSubtitleTrackId
<< "| dualSubtitle:" << dualSubtitle
<< "| commentCount:" << m_commentPayload.size();
m_mpvWidget->controller()->setProperty(QStringLiteral("secondary-sid"),
m_mpvController->setProperty(QStringLiteral("secondary-sid"),
QStringLiteral("no"));
if (dualSubtitle && m_selectedSubtitleTrackId > 0) {
m_mpvWidget->controller()->setProperty(QStringLiteral("sid"),
m_mpvController->setProperty(QStringLiteral("sid"),
m_selectedSubtitleTrackId);
} else {
m_mpvWidget->controller()->setProperty(QStringLiteral("sid"),
m_mpvController->setProperty(QStringLiteral("sid"),
QStringLiteral("no"));
}
clearDanmakuMotionStabilityProfile();
SubtitleStyleUtils::applyToController(m_mpvWidget->controller(), false);
SubtitleStyleUtils::applyToController(m_mpvController, false);
return;
}
@@ -808,36 +819,36 @@ void PlayerDanmakuController::applyTrackSelection()
<< "| danmakuTrackId:" << m_danmakuTrackId
<< "| contentSubtitleTrackId:" << m_selectedSubtitleTrackId
<< "| dualSubtitle:" << dualSubtitle;
m_mpvWidget->controller()->setProperty(QStringLiteral("sid"),
m_mpvController->setProperty(QStringLiteral("sid"),
m_danmakuTrackId);
if (dualSubtitle && m_selectedSubtitleTrackId > 0) {
m_mpvWidget->controller()->setProperty(QStringLiteral("secondary-sub-pos"),
m_mpvController->setProperty(QStringLiteral("secondary-sub-pos"),
92);
m_mpvWidget->controller()->setProperty(QStringLiteral("secondary-sid"),
m_mpvController->setProperty(QStringLiteral("secondary-sid"),
m_selectedSubtitleTrackId);
} else {
m_mpvWidget->controller()->setProperty(QStringLiteral("secondary-sid"),
m_mpvController->setProperty(QStringLiteral("secondary-sid"),
QStringLiteral("no"));
}
applyDanmakuMotionStabilityProfile();
SubtitleStyleUtils::applyToController(m_mpvWidget->controller(), true);
SubtitleStyleUtils::applyToController(m_mpvController, true);
return;
}
qDebug().noquote()
<< "[Danmaku][Player] Apply regular subtitle selection"
<< "| contentSubtitleTrackId:" << m_selectedSubtitleTrackId;
m_mpvWidget->controller()->setProperty(QStringLiteral("secondary-sid"),
m_mpvController->setProperty(QStringLiteral("secondary-sid"),
QStringLiteral("no"));
if (m_selectedSubtitleTrackId > 0) {
m_mpvWidget->controller()->setProperty(QStringLiteral("sid"),
m_mpvController->setProperty(QStringLiteral("sid"),
m_selectedSubtitleTrackId);
} else {
m_mpvWidget->controller()->setProperty(QStringLiteral("sid"),
m_mpvController->setProperty(QStringLiteral("sid"),
QStringLiteral("no"));
}
clearDanmakuMotionStabilityProfile();
SubtitleStyleUtils::applyToController(m_mpvWidget->controller(), false);
SubtitleStyleUtils::applyToController(m_mpvController, false);
}
QCoro::Task<void> PlayerDanmakuController::loadDanmakuTask(quint64 requestId,
@@ -10,18 +10,21 @@
#include <qcorotask.h>
class QEmbyCore;
class MpvWidget;
class NativeDanmakuOverlay;
class MpvController;
class IDanmakuSurface;
class PlayerDanmakuController : public QObject
{
Q_OBJECT
public:
explicit PlayerDanmakuController(QEmbyCore *core,
MpvWidget *mpvWidget,
NativeDanmakuOverlay *nativeDanmakuOverlay,
MpvController *mpvController,
IDanmakuSurface *danmakuSurface,
QObject *parent = nullptr);
// Optional late-bind for the QML surface (constructed after the controller).
void setDanmakuSurface(IDanmakuSurface *surface);
void setPlaybackContext(const PlayerLaunchContext &context);
void clearPlaybackContext();
@@ -78,8 +81,8 @@ private:
bool saveAsManualMatch);
QPointer<QEmbyCore> m_core;
QPointer<MpvWidget> m_mpvWidget;
QPointer<NativeDanmakuOverlay> m_nativeDanmakuOverlay;
QPointer<MpvController> m_mpvController;
IDanmakuSurface *m_danmakuSurface = nullptr;
PlayerLaunchContext m_launchContext;
DanmakuMediaContext m_mediaContext;
QString m_assFilePath;
@@ -1,813 +0,0 @@
#include "playerlongpresshandler.h"
#include <QAbstractAnimation>
#include <QDebug>
#include <QEasingCurve>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QPropertyAnimation>
#include <QStyle>
#include <QTimer>
#include <QWidget>
#include <cmath>
#include <config/config_keys.h>
#include <config/configstore.h>
namespace
{
void stopPropertyAnimationSafely(QPropertyAnimation *animation)
{
if (!animation || !animation->targetObject() ||
animation->state() == QAbstractAnimation::Stopped)
{
return;
}
animation->stop();
}
}
PlayerLongPressHandler::PlayerLongPressHandler(QWidget *parent)
: QObject(parent), m_parentWidget(parent)
{
m_keyLongPressTimer = new QTimer(this);
connect(m_keyLongPressTimer, &QTimer::timeout, this,
&PlayerLongPressHandler::onKeyLongPressTimeout);
m_mouseEdgeTimer = new QTimer(this);
connect(m_mouseEdgeTimer, &QTimer::timeout, this,
&PlayerLongPressHandler::onMouseEdgeLongPressTimeout);
m_pulseTimer = new QTimer(this);
m_pulseTimer->setInterval(260);
connect(m_pulseTimer, &QTimer::timeout, this,
&PlayerLongPressHandler::updateIndicatorPulse);
m_leftIndicator = createIndicator(
QStringLiteral(":/svg/player/fast-rewind.svg"),
m_leftIndicatorIcon, m_leftIndicatorText,
m_leftIndicatorOpacity, m_leftIndicatorOpacityAnim,
m_leftIndicatorGeometryAnim, m_leftIndicatorIconAnim);
m_rightIndicator = createIndicator(
QStringLiteral(":/svg/player/fast-forward.svg"),
m_rightIndicatorIcon, m_rightIndicatorText,
m_rightIndicatorOpacity, m_rightIndicatorOpacityAnim,
m_rightIndicatorGeometryAnim, m_rightIndicatorIconAnim);
}
QWidget *PlayerLongPressHandler::createIndicator(
const QString &iconPath, QLabel *&iconLabel, QLabel *&textLabel,
QGraphicsOpacityEffect *&opacityEffect,
QPropertyAnimation *&opacityAnim,
QPropertyAnimation *&geometryAnim,
QPropertyAnimation *&iconAnim)
{
auto *overlay = new QWidget(m_parentWidget);
overlay->setObjectName("playerMouseEdgeIndicator");
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
overlay->setAttribute(Qt::WA_TranslucentBackground, true);
overlay->setProperty("gestureActive", false);
overlay->setProperty("indicatorVisible", false);
overlay->hide();
auto *layout = new QHBoxLayout(overlay);
layout->setContentsMargins(18, 12, 18, 12);
layout->setSpacing(12);
layout->setAlignment(Qt::AlignCenter);
iconLabel = new QLabel(overlay);
iconLabel->setObjectName("playerMouseEdgeIndicatorIcon");
iconLabel->setFixedSize(18, 18);
iconLabel->setAlignment(Qt::AlignCenter);
iconLabel->setPixmap(QIcon(iconPath).pixmap(18, 18));
layout->addWidget(iconLabel, 0, Qt::AlignCenter);
textLabel = new QLabel(overlay);
textLabel->setObjectName("playerMouseEdgeIndicatorText");
textLabel->setAlignment(Qt::AlignCenter);
textLabel->hide();
layout->addWidget(textLabel, 0, Qt::AlignCenter);
opacityEffect = new QGraphicsOpacityEffect(overlay);
opacityEffect->setOpacity(0.0);
overlay->setGraphicsEffect(opacityEffect);
opacityAnim = new QPropertyAnimation(opacityEffect, "opacity", this);
opacityAnim->setEasingCurve(QEasingCurve::OutCubic);
geometryAnim = new QPropertyAnimation(overlay, "geometry", this);
geometryAnim->setEasingCurve(QEasingCurve::OutCubic);
iconAnim = new QPropertyAnimation(iconLabel, "pos", this);
iconAnim->setDuration(220);
iconAnim->setEasingCurve(QEasingCurve::InOutSine);
connect(opacityAnim, &QPropertyAnimation::finished, overlay,
[overlay, opacityEffect]()
{
if (!overlay->property("indicatorVisible").toBool() &&
opacityEffect && opacityEffect->opacity() <= 0.01)
{
overlay->hide();
}
});
return overlay;
}
QString PlayerLongPressHandler::longPressMode() const
{
const QString mode =
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerLongPressMode, "seek");
return mode == QStringLiteral("speed") ? mode : QStringLiteral("seek");
}
int PlayerLongPressHandler::longPressTriggerMs() const
{
const int triggerMs =
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerLongPressTriggerMs, "500").toInt();
return triggerMs > 0 ? triggerMs : 500;
}
double PlayerLongPressHandler::nextPlaybackRate(double currentRate) const
{
static const QList<double> kSpeedSteps = {2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0};
for (double rate : kSpeedSteps)
{
if (rate > currentRate + 0.001)
{
return rate;
}
}
return kSpeedSteps.constLast();
}
bool PlayerLongPressHandler::canStartPropertyAnimation(const QPropertyAnimation *animation) const
{
return animation && animation->targetObject();
}
bool PlayerLongPressHandler::isMouseEdgeLongPressEnabled() const
{
return ConfigStore::instance()->get<bool>(ConfigKeys::PlayerMouseEdgeLongPress, true);
}
QString PlayerLongPressHandler::formatSpeedText(double speed)
{
QString text = QString::number(speed, 'f', 2);
while (text.contains('.') && text.endsWith('0'))
{
text.chop(1);
}
if (text.endsWith('.'))
{
text.append('0');
}
return text + "X";
}
QString PlayerLongPressHandler::formatLongPressSeekText(double seconds)
{
QString text = QString::number(seconds, 'f', seconds >= 10.0 ? 0 : 1);
while (text.contains('.') && text.endsWith('0'))
{
text.chop(1);
}
if (text.endsWith('.'))
{
text.chop(1);
}
return text + "s";
}
void PlayerLongPressHandler::startKeyLongPress(int direction, bool silent)
{
m_teardown = false;
stopKeyLongPress(false);
m_keyDirection = direction;
m_keySeekRate = 2.0;
m_keyRestoreSpeed = 1.0;
m_keyCurrentSpeed = 1.0;
m_keySpeedTick = 0;
m_keyTriggered = false;
m_keySilent = silent;
m_keySpeedActive = false;
m_keyActiveMode = longPressMode();
const double step =
ConfigStore::instance()->get<QString>(ConfigKeys::PlayerSeekStep, "10").toDouble();
const bool longPressEnabled =
ConfigStore::instance()->get<bool>(ConfigKeys::PlayerLongPressSeek, true);
if (!longPressEnabled)
{
Q_EMIT seekRequested(direction * step, silent);
m_keyDirection = 0;
m_keySilent = false;
m_keyActiveMode.clear();
return;
}
if (m_keyActiveMode == QStringLiteral("seek"))
{
Q_EMIT seekRequested(direction * step, silent);
}
qDebug() << "[PlayerLongPressHandler] Direction key pressed"
<< "| direction:" << direction
<< "| mode:" << m_keyActiveMode
<< "| triggerMs:" << longPressTriggerMs();
m_keyLongPressTimer->setInterval(longPressTriggerMs());
m_keyLongPressTimer->start();
}
void PlayerLongPressHandler::stopKeyLongPress(bool applyShortPressFallback)
{
if (m_keyLongPressTimer)
{
m_keyLongPressTimer->stop();
}
if (m_keyDirection == 0)
{
return;
}
if (m_keyActiveMode == QStringLiteral("speed"))
{
if (!m_keySpeedActive && applyShortPressFallback)
{
const double step = ConfigStore::instance()
->get<QString>(ConfigKeys::PlayerSeekStep, "10")
.toDouble();
Q_EMIT seekRequested(m_keyDirection * step, m_keySilent);
}
}
m_keyDirection = 0;
m_keySeekRate = 2.0;
m_keyRestoreSpeed = 1.0;
m_keyCurrentSpeed = 1.0;
m_keySpeedTick = 0;
m_keyTriggered = false;
m_keySilent = false;
m_keySpeedActive = false;
m_keyActiveMode.clear();
updateIndicators();
}
void PlayerLongPressHandler::onKeyLongPressTimeout()
{
if (m_keyDirection == 0 || m_keyActiveMode.isEmpty())
{
return;
}
if (m_keyActiveMode == QStringLiteral("speed"))
{
if (!m_keyTriggered)
{
m_keyTriggered = true;
m_keySpeedActive = true;
m_keyCurrentSpeed = nextPlaybackRate(1.0);
m_keySpeedTick = 0;
const QString speedText = formatSpeedText(m_keyCurrentSpeed);
updateIndicators();
Q_EMIT toastRequested(tr("Speed: %1X").arg(speedText.chopped(1)));
qDebug() << "[PlayerLongPressHandler] Long press speed activated"
<< "| direction:" << m_keyDirection
<< "| targetSpeed:" << m_keyCurrentSpeed;
}
else if (m_keySpeedTick > 0 && m_keySpeedTick % 2 == 0)
{
const double nextSpeed = nextPlaybackRate(m_keyCurrentSpeed);
if (std::abs(nextSpeed - m_keyCurrentSpeed) > 0.001)
{
m_keyCurrentSpeed = nextSpeed;
const QString speedText = formatSpeedText(m_keyCurrentSpeed);
updateIndicators();
Q_EMIT toastRequested(tr("Speed: %1X").arg(speedText.chopped(1)));
qDebug() << "[PlayerLongPressHandler] Long press speed increased"
<< "| direction:" << m_keyDirection
<< "| targetSpeed:" << m_keyCurrentSpeed;
}
}
const double deltaSeconds = m_keyDirection * m_keyCurrentSpeed * 0.25;
Q_EMIT seekRequested(deltaSeconds, true);
++m_keySpeedTick;
m_keyLongPressTimer->setInterval(250);
return;
}
const bool wasTriggered = m_keyTriggered;
m_keyTriggered = true;
m_keyLongPressTimer->setInterval(100);
Q_EMIT seekRequested(m_keyDirection * m_keySeekRate, m_keySilent);
updateIndicators();
if (!wasTriggered)
{
qDebug() << "[PlayerLongPressHandler] Long press seek activated"
<< "| direction:" << m_keyDirection;
}
m_keySeekRate *= 1.15;
if (m_keySeekRate > 30.0)
{
m_keySeekRate = 30.0;
}
}
void PlayerLongPressHandler::startMouseEdgeLongPress(int direction)
{
m_teardown = false;
stopMouseEdgeLongPress();
if (!isMouseEdgeLongPressEnabled())
{
return;
}
m_mouseEdgeDirection = direction;
m_mouseEdgeTick = 0;
m_mouseEdgeSpeed = 1.0;
m_mouseEdgeActive = false;
qDebug() << "[PlayerLongPressHandler] Mouse edge long press armed"
<< "| direction:" << direction
<< "| triggerMs:" << longPressTriggerMs();
if (m_mouseEdgeTimer)
{
m_mouseEdgeTimer->setInterval(longPressTriggerMs());
m_mouseEdgeTimer->start();
}
}
bool PlayerLongPressHandler::stopMouseEdgeLongPress()
{
if (m_mouseEdgeTimer)
{
m_mouseEdgeTimer->stop();
}
const bool wasActive = m_mouseEdgeActive;
if (wasActive)
{
qDebug() << "[PlayerLongPressHandler] Mouse edge long press stopped"
<< "| direction:" << m_mouseEdgeDirection
<< "| speed:" << m_mouseEdgeSpeed;
}
m_mouseEdgeDirection = 0;
m_mouseEdgeTick = 0;
m_mouseEdgeSpeed = 1.0;
m_mouseEdgeActive = false;
updateIndicators();
return wasActive;
}
void PlayerLongPressHandler::onMouseEdgeLongPressTimeout()
{
if (m_mouseEdgeDirection == 0)
{
return;
}
if (!m_mouseEdgeActive)
{
m_mouseEdgeActive = true;
m_mouseEdgeSpeed = nextPlaybackRate(1.0);
updateIndicators();
qDebug() << "[PlayerLongPressHandler] Mouse edge long press activated"
<< "| direction:" << m_mouseEdgeDirection
<< "| speed:" << m_mouseEdgeSpeed;
}
else if (m_mouseEdgeTick > 0 && m_mouseEdgeTick % 2 == 0)
{
const double nextSpeed = nextPlaybackRate(m_mouseEdgeSpeed);
if (std::abs(nextSpeed - m_mouseEdgeSpeed) > 0.001)
{
m_mouseEdgeSpeed = nextSpeed;
updateIndicators();
qDebug() << "[PlayerLongPressHandler] Mouse edge long press accelerated"
<< "| direction:" << m_mouseEdgeDirection
<< "| speed:" << m_mouseEdgeSpeed;
}
}
const double deltaSeconds = m_mouseEdgeDirection * m_mouseEdgeSpeed * 0.25;
Q_EMIT seekRequested(deltaSeconds, true);
++m_mouseEdgeTick;
if (m_mouseEdgeTimer)
{
m_mouseEdgeTimer->setInterval(250);
}
}
bool PlayerLongPressHandler::isMouseEdgeActive() const
{
return m_mouseEdgeActive;
}
int PlayerLongPressHandler::mouseEdgeLongPressDirection() const
{
return m_mouseEdgeDirection;
}
bool PlayerLongPressHandler::isKeyLongPressTriggered() const
{
return m_keyTriggered;
}
int PlayerLongPressHandler::keyLongPressDirection() const
{
return m_keyDirection;
}
int PlayerLongPressHandler::activeLongPressDirection() const
{
if (m_mouseEdgeActive && m_mouseEdgeDirection != 0)
{
return m_mouseEdgeDirection;
}
if (m_keyTriggered && m_keyDirection != 0)
{
return m_keyDirection;
}
return 0;
}
void PlayerLongPressHandler::setTeardown(bool teardown)
{
m_teardown = teardown;
if (!m_teardown)
{
return;
}
if (m_keyLongPressTimer)
{
m_keyLongPressTimer->stop();
}
if (m_mouseEdgeTimer)
{
m_mouseEdgeTimer->stop();
}
stopIndicatorAnimations();
}
QRect PlayerLongPressHandler::indicatorGeometry(int direction, bool active) const
{
const int w = m_parentWidget ? m_parentWidget->width() : 800;
const int h = m_parentWidget ? m_parentWidget->height() : 600;
const int indicatorWidth = active ? 128 : 60;
const int indicatorHeight = 54;
const int indicatorMargin = 28;
const int indicatorY = qMax(m_topHudHeight + 24, (h - indicatorHeight) / 2);
const int indicatorX = direction < 0
? indicatorMargin
: w - indicatorWidth - indicatorMargin;
return QRect(indicatorX, indicatorY, indicatorWidth, indicatorHeight);
}
void PlayerLongPressHandler::animateIndicator(
QWidget *overlay, int direction, bool active, bool visible)
{
if (!overlay)
{
return;
}
QGraphicsOpacityEffect *opacityEffect =
direction < 0 ? m_leftIndicatorOpacity : m_rightIndicatorOpacity;
QPropertyAnimation *opacityAnim =
direction < 0 ? m_leftIndicatorOpacityAnim : m_rightIndicatorOpacityAnim;
QPropertyAnimation *geometryAnim =
direction < 0 ? m_leftIndicatorGeometryAnim : m_rightIndicatorGeometryAnim;
const QRect targetGeometry = indicatorGeometry(direction, active);
if (!opacityEffect || !opacityAnim || !geometryAnim)
{
overlay->setGeometry(targetGeometry);
overlay->setVisible(visible);
return;
}
if (!opacityAnim->targetObject() || !geometryAnim->targetObject() || m_teardown)
{
overlay->setProperty("indicatorVisible", visible);
overlay->setGeometry(targetGeometry);
opacityEffect->setOpacity(visible ? (active ? 1.0 : 0.62) : 0.0);
overlay->setVisible(visible);
return;
}
stopPropertyAnimationSafely(opacityAnim);
stopPropertyAnimationSafely(geometryAnim);
if (visible)
{
const bool wasVisible = overlay->isVisible();
const QRect startGeometry = wasVisible
? overlay->geometry()
: targetGeometry.translated(
direction < 0 ? -14 : 14, 0);
const qreal startOpacity = wasVisible ? opacityEffect->opacity() : 0.0;
const qreal targetOpacity = active ? 1.0 : 0.62;
overlay->setProperty("indicatorVisible", true);
overlay->setGeometry(startGeometry);
overlay->show();
overlay->raise();
opacityEffect->setOpacity(startOpacity);
opacityAnim->setDuration(wasVisible ? 120 : 180);
opacityAnim->setStartValue(startOpacity);
opacityAnim->setEndValue(targetOpacity);
geometryAnim->setDuration(wasVisible ? 140 : 200);
geometryAnim->setStartValue(startGeometry);
geometryAnim->setEndValue(targetGeometry);
}
else
{
if (!overlay->isVisible())
{
opacityEffect->setOpacity(0.0);
return;
}
const QRect currentGeometry = overlay->geometry();
overlay->setProperty("indicatorVisible", false);
opacityAnim->setDuration(120);
opacityAnim->setStartValue(opacityEffect->opacity());
opacityAnim->setEndValue(0.0);
geometryAnim->setDuration(120);
geometryAnim->setStartValue(currentGeometry);
geometryAnim->setEndValue(
currentGeometry.translated(direction < 0 ? -10 : 10, 0));
}
if (canStartPropertyAnimation(opacityAnim))
{
opacityAnim->start();
}
else if (opacityEffect)
{
opacityEffect->setOpacity(visible ? (active ? 1.0 : 0.62) : 0.0);
}
if (canStartPropertyAnimation(geometryAnim))
{
geometryAnim->start();
}
else
{
overlay->setGeometry(visible ? targetGeometry
: overlay->geometry().translated(
direction < 0 ? -10 : 10, 0));
}
}
void PlayerLongPressHandler::animateIndicatorIcon(
QLabel *iconLabel, int direction, bool active, bool immediate)
{
if (!iconLabel)
{
return;
}
QPropertyAnimation *iconAnim =
direction < 0 ? m_leftIndicatorIconAnim : m_rightIndicatorIconAnim;
if (!iconAnim)
{
return;
}
const QPoint basePos = iconLabel->property("basePos").toPoint();
const int offset = (active && m_pulseForward)
? (direction < 0 ? -4 : 4)
: 0;
const QPoint targetPos = basePos + QPoint(offset, 0);
if (immediate || !iconAnim->targetObject() || m_teardown)
{
iconLabel->move(targetPos);
return;
}
stopPropertyAnimationSafely(iconAnim);
iconAnim->setStartValue(iconLabel->pos());
iconAnim->setEndValue(targetPos);
if (canStartPropertyAnimation(iconAnim))
{
iconAnim->start();
}
else
{
iconLabel->move(targetPos);
}
}
void PlayerLongPressHandler::setIndicatorState(
QWidget *overlay, QLabel *iconLabel, QLabel *textLabel, int direction,
bool active, const QString &speedText)
{
if (!overlay || !iconLabel || !textLabel)
{
return;
}
overlay->setProperty("gestureActive", active);
overlay->style()->unpolish(overlay);
overlay->style()->polish(overlay);
overlay->update();
if (speedText.isEmpty())
{
textLabel->clear();
textLabel->hide();
}
else
{
textLabel->setText(speedText);
textLabel->show();
}
if (overlay->layout())
{
overlay->layout()->activate();
}
iconLabel->setProperty("basePos", iconLabel->pos());
animateIndicatorIcon(iconLabel, direction, active, true);
animateIndicator(overlay, direction, active, true);
}
void PlayerLongPressHandler::updateIndicatorPulse()
{
if (m_teardown)
{
stopIndicatorAnimations();
return;
}
const int activeDirection = activeLongPressDirection();
if (activeDirection == 0)
{
if (m_pulseTimer)
{
m_pulseTimer->stop();
}
m_pulseForward = false;
animateIndicatorIcon(m_leftIndicatorIcon, -1, false, true);
animateIndicatorIcon(m_rightIndicatorIcon, 1, false, true);
return;
}
m_pulseForward = !m_pulseForward;
animateIndicatorIcon(m_leftIndicatorIcon, -1, activeDirection < 0);
animateIndicatorIcon(m_rightIndicatorIcon, 1, activeDirection > 0);
}
void PlayerLongPressHandler::updateIndicators()
{
if (m_teardown)
{
stopIndicatorAnimations();
if (m_leftIndicator)
{
m_leftIndicator->hide();
}
if (m_rightIndicator)
{
m_rightIndicator->hide();
}
animateIndicatorIcon(m_leftIndicatorIcon, -1, false, true);
animateIndicatorIcon(m_rightIndicatorIcon, 1, false, true);
return;
}
const int activeDirection = activeLongPressDirection();
if (activeDirection == 0)
{
if (m_leftIndicator)
{
m_leftIndicator->setProperty("gestureActive", false);
m_leftIndicator->style()->unpolish(m_leftIndicator);
m_leftIndicator->style()->polish(m_leftIndicator);
animateIndicator(m_leftIndicator, -1, false, false);
}
if (m_rightIndicator)
{
m_rightIndicator->setProperty("gestureActive", false);
m_rightIndicator->style()->unpolish(m_rightIndicator);
m_rightIndicator->style()->polish(m_rightIndicator);
animateIndicator(m_rightIndicator, 1, false, false);
}
updateIndicatorPulse();
return;
}
const bool fromMouse = m_mouseEdgeActive && m_mouseEdgeDirection != 0;
const QString indicatorText =
fromMouse
? formatSpeedText(m_mouseEdgeSpeed)
: (m_keyActiveMode == QStringLiteral("speed")
? formatSpeedText(m_keyCurrentSpeed)
: formatLongPressSeekText(m_keySeekRate));
const bool leftActive = activeDirection < 0;
const bool rightActive = activeDirection > 0;
setIndicatorState(
m_leftIndicator, m_leftIndicatorIcon,
m_leftIndicatorText, -1, leftActive,
leftActive ? indicatorText : QString());
setIndicatorState(
m_rightIndicator, m_rightIndicatorIcon,
m_rightIndicatorText, 1, rightActive,
rightActive ? indicatorText : QString());
if (m_pulseTimer && !m_pulseTimer->isActive())
{
m_pulseForward = false;
updateIndicatorPulse();
m_pulseTimer->start();
}
}
void PlayerLongPressHandler::stopIndicatorAnimations()
{
if (m_pulseTimer)
{
m_pulseTimer->stop();
}
const QList<QPropertyAnimation *> animations = {
m_leftIndicatorOpacityAnim, m_rightIndicatorOpacityAnim,
m_leftIndicatorGeometryAnim, m_rightIndicatorGeometryAnim,
m_leftIndicatorIconAnim, m_rightIndicatorIconAnim};
for (QPropertyAnimation *animation : animations)
{
stopPropertyAnimationSafely(animation);
}
}
void PlayerLongPressHandler::stopAllAnimations()
{
stopIndicatorAnimations();
}
void PlayerLongPressHandler::updateGeometry(int parentWidth, int parentHeight, int topHudHeight)
{
Q_UNUSED(parentHeight);
m_topHudHeight = topHudHeight;
auto syncGeometry = [this](QWidget *overlay, QPropertyAnimation *animation, int direction)
{
if (!overlay)
{
return;
}
const QRect targetGeometry = indicatorGeometry(
direction, overlay->property("gestureActive").toBool());
if (animation && animation->state() == QAbstractAnimation::Running)
{
animation->setEndValue(targetGeometry);
return;
}
overlay->setGeometry(targetGeometry);
};
syncGeometry(m_leftIndicator, m_leftIndicatorGeometryAnim, -1);
syncGeometry(m_rightIndicator, m_rightIndicatorGeometryAnim, 1);
}
@@ -1,123 +0,0 @@
#ifndef PLAYERLONGPRESSHANDLER_H
#define PLAYERLONGPRESSHANDLER_H
#include <QObject>
#include <QPoint>
#include <QRect>
#include <QString>
class QWidget;
class QLabel;
class QGraphicsOpacityEffect;
class QPropertyAnimation;
class QTimer;
class PlayerLongPressHandler : public QObject
{
Q_OBJECT
public:
explicit PlayerLongPressHandler(QWidget *parent);
void startKeyLongPress(int direction, bool silent);
void stopKeyLongPress(bool applyShortPressFallback = true);
void startMouseEdgeLongPress(int direction);
bool stopMouseEdgeLongPress();
bool isMouseEdgeLongPressEnabled() const;
bool isMouseEdgeActive() const;
int mouseEdgeLongPressDirection() const;
void updateGeometry(int parentWidth, int parentHeight, int topHudHeight);
void setTeardown(bool teardown);
void stopAllAnimations();
int activeLongPressDirection() const;
bool isKeyLongPressTriggered() const;
int keyLongPressDirection() const;
static QString formatSpeedText(double speed);
static QString formatLongPressSeekText(double seconds);
signals:
void seekRequested(double delta, bool silent);
void toastRequested(const QString &msg);
private:
QString longPressMode() const;
int longPressTriggerMs() const;
double nextPlaybackRate(double currentRate) const;
bool canStartPropertyAnimation(const QPropertyAnimation *animation) const;
void onKeyLongPressTimeout();
void onMouseEdgeLongPressTimeout();
void updateIndicators();
void updateIndicatorPulse();
QRect indicatorGeometry(int direction, bool active) const;
void animateIndicator(QWidget *overlay, int direction, bool active, bool visible);
void animateIndicatorIcon(QLabel *iconLabel, int direction, bool active, bool immediate = false);
void setIndicatorState(QWidget *overlay, QLabel *iconLabel, QLabel *textLabel,
int direction, bool active, const QString &speedText);
void stopIndicatorAnimations();
QWidget *createIndicator(const QString &iconPath, QLabel *&iconLabel, QLabel *&textLabel,
QGraphicsOpacityEffect *&opacityEffect,
QPropertyAnimation *&opacityAnim,
QPropertyAnimation *&geometryAnim,
QPropertyAnimation *&iconAnim);
QWidget *m_parentWidget = nullptr;
int m_topHudHeight = 0;
QTimer *m_keyLongPressTimer = nullptr;
int m_keyDirection = 0;
double m_keySeekRate = 2.0;
double m_keyRestoreSpeed = 1.0;
double m_keyCurrentSpeed = 1.0;
int m_keySpeedTick = 0;
bool m_keyTriggered = false;
bool m_keySilent = false;
bool m_keySpeedActive = false;
QString m_keyActiveMode;
QTimer *m_mouseEdgeTimer = nullptr;
int m_mouseEdgeDirection = 0;
int m_mouseEdgeTick = 0;
double m_mouseEdgeSpeed = 1.0;
bool m_mouseEdgeActive = false;
QWidget *m_leftIndicator = nullptr;
QWidget *m_rightIndicator = nullptr;
QLabel *m_leftIndicatorIcon = nullptr;
QLabel *m_rightIndicatorIcon = nullptr;
QLabel *m_leftIndicatorText = nullptr;
QLabel *m_rightIndicatorText = nullptr;
QGraphicsOpacityEffect *m_leftIndicatorOpacity = nullptr;
QGraphicsOpacityEffect *m_rightIndicatorOpacity = nullptr;
QPropertyAnimation *m_leftIndicatorOpacityAnim = nullptr;
QPropertyAnimation *m_rightIndicatorOpacityAnim = nullptr;
QPropertyAnimation *m_leftIndicatorGeometryAnim = nullptr;
QPropertyAnimation *m_rightIndicatorGeometryAnim = nullptr;
QPropertyAnimation *m_leftIndicatorIconAnim = nullptr;
QPropertyAnimation *m_rightIndicatorIconAnim = nullptr;
QTimer *m_pulseTimer = nullptr;
bool m_pulseForward = false;
bool m_teardown = false;
};
#endif
@@ -1,416 +0,0 @@
#include "playermediaswitcherpanel.h"
#include "horizontallistviewgallery.h"
#include "../utils/mediaitemutils.h"
#include <qembycore.h>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QTimer>
#include <QVBoxLayout>
namespace {
constexpr int kMovieGalleryHeight = 162;
constexpr int kSeasonGalleryHeight = 136;
constexpr int kEpisodeGalleryHeight = 110;
constexpr int kSpinnerSize = 28;
constexpr int kSpinnerContainerHeight = 60;
class SpinnerPainter : public QWidget {
public:
explicit SpinnerPainter(QWidget *parent = nullptr)
: QWidget(parent) {
setFixedSize(kSpinnerSize, kSpinnerSize);
setAttribute(Qt::WA_TranslucentBackground);
}
void setAngle(qreal angle) {
m_angle = angle;
update();
}
protected:
void paintEvent(QPaintEvent *) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
const qreal penWidth = 3.0;
const qreal margin = penWidth / 2.0;
const QRectF arcRect(margin, margin,
width() - penWidth, height() - penWidth);
QPen trackPen(QColor(255, 255, 255, 30), penWidth);
trackPen.setCapStyle(Qt::RoundCap);
p.setPen(trackPen);
p.drawArc(arcRect, 0, 360 * 16);
QPen sweepPen(QColor(96, 165, 250, 230), penWidth);
sweepPen.setCapStyle(Qt::RoundCap);
p.setPen(sweepPen);
const int startAngle = static_cast<int>(m_angle * 16);
p.drawArc(arcRect, startAngle, 90 * 16);
}
private:
qreal m_angle = 0.0;
};
}
PlayerMediaSwitcherPanel::PlayerMediaSwitcherPanel(QEmbyCore *core,
QWidget *parent)
: QFrame(parent) {
setObjectName("playerMediaSwitcherPanel");
setAttribute(Qt::WA_StyledBackground, true);
hide();
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(10, 6, 10, 6);
layout->setSpacing(3);
m_titleLabel = new QLabel(this);
m_titleLabel->setObjectName("playerMediaSwitcherTitle");
m_titleLabel->setWordWrap(true);
layout->addWidget(m_titleLabel);
m_statusLabel = new QLabel(this);
m_statusLabel->setObjectName("playerMediaSwitcherStatus");
m_statusLabel->setWordWrap(true);
layout->addWidget(m_statusLabel);
m_spinnerContainer = new QWidget(this);
m_spinnerContainer->setObjectName("playerMediaSwitcherLoading");
m_spinnerContainer->setFixedHeight(kSpinnerContainerHeight);
auto *spinnerLayout = new QHBoxLayout(m_spinnerContainer);
spinnerLayout->setContentsMargins(0, 0, 0, 0);
spinnerLayout->addStretch();
auto *spinner = new SpinnerPainter(m_spinnerContainer);
m_spinnerWidget = spinner;
spinnerLayout->addWidget(spinner);
spinnerLayout->addStretch();
m_spinnerTimer = new QTimer(this);
m_spinnerTimer->setInterval(16);
connect(m_spinnerTimer, &QTimer::timeout, this, [this, spinner]() {
m_spinnerAngle -= 4.0;
if (m_spinnerAngle < -360.0)
m_spinnerAngle += 360.0;
spinner->setAngle(m_spinnerAngle);
});
layout->addWidget(m_spinnerContainer);
m_movieGallery = new HorizontalListViewGallery(core, this);
m_movieGallery->setObjectName("playerMediaSwitcherMovieGallery");
m_movieGallery->setCardStyle(MediaCardDelegate::Poster);
m_movieGallery->setTileSize(QSize(90, 154));
m_movieGallery->setTextPixelSizes(10, 9);
m_movieGallery->setContentPadding(5);
m_movieGallery->setHoverControls(MediaCardDelegate::HoverControlPlay);
m_movieGallery->setFixedHeight(kMovieGalleryHeight);
layout->addWidget(m_movieGallery);
m_seasonsLabel = new QLabel(tr("Seasons"), this);
m_seasonsLabel->setObjectName("playerMediaSwitcherSectionTitle");
layout->addWidget(m_seasonsLabel);
m_seasonGallery = new HorizontalListViewGallery(core, this);
m_seasonGallery->setObjectName("playerMediaSwitcherSeasonGallery");
m_seasonGallery->setCardStyle(MediaCardDelegate::Poster);
m_seasonGallery->setTileSize(QSize(74, 132));
m_seasonGallery->setTextPixelSizes(10, 9);
m_seasonGallery->setContentPadding(4);
m_seasonGallery->setHoverControls(
MediaCardDelegate::HoverControls());
m_seasonGallery->setFixedHeight(kSeasonGalleryHeight);
layout->addWidget(m_seasonGallery);
m_episodesLabel = new QLabel(tr("Episodes"), this);
m_episodesLabel->setObjectName("playerMediaSwitcherSectionTitle");
layout->addWidget(m_episodesLabel);
m_episodeGallery = new HorizontalListViewGallery(core, this);
m_episodeGallery->setObjectName("playerMediaSwitcherEpisodeGallery");
m_episodeGallery->setCardStyle(MediaCardDelegate::LibraryTile);
m_episodeGallery->setTileSize(QSize(154, 102));
m_episodeGallery->setTextPixelSizes(10, 9);
m_episodeGallery->setContentPadding(6);
m_episodeGallery->setHoverControls(MediaCardDelegate::HoverControlPlay);
m_episodeGallery->setFixedHeight(kEpisodeGalleryHeight);
layout->addWidget(m_episodeGallery);
connect(m_movieGallery, &HorizontalListViewGallery::itemClicked, this,
&PlayerMediaSwitcherPanel::onPlayableItemClicked);
connect(m_movieGallery, &HorizontalListViewGallery::playRequested, this,
&PlayerMediaSwitcherPanel::onPlayableItemClicked);
connect(m_seasonGallery, &HorizontalListViewGallery::itemClicked, this,
&PlayerMediaSwitcherPanel::onSeasonItemClicked);
connect(m_seasonGallery, &HorizontalListViewGallery::playRequested, this,
&PlayerMediaSwitcherPanel::onSeasonItemClicked);
connect(m_episodeGallery, &HorizontalListViewGallery::itemClicked, this,
&PlayerMediaSwitcherPanel::onPlayableItemClicked);
connect(m_episodeGallery, &HorizontalListViewGallery::playRequested, this,
&PlayerMediaSwitcherPanel::onPlayableItemClicked);
setStatusMessage(QString());
applySeriesMode();
}
int PlayerMediaSwitcherPanel::preferredDrawerHeight() const {
auto *rootLayout = qobject_cast<QVBoxLayout *>(layout());
if (!rootLayout) {
return sizeHint().height();
}
const QMargins margins = rootLayout->contentsMargins();
int height = margins.top() + margins.bottom();
bool hasVisibleWidget = false;
const auto accumulateHeight = [&](const QWidget *widget) {
const int widgetHeight = visibleHeightForWidget(widget);
if (widgetHeight <= 0) {
return;
}
if (hasVisibleWidget) {
height += rootLayout->spacing();
}
height += widgetHeight;
hasVisibleWidget = true;
};
accumulateHeight(m_titleLabel);
accumulateHeight(m_statusLabel);
accumulateHeight(m_spinnerContainer);
accumulateHeight(m_movieGallery);
accumulateHeight(m_seasonsLabel);
accumulateHeight(m_seasonGallery);
accumulateHeight(m_episodesLabel);
accumulateHeight(m_episodeGallery);
return height;
}
void PlayerMediaSwitcherPanel::setLoadingState(const QString &title,
const QString &message,
bool seriesMode) {
m_titleLabel->setText(title);
m_isLoading = true;
m_loadingSeriesMode = seriesMode;
m_isSeriesMode = seriesMode;
m_currentMediaId.clear();
m_currentSeriesName.clear();
m_selectedSeasonId.clear();
m_movieItems.clear();
m_seasons.clear();
m_episodesBySeasonId.clear();
m_movieGallery->setItems(QList<MediaItem>{});
m_seasonGallery->setItems(QList<MediaItem>{});
m_episodeGallery->setItems(QList<MediaItem>{});
setStatusMessage(message);
applySeriesMode();
}
void PlayerMediaSwitcherPanel::setMovieItems(const QList<MediaItem> &items,
const QString &currentMediaId) {
m_isLoading = false;
m_loadingSeriesMode = false;
m_isSeriesMode = false;
m_currentMediaId = currentMediaId;
m_currentSeriesName.clear();
m_selectedSeasonId.clear();
m_movieItems = items;
m_seasons.clear();
m_episodesBySeasonId.clear();
m_titleLabel->setText(tr("Continue Watching"));
populateMovieGallery();
applySeriesMode();
}
void PlayerMediaSwitcherPanel::setSeriesData(
const QString &seriesName, const QList<MediaItem> &seasons,
const QHash<QString, QList<MediaItem>> &episodesBySeasonId,
const QString &currentMediaId) {
m_isLoading = false;
m_loadingSeriesMode = true;
m_isSeriesMode = true;
m_currentSeriesName = seriesName;
m_currentMediaId = currentMediaId;
m_movieItems.clear();
m_seasons = seasons;
m_episodesBySeasonId = episodesBySeasonId;
m_titleLabel->setText(seriesName.isEmpty() ? tr("Episodes") : seriesName);
m_selectedSeasonId.clear();
for (const MediaItem &season : m_seasons) {
const auto episodes = m_episodesBySeasonId.value(season.id);
for (const MediaItem &episode : episodes) {
if (episode.id == m_currentMediaId) {
m_selectedSeasonId = season.id;
break;
}
}
if (!m_selectedSeasonId.isEmpty()) {
break;
}
}
if (m_selectedSeasonId.isEmpty() && !m_seasons.isEmpty()) {
m_selectedSeasonId = m_seasons.first().id;
}
populateSeasonGallery();
populateEpisodeGallery();
applySeriesMode();
}
void PlayerMediaSwitcherPanel::onSeasonItemClicked(const MediaItem &season) {
if (season.id.isEmpty() || season.id == m_selectedSeasonId) {
return;
}
m_selectedSeasonId = season.id;
populateSeasonGallery();
populateEpisodeGallery();
applySeriesMode();
}
void PlayerMediaSwitcherPanel::onPlayableItemClicked(const MediaItem &item) {
if (item.id.isEmpty()) {
return;
}
Q_EMIT playRequested(item.id, formatResumeDisplayTitle(item),
item.userData.playbackPositionTicks);
}
void PlayerMediaSwitcherPanel::applySeriesMode() {
const bool hasStatus = !m_statusLabel->text().trimmed().isEmpty();
const bool showSeriesSections = !m_isLoading && m_isSeriesMode;
const bool hasSeasons = !m_seasons.isEmpty();
const bool hasEpisodeItems =
!m_episodesBySeasonId.value(currentSelectedSeasonId()).isEmpty();
m_statusLabel->setVisible(hasStatus);
m_spinnerContainer->setVisible(m_isLoading);
if (m_isLoading) {
m_spinnerAngle = 0.0;
m_spinnerTimer->start();
} else {
m_spinnerTimer->stop();
}
m_movieGallery->setVisible(!m_isLoading && !m_isSeriesMode && !hasStatus);
m_seasonsLabel->setVisible(showSeriesSections && hasSeasons);
m_seasonGallery->setVisible(showSeriesSections && hasSeasons);
m_episodesLabel->setVisible(showSeriesSections && hasEpisodeItems);
m_episodeGallery->setVisible(showSeriesSections && hasEpisodeItems);
setFixedHeight(preferredDrawerHeight());
}
void PlayerMediaSwitcherPanel::populateMovieGallery() {
setStatusMessage(QString());
m_movieGallery->setItems(m_movieItems);
m_movieGallery->setHighlightedItemId(m_currentMediaId);
const QString targetMovieId =
m_currentMediaId.isEmpty() && !m_movieItems.isEmpty()
? m_movieItems.first().id
: m_currentMediaId;
m_movieGallery->scrollToItemId(targetMovieId);
if (m_movieItems.isEmpty()) {
setStatusMessage(tr("No active media found."));
}
}
void PlayerMediaSwitcherPanel::populateSeasonGallery() {
setStatusMessage(QString());
const QString selectedSeasonId = currentSelectedSeasonId();
m_seasonGallery->setItems(m_seasons);
m_seasonGallery->setHighlightedItemId(selectedSeasonId);
m_seasonGallery->scrollToItemId(selectedSeasonId);
}
void PlayerMediaSwitcherPanel::populateEpisodeGallery() {
setStatusMessage(QString());
const QList<MediaItem> episodes =
m_episodesBySeasonId.value(currentSelectedSeasonId());
QString targetEpisodeId = m_currentMediaId;
bool hasCurrentEpisode = false;
for (const MediaItem &episode : episodes) {
if (episode.id == m_currentMediaId) {
hasCurrentEpisode = true;
break;
}
}
if (!hasCurrentEpisode && !episodes.isEmpty()) {
targetEpisodeId = episodes.first().id;
}
m_episodeGallery->setItems(episodes);
m_episodeGallery->setHighlightedItemId(m_currentMediaId);
m_episodeGallery->scrollToItemId(targetEpisodeId);
if (episodes.isEmpty()) {
setStatusMessage(tr("No episodes found."));
}
}
void PlayerMediaSwitcherPanel::setStatusMessage(const QString &message) {
m_statusLabel->setText(message);
}
QString PlayerMediaSwitcherPanel::formatEpisodeDisplayTitle(
const MediaItem &episode) const {
return MediaItemUtils::playbackTitle(episode, m_currentSeriesName);
}
QString PlayerMediaSwitcherPanel::formatResumeDisplayTitle(
const MediaItem &item) const {
if (item.type == QStringLiteral("Episode")) {
return formatEpisodeDisplayTitle(item);
}
return item.name;
}
QString PlayerMediaSwitcherPanel::currentSelectedSeasonId() const {
if (!m_selectedSeasonId.isEmpty()) {
return m_selectedSeasonId;
}
return m_seasons.isEmpty() ? QString() : m_seasons.first().id;
}
int PlayerMediaSwitcherPanel::visibleHeightForWidget(const QWidget *widget) const
{
if (!widget || !widget->isVisible()) {
return 0;
}
if (widget->minimumHeight() == widget->maximumHeight() &&
widget->minimumHeight() > 0) {
return widget->minimumHeight();
}
if (widget->height() > 0) {
return widget->height();
}
return widget->sizeHint().height();
}
@@ -1,73 +0,0 @@
#ifndef PLAYERMEDIASWITCHERPANEL_H
#define PLAYERMEDIASWITCHERPANEL_H
#include <QFrame>
#include <QHash>
#include <QList>
#include <models/media/mediaitem.h>
class QLabel;
class QTimer;
class HorizontalListViewGallery;
class QEmbyCore;
class QWidget;
class PlayerMediaSwitcherPanel : public QFrame {
Q_OBJECT
public:
explicit PlayerMediaSwitcherPanel(QEmbyCore *core, QWidget *parent = nullptr);
~PlayerMediaSwitcherPanel() override = default;
int preferredDrawerHeight() const;
void setLoadingState(const QString &title, const QString &message,
bool seriesMode);
void setMovieItems(const QList<MediaItem> &items, const QString &currentMediaId);
void setSeriesData(const QString &seriesName,
const QList<MediaItem> &seasons,
const QHash<QString, QList<MediaItem>> &episodesBySeasonId,
const QString &currentMediaId);
Q_SIGNALS:
void playRequested(QString mediaId, QString displayTitle,
long long startPositionTicks);
private Q_SLOTS:
void onSeasonItemClicked(const MediaItem &season);
void onPlayableItemClicked(const MediaItem &item);
private:
void applySeriesMode();
void populateMovieGallery();
void populateSeasonGallery();
void populateEpisodeGallery();
void setStatusMessage(const QString &message);
QString formatEpisodeDisplayTitle(const MediaItem &episode) const;
QString formatResumeDisplayTitle(const MediaItem &item) const;
QString currentSelectedSeasonId() const;
int visibleHeightForWidget(const QWidget *widget) const;
QLabel *m_titleLabel = nullptr;
QLabel *m_statusLabel = nullptr;
QLabel *m_seasonsLabel = nullptr;
QLabel *m_episodesLabel = nullptr;
QWidget *m_spinnerContainer = nullptr;
QWidget *m_spinnerWidget = nullptr;
QTimer *m_spinnerTimer = nullptr;
qreal m_spinnerAngle = 0.0;
HorizontalListViewGallery *m_movieGallery = nullptr;
HorizontalListViewGallery *m_seasonGallery = nullptr;
HorizontalListViewGallery *m_episodeGallery = nullptr;
QList<MediaItem> m_movieItems;
QList<MediaItem> m_seasons;
QHash<QString, QList<MediaItem>> m_episodesBySeasonId;
QString m_currentMediaId;
QString m_currentSeriesName;
QString m_selectedSeasonId;
bool m_isLoading = false;
bool m_loadingSeriesMode = false;
bool m_isSeriesMode = false;
};
#endif
-351
View File
@@ -1,351 +0,0 @@
#include "playerosdlayer.h"
#include <QAbstractAnimation>
#include <QGraphicsOpacityEffect>
#include <QIcon>
#include <QLabel>
#include <QProgressBar>
#include <QPropertyAnimation>
#include <QTimer>
#include <QWidget>
#include <cmath>
namespace
{
constexpr int kSeekLineHeight = 4;
constexpr int kSeekLabelMinWidth = 80;
constexpr int kSeekLabelMaxWidth = 148;
constexpr int kSeekLabelHeight = 28;
constexpr int kSeekLabelBottomOffset = 46;
constexpr int kSeekHorizontalMargin = 10;
constexpr int kSeekMarkerSize = 7;
constexpr int kVolumePanelWidth = 292;
constexpr int kVolumePanelHeight = 46;
constexpr int kVolumePanelMargin = 16;
constexpr int kVolumePanelTopMin = 72;
constexpr int kVolumeIconSize = 20;
constexpr int kVolumeTextWidth = 46;
constexpr int kVolumeContentPadding = 18;
constexpr int kVolumeContentGap = 14;
constexpr int kVolumeBarHeight = 5;
void stopFadeAnimationSafely(QPropertyAnimation *animation)
{
if (!animation || !animation->targetObject() ||
animation->state() == QAbstractAnimation::Stopped)
{
return;
}
animation->stop();
}
}
PlayerOsdLayer::PlayerOsdLayer(QWidget *parent)
: QObject(parent)
{
m_container = new QWidget(parent);
m_container->setObjectName("osdContainer");
m_container->setAttribute(Qt::WA_TransparentForMouseEvents);
m_container->hide();
m_opacity = new QGraphicsOpacityEffect(m_container);
m_opacity->setOpacity(0.0);
m_container->setGraphicsEffect(m_opacity);
m_fadeAnim = new QPropertyAnimation(m_opacity, "opacity", this);
m_fadeAnim->setDuration(250);
m_hideTimer = new QTimer(this);
m_hideTimer->setSingleShot(true);
connect(m_hideTimer, &QTimer::timeout, this, &PlayerOsdLayer::hide);
m_seekLine = new QProgressBar(m_container);
m_seekLine->setObjectName("osdSeekLine");
m_seekLine->setTextVisible(false);
m_seekLine->setFixedHeight(kSeekLineHeight);
m_seekTimeLabel = new QLabel(m_container);
m_seekTimeLabel->setObjectName("osdSeekTimeLabel");
m_seekTimeLabel->setAlignment(Qt::AlignCenter);
m_seekTimeLabel->setMinimumWidth(kSeekLabelMinWidth);
m_seekStem = new QWidget(m_container);
m_seekStem->setObjectName("osdSeekStem");
m_seekMarker = new QWidget(m_container);
m_seekMarker->setObjectName("osdSeekMarker");
m_volumePanel = new QWidget(m_container);
m_volumePanel->setObjectName("osdVolumePanel");
m_volumePanel->setAttribute(Qt::WA_TransparentForMouseEvents);
m_volumePanel->hide();
m_volumeIconLabel = new QLabel(m_volumePanel);
m_volumeIconLabel->setObjectName("osdVolumeIconLabel");
m_volumeIconLabel->setAlignment(Qt::AlignCenter);
m_volumeBar = new QProgressBar(m_volumePanel);
m_volumeBar->setObjectName("osdVolumeBar");
m_volumeBar->setTextVisible(false);
m_volumeBar->setRange(0, 100);
m_volumeBar->setFixedHeight(kVolumeBarHeight);
m_volumeLabel = new QLabel(m_volumePanel);
m_volumeLabel->setObjectName("osdVolumeLabel");
m_volumeLabel->setAlignment(Qt::AlignCenter);
}
void PlayerOsdLayer::showSeek(double position, double duration, const QString &timeText)
{
fadeIn();
m_seekLine->show();
m_seekTimeLabel->show();
m_seekStem->show();
m_seekMarker->show();
m_volumePanel->hide();
m_seekDuration = std::isfinite(duration) && duration > 0.0 ? duration : 0.0;
m_seekPosition = std::isfinite(position) ? position : 0.0;
if (m_seekDuration > 0.0)
{
m_seekPosition = qBound(0.0, m_seekPosition, m_seekDuration);
}
else
{
m_seekPosition = 0.0;
}
const int maximum = qMax(1, static_cast<int>(std::ceil(m_seekDuration)));
m_seekLine->setRange(0, maximum);
m_seekLine->setValue(qBound(0, static_cast<int>(std::round(m_seekPosition)), maximum));
m_seekTimeLabel->setText(timeText);
updateSeekLayout();
m_hideTimer->start(1500);
}
void PlayerOsdLayer::showVolume(int volumePercent, const QString &text, bool muted)
{
fadeIn();
m_seekLine->hide();
m_seekTimeLabel->hide();
m_seekStem->hide();
m_seekMarker->hide();
m_volumePanel->show();
m_volumePercent = qBound(0, volumePercent, 100);
const bool effectiveMuted = muted || m_volumePercent <= 0;
const QString iconPath =
effectiveMuted ? QStringLiteral(":/svg/player/volume-mute.svg") : QStringLiteral(":/svg/player/volume.svg");
m_volumeIconLabel->setPixmap(QIcon(iconPath).pixmap(kVolumeIconSize, kVolumeIconSize));
m_volumeBar->setValue(m_volumePercent);
m_volumeLabel->setText(text);
updateVolumeLayout();
m_hideTimer->start(1500);
}
void PlayerOsdLayer::hide()
{
if (m_opacity->opacity() <= 0.0)
{
return;
}
stopFadeAnimationSafely(m_fadeAnim);
m_fadeAnim->setStartValue(m_opacity->opacity());
m_fadeAnim->setEndValue(0.0);
if (canStartAnimation())
{
m_fadeAnim->start();
}
else
{
m_opacity->setOpacity(0.0);
if (m_container)
{
m_container->hide();
}
}
}
void PlayerOsdLayer::forceHide()
{
if (m_hideTimer)
{
m_hideTimer->stop();
}
stopFadeAnimationSafely(m_fadeAnim);
if (m_opacity)
{
m_opacity->setOpacity(0.0);
}
if (m_container)
{
m_container->hide();
}
}
void PlayerOsdLayer::updateGeometry(int parentWidth, int parentHeight)
{
m_parentWidth = parentWidth;
m_parentHeight = parentHeight;
m_container->setGeometry(0, 0, parentWidth, parentHeight);
m_seekLine->setGeometry(0, parentHeight - kSeekLineHeight, parentWidth, kSeekLineHeight);
updateSeekLayout();
updateVolumeLayout();
}
void PlayerOsdLayer::stopAnimations()
{
if (m_hideTimer)
{
m_hideTimer->stop();
}
if (m_fadeAnim)
{
stopFadeAnimationSafely(m_fadeAnim);
}
if (m_opacity)
{
m_opacity->setOpacity(0.0);
}
if (m_container)
{
m_container->hide();
}
}
bool PlayerOsdLayer::isVisible() const
{
return m_container && m_container->isVisible();
}
bool PlayerOsdLayer::isSeekLineVisible() const
{
return isVisible() && m_seekLine && !m_seekLine->isHidden();
}
QWidget *PlayerOsdLayer::container() const
{
return m_container;
}
void PlayerOsdLayer::updateSeekPosition(int position, const QString &timeText)
{
m_seekPosition = position;
if (m_seekDuration > 0.0)
{
m_seekPosition = qBound(0.0, m_seekPosition, m_seekDuration);
}
const int maximum = qMax(1, m_seekLine->maximum());
m_seekLine->setValue(qBound(0, static_cast<int>(std::round(m_seekPosition)), maximum));
m_seekTimeLabel->setText(timeText);
updateSeekLayout();
}
void PlayerOsdLayer::fadeIn()
{
if (!m_container->isVisible() || m_opacity->opacity() == 0.0)
{
m_container->show();
stopFadeAnimationSafely(m_fadeAnim);
m_fadeAnim->setStartValue(m_opacity->opacity());
m_fadeAnim->setEndValue(1.0);
if (canStartAnimation())
{
m_fadeAnim->start();
}
else
{
m_opacity->setOpacity(1.0);
}
}
}
bool PlayerOsdLayer::canStartAnimation() const
{
return m_fadeAnim && m_fadeAnim->targetObject();
}
void PlayerOsdLayer::updateSeekLayout()
{
if (!m_seekTimeLabel || !m_seekStem || !m_seekMarker || m_parentWidth <= 0 || m_parentHeight <= 0)
{
return;
}
const double progressRatio = m_seekDuration > 0.0 ? qBound(0.0, m_seekPosition / m_seekDuration, 1.0) : 0.0;
const int progressX = qBound(0, static_cast<int>(std::round(progressRatio * (m_parentWidth - 1))),
qMax(0, m_parentWidth - 1));
const int preferredWidth = m_seekTimeLabel->sizeHint().width();
const int labelWidth = qBound(kSeekLabelMinWidth, preferredWidth, kSeekLabelMaxWidth);
const int labelY = qMax(12, m_parentHeight - kSeekLabelBottomOffset);
int labelX = 0;
if (m_parentWidth <= labelWidth + kSeekHorizontalMargin * 2)
{
labelX = qMax(0, (m_parentWidth - labelWidth) / 2);
}
else
{
labelX = qBound(kSeekHorizontalMargin, progressX - labelWidth / 2,
m_parentWidth - labelWidth - kSeekHorizontalMargin);
}
m_seekTimeLabel->setGeometry(labelX, labelY, labelWidth, kSeekLabelHeight);
const int markerX = qBound(0, progressX - kSeekMarkerSize / 2, qMax(0, m_parentWidth - kSeekMarkerSize));
const int markerY = qMax(labelY + kSeekLabelHeight + 3, m_parentHeight - kSeekLineHeight - kSeekMarkerSize - 1);
m_seekMarker->setGeometry(markerX, markerY, kSeekMarkerSize, kSeekMarkerSize);
const int stemTop = labelY + kSeekLabelHeight + 2;
const int stemBottom = qMax(stemTop, markerY - 1);
const int stemHeight = qMax(2, stemBottom - stemTop);
const int stemX = qBound(0, progressX, qMax(0, m_parentWidth - 1));
m_seekStem->setGeometry(stemX, stemTop, 1, stemHeight);
}
void PlayerOsdLayer::updateVolumeLayout()
{
if (!m_volumePanel || !m_volumeIconLabel || !m_volumeBar || !m_volumeLabel || m_parentWidth <= 0 ||
m_parentHeight <= 0)
{
return;
}
const int panelWidth = qMin(kVolumePanelWidth, qMax(180, m_parentWidth - kVolumePanelMargin * 2));
const int panelX = qMax(0, (m_parentWidth - panelWidth) / 2);
const int preferredY = static_cast<int>(std::round(m_parentHeight * 0.22));
const int maxPanelY = qMax(0, m_parentHeight - kVolumePanelHeight - kVolumePanelMargin);
const int panelY = qMin(qMax(kVolumePanelTopMin, preferredY), maxPanelY);
m_volumePanel->setGeometry(panelX, panelY, panelWidth, kVolumePanelHeight);
const int availableWidth = panelWidth - kVolumeContentPadding * 2;
const int barWidth =
qMax(86, availableWidth - kVolumeIconSize - kVolumeTextWidth - kVolumeContentGap * 2);
const int contentWidth = kVolumeIconSize + kVolumeContentGap + barWidth + kVolumeContentGap + kVolumeTextWidth;
const int contentX = qMax(kVolumeContentPadding, (panelWidth - contentWidth) / 2);
const int iconY = (kVolumePanelHeight - kVolumeIconSize) / 2;
m_volumeIconLabel->setGeometry(contentX, iconY, kVolumeIconSize, kVolumeIconSize);
const int barX = contentX + kVolumeIconSize + kVolumeContentGap;
const int barY = (kVolumePanelHeight - kVolumeBarHeight) / 2;
m_volumeBar->setGeometry(barX, barY, barWidth, kVolumeBarHeight);
const int textX = barX + barWidth + kVolumeContentGap;
m_volumeLabel->setGeometry(textX, 0, kVolumeTextWidth, kVolumePanelHeight);
}
-75
View File
@@ -1,75 +0,0 @@
#ifndef PLAYEROSDLAYER_H
#define PLAYEROSDLAYER_H
#include <QObject>
class QWidget;
class QLabel;
class QProgressBar;
class QGraphicsOpacityEffect;
class QPropertyAnimation;
class QTimer;
class PlayerOsdLayer : public QObject
{
Q_OBJECT
public:
explicit PlayerOsdLayer(QWidget *parent);
void showSeek(double position, double duration, const QString &timeText);
void showVolume(int volumePercent, const QString &text, bool muted);
void hide();
void forceHide();
void updateGeometry(int parentWidth, int parentHeight);
void stopAnimations();
bool isVisible() const;
bool isSeekLineVisible() const;
QWidget *container() const;
void updateSeekPosition(int position, const QString &timeText);
private:
void fadeIn();
bool canStartAnimation() const;
void updateSeekLayout();
void updateVolumeLayout();
QWidget *m_container = nullptr;
QProgressBar *m_seekLine = nullptr;
QLabel *m_seekTimeLabel = nullptr;
QWidget *m_seekStem = nullptr;
QWidget *m_seekMarker = nullptr;
QWidget *m_volumePanel = nullptr;
QLabel *m_volumeIconLabel = nullptr;
QProgressBar *m_volumeBar = nullptr;
QLabel *m_volumeLabel = nullptr;
QGraphicsOpacityEffect *m_opacity = nullptr;
QPropertyAnimation *m_fadeAnim = nullptr;
QTimer *m_hideTimer = nullptr;
int m_parentWidth = 0;
int m_parentHeight = 0;
double m_seekPosition = 0.0;
double m_seekDuration = 0.0;
int m_volumePercent = 100;
};
#endif
@@ -134,6 +134,9 @@ QWidget *PlayerOverlayDialog::surfaceWidget() const
return m_surface;
}
// NOTE: no FadeHelper here — m_surface carries a QGraphicsDropShadowEffect,
// and nesting a QGraphicsOpacityEffect on the parent breaks rendering
// ("QPainter not active" spam, invisible surface). Open/close stay instant.
void PlayerOverlayDialog::open()
{
syncOverlayGeometry();
@@ -1,204 +0,0 @@
#include "playerstatisticsoverlay.h"
#include <QPainter>
#include <QPaintEvent>
#include <QTextLayout>
#include <QTextOption>
#include <cmath>
namespace
{
void configureWrappedLayout(QTextLayout *layout,
const QString &text,
const QFont &font,
qreal width)
{
if (!layout)
{
return;
}
layout->clearLayout();
layout->setText(text);
layout->setFont(font);
QTextOption option(Qt::AlignLeft | Qt::AlignTop);
option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
layout->setTextOption(option);
layout->beginLayout();
qreal y = 0.0;
while (true)
{
QTextLine line = layout->createLine();
if (!line.isValid())
{
break;
}
line.setLineWidth(width);
line.setPosition(QPointF(0.0, y));
y += line.height();
}
layout->endLayout();
}
qreal layoutHeight(const QTextLayout &layout)
{
qreal height = 0.0;
for (int i = 0; i < layout.lineCount(); ++i)
{
const QTextLine line = layout.lineAt(i);
height = qMax(height, line.y() + line.height());
}
return height;
}
}
PlayerStatisticsOverlay::PlayerStatisticsOverlay(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_NoSystemBackground);
setAttribute(Qt::WA_TransparentForMouseEvents);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
QFont overlayFont(QStringLiteral("Microsoft YaHei UI"));
overlayFont.setPointSize(10);
overlayFont.setWeight(QFont::DemiBold);
setFont(overlayFont);
}
void PlayerStatisticsOverlay::setLines(const QStringList &lines)
{
if (m_lines == lines)
{
return;
}
m_lines = lines;
updateGeometry();
update();
}
void PlayerStatisticsOverlay::setTextColor(const QColor &color)
{
if (m_textColor == color)
{
return;
}
m_textColor = color;
update();
}
void PlayerStatisticsOverlay::setShadowColor(const QColor &color)
{
if (m_shadowColor == color)
{
return;
}
m_shadowColor = color;
update();
}
QSize PlayerStatisticsOverlay::sizeHint() const
{
const int targetWidth = width() > 0 ? width() : 520;
const qreal contentWidth = qMax<qreal>(160.0, targetWidth - (m_horizontalPadding * 2));
qreal totalHeight = m_verticalPadding * 2;
bool hadVisibleLine = false;
for (const QString &line : m_lines)
{
if (line.trimmed().isEmpty())
{
if (hadVisibleLine)
{
totalHeight += m_sectionSpacing;
}
continue;
}
totalHeight += textHeightForWidth(line, contentWidth);
totalHeight += m_rowSpacing;
hadVisibleLine = true;
}
if (hadVisibleLine)
{
totalHeight -= m_rowSpacing;
}
return QSize(targetWidth, qMax(0, static_cast<int>(std::ceil(totalHeight))));
}
QSize PlayerStatisticsOverlay::minimumSizeHint() const
{
return sizeHint();
}
void PlayerStatisticsOverlay::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::TextAntialiasing, true);
painter.setRenderHint(QPainter::Antialiasing, true);
const qreal contentWidth = qMax<qreal>(160.0, width() - (m_horizontalPadding * 2));
qreal y = m_verticalPadding;
bool hadVisibleLine = false;
for (const QString &line : m_lines)
{
if (line.trimmed().isEmpty())
{
if (hadVisibleLine)
{
y += m_sectionSpacing;
}
continue;
}
const qreal blockHeight = textHeightForWidth(line, contentWidth);
const QPointF origin(m_horizontalPadding, y);
static const QList<QPointF> shadowOffsets = {
QPointF(-1.0, 0.0), QPointF(1.0, 0.0), QPointF(0.0, -1.0),
QPointF(0.0, 1.0), QPointF(-1.0, -1.0), QPointF(1.0, -1.0),
QPointF(-1.0, 1.0), QPointF(1.0, 1.0)};
for (const QPointF &offset : shadowOffsets)
{
drawTextBlock(&painter, line, origin + offset, contentWidth, m_shadowColor);
}
drawTextBlock(&painter, line, origin, contentWidth, m_textColor);
y += blockHeight + m_rowSpacing;
hadVisibleLine = true;
}
}
qreal PlayerStatisticsOverlay::textHeightForWidth(const QString &text, qreal width) const
{
QTextLayout layout;
configureWrappedLayout(&layout, text, font(), width);
return layoutHeight(layout);
}
void PlayerStatisticsOverlay::drawTextBlock(QPainter *painter,
const QString &text,
const QPointF &topLeft,
qreal width,
const QColor &color) const
{
QTextLayout layout;
configureWrappedLayout(&layout, text, font(), width);
painter->save();
painter->setPen(color);
layout.draw(painter, topLeft);
painter->restore();
}
@@ -1,49 +0,0 @@
#ifndef PLAYERSTATISTICSOVERLAY_H
#define PLAYERSTATISTICSOVERLAY_H
#include <QColor>
#include <QStringList>
#include <QWidget>
class QPainter;
class QPaintEvent;
class QPointF;
class PlayerStatisticsOverlay : public QWidget
{
public:
explicit PlayerStatisticsOverlay(QWidget *parent = nullptr);
void setLines(const QStringList &lines);
QStringList lines() const { return m_lines; }
QColor textColor() const { return m_textColor; }
void setTextColor(const QColor &color);
QColor shadowColor() const { return m_shadowColor; }
void setShadowColor(const QColor &color);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
private:
qreal textHeightForWidth(const QString &text, qreal width) const;
void drawTextBlock(QPainter *painter,
const QString &text,
const QPointF &topLeft,
qreal width,
const QColor &color) const;
QStringList m_lines;
QColor m_textColor = Qt::white;
QColor m_shadowColor = QColor(0, 0, 0, 230);
int m_horizontalPadding = 4;
int m_verticalPadding = 4;
int m_rowSpacing = 2;
int m_sectionSpacing = 8;
};
#endif
-87
View File
@@ -1,87 +0,0 @@
#include "playerwindow.h"
#include "../views/media/playerview.h"
#include <QPoint>
#include <QRect>
#include <QSize>
#include <QVBoxLayout>
#include <QWKWidgets/widgetwindowagent.h>
PlayerWindow::PlayerWindow(QEmbyCore* core, QWidget *parent)
: QWidget(parent)
{
#if (defined(Q_OS_MACOS) || defined(Q_OS_MAC)) && \
(QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
setWindowFlag(Qt::ExpandedClientAreaHint, true);
setWindowFlag(Qt::NoTitleBarBackgroundHint, true);
setAttribute(Qt::WA_ContentsMarginsRespectsSafeArea, false);
#endif
m_windowAgent = new QWK::WidgetWindowAgent(this);
m_windowAgent->setup(this);
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
m_windowAgent->setWindowAttribute("no-system-buttons", false);
#endif
#if defined(Q_OS_MAC)
m_windowAgent->setSystemButtonAreaCallback([](const QSize &size) {
return QRect(QPoint(0, 0), QSize(80, size.height()));
});
#endif
m_playerView = new PlayerView(core, this);
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(m_playerView);
QWidget* topHUD = m_playerView->findChild<QWidget*>("playerTopHUD");
if (topHUD) {
m_windowAgent->setTitleBar(topHUD);
}
#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC)
if (auto* minBtn = m_playerView->findChild<QWidget*>("hud-min-btn"))
m_windowAgent->setSystemButton(QWK::WindowAgentBase::Minimize, minBtn);
if (auto* maxBtn = m_playerView->findChild<QWidget*>("hud-max-btn"))
m_windowAgent->setSystemButton(QWK::WindowAgentBase::Maximize, maxBtn);
if (auto* closeBtn = m_playerView->findChild<QWidget*>("hud-close-btn"))
m_windowAgent->setSystemButton(QWK::WindowAgentBase::Close, closeBtn);
#endif
if (auto* backBtn = m_playerView->findChild<QWidget*>("hud-back-btn"))
m_windowAgent->setHitTestVisible(backBtn, true);
connect(m_playerView, &PlayerView::playerChromeVisibilityChanged, this,
&PlayerWindow::setMacSystemButtonsVisible);
connect(m_playerView, &PlayerView::playbackTitleChanged, this,
[this](const QString &title) { setWindowTitle(title); });
connect(m_playerView, &BaseView::navigateBack, this, [this]() {
close();
});
}
void PlayerWindow::playMedia(const QString &mediaId, const QString &title,
const QString &streamUrl, long long startPositionTicks,
const QVariant& sourceInfoVar)
{
setWindowTitle(title);
m_playerView->playMedia(mediaId, title, streamUrl, startPositionTicks, sourceInfoVar);
}
void PlayerWindow::setMacSystemButtonsVisible(bool visible)
{
#if defined(Q_OS_MACOS) || defined(Q_OS_MAC)
if (m_windowAgent) {
m_windowAgent->setWindowAttribute("no-system-buttons", !visible);
}
#else
Q_UNUSED(visible);
#endif
}
-33
View File
@@ -1,33 +0,0 @@
#ifndef PLAYERWINDOW_H
#define PLAYERWINDOW_H
#include <QWidget>
#include <QPointer>
class QEmbyCore;
class PlayerView;
namespace QWK {
class WidgetWindowAgent;
}
class PlayerWindow : public QWidget
{
Q_OBJECT
public:
explicit PlayerWindow(QEmbyCore* core, QWidget *parent = nullptr);
void playMedia(const QString &mediaId, const QString &title,
const QString &streamUrl, long long startPositionTicks = 0,
const QVariant& sourceInfoVar = QVariant());
private:
void setMacSystemButtonsVisible(bool visible);
PlayerView* m_playerView = nullptr;
QWK::WidgetWindowAgent* m_windowAgent = nullptr;
};
#endif
@@ -1,406 +0,0 @@
#include "searchcompleterpopup.h"
#include "../managers/thememanager.h"
#include <QAbstractItemView>
#include <QApplication>
#include <QFontMetrics>
#include <QIcon>
#include <QLinearGradient>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyledItemDelegate>
#include <utility>
namespace
{
constexpr int kPopupItemHeight = 40;
constexpr int kPopupItemHorizontalInset = 4;
constexpr int kPopupItemVerticalInset = 3;
constexpr int kPopupItemRadius = 13;
constexpr int kPopupItemIconBubbleSize = 22;
constexpr int kPopupItemIconSize = 14;
constexpr int kPopupItemLeftPadding = 12;
constexpr int kPopupItemRightPadding = 12;
constexpr int kPopupItemContentGap = 10;
constexpr int kPopupPanelChromeHeight = 2;
struct PopupItemPalette
{
QColor backgroundTop;
QColor backgroundBottom;
QColor border;
QColor text;
QColor textHighlight;
QColor iconBubble;
QColor icon;
};
PopupItemPalette popupItemPalette(bool darkMode, bool hovered, bool selected)
{
if (darkMode)
{
if (selected)
{
return {
QColor(QStringLiteral("#163356")),
QColor(QStringLiteral("#102B49")),
QColor(QStringLiteral("#3C73A7")),
QColor(QStringLiteral("#F8FAFC")),
QColor(QStringLiteral("#BFDBFE")),
QColor(QStringLiteral("#1D446C")),
QColor(QStringLiteral("#BFDBFE"))};
}
if (hovered)
{
return {
QColor(QStringLiteral("#162234")),
QColor(QStringLiteral("#101B2C")),
QColor(QStringLiteral("#28486B")),
QColor(QStringLiteral("#E6EEF8")),
QColor(QStringLiteral("#93C5FD")),
QColor(QStringLiteral("#17314E")),
QColor(QStringLiteral("#93C5FD"))};
}
return {
QColor(QStringLiteral("#111B2B")),
QColor(QStringLiteral("#0D1726")),
QColor(QStringLiteral("#1D2B3D")),
QColor(QStringLiteral("#CBD5E1")),
QColor(QStringLiteral("#93C5FD")),
QColor(QStringLiteral("#162233")),
QColor(QStringLiteral("#7F93AA"))};
}
if (selected)
{
return {
QColor(QStringLiteral("#EAF2FF")),
QColor(QStringLiteral("#E2EEFF")),
QColor(QStringLiteral("#C6D9F6")),
QColor(QStringLiteral("#1E40AF")),
QColor(QStringLiteral("#1D4ED8")),
QColor(QStringLiteral("#DDE9FF")),
QColor(QStringLiteral("#1D4ED8"))};
}
if (hovered)
{
return {
QColor(QStringLiteral("#F6FAFF")),
QColor(QStringLiteral("#EEF5FF")),
QColor(QStringLiteral("#D8E5F7")),
QColor(QStringLiteral("#284675")),
QColor(QStringLiteral("#2563EB")),
QColor(QStringLiteral("#EAF2FF")),
QColor(QStringLiteral("#2563EB"))};
}
return {
QColor(QStringLiteral("#F8FBFE")),
QColor(QStringLiteral("#F3F7FA")),
QColor(QStringLiteral("#E4EBF3")),
QColor(QStringLiteral("#334155")),
QColor(QStringLiteral("#2563EB")),
QColor(QStringLiteral("#EFF4FA")),
QColor(QStringLiteral("#7A8CA3"))};
}
class SearchCompleterItemDelegate : public QStyledItemDelegate
{
public:
explicit SearchCompleterItemDelegate(const SearchCompleterPopup *popup,
QObject *parent = nullptr)
: QStyledItemDelegate(parent), m_popup(popup)
{
}
QSize sizeHint(const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
Q_UNUSED(option);
Q_UNUSED(index);
return QSize(0, kPopupItemHeight);
}
void paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const override
{
if (!painter)
{
return;
}
const QString text = index.data(Qt::DisplayRole).toString().trimmed();
if (text.isEmpty())
{
return;
}
const bool darkMode = ThemeManager::instance()->isDarkMode();
const bool selected = option.state & QStyle::State_Selected;
const bool hovered = option.state & QStyle::State_MouseOver;
const PopupItemPalette palette =
popupItemPalette(darkMode, hovered, selected);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setRenderHint(QPainter::TextAntialiasing, true);
painter->setRenderHint(QPainter::SmoothPixmapTransform, true);
const QRect itemRect =
option.rect.adjusted(kPopupItemHorizontalInset,
kPopupItemVerticalInset,
-kPopupItemHorizontalInset,
-kPopupItemVerticalInset);
const QRectF cardRect =
QRectF(itemRect).adjusted(0.5, 0.5, -0.5, -0.5);
QPainterPath cardPath;
cardPath.addRoundedRect(cardRect, kPopupItemRadius, kPopupItemRadius);
QLinearGradient backgroundGradient(0, cardRect.top(), 0,
cardRect.bottom());
backgroundGradient.setColorAt(0.0, palette.backgroundTop);
backgroundGradient.setColorAt(1.0, palette.backgroundBottom);
painter->fillPath(cardPath, backgroundGradient);
painter->setPen(QPen(palette.border, 0.9));
painter->drawPath(cardPath);
const int iconBubbleY =
itemRect.top() + (itemRect.height() - kPopupItemIconBubbleSize) / 2;
const QRect iconBubbleRect(itemRect.left() + kPopupItemLeftPadding,
iconBubbleY,
kPopupItemIconBubbleSize,
kPopupItemIconBubbleSize);
QPainterPath iconBubblePath;
iconBubblePath.addEllipse(QRectF(iconBubbleRect).adjusted(
0.5, 0.5, -0.5, -0.5));
painter->fillPath(iconBubblePath, palette.iconBubble);
const QRect iconRect(
iconBubbleRect.left() +
(iconBubbleRect.width() - kPopupItemIconSize) / 2,
iconBubbleRect.top() +
(iconBubbleRect.height() - kPopupItemIconSize) / 2,
kPopupItemIconSize, kPopupItemIconSize);
ThemeManager::getAdaptiveIcon(
QStringLiteral(":/svg/dark/search.svg"), palette.icon)
.paint(painter, iconRect);
const QRect textRect(
iconBubbleRect.right() + kPopupItemContentGap,
itemRect.top(),
qMax(1, itemRect.right() - kPopupItemRightPadding -
(iconBubbleRect.right() + kPopupItemContentGap) + 1),
itemRect.height());
QFont textFont = option.font;
textFont.setPointSizeF(9.75);
textFont.setWeight(QFont::Medium);
QFont highlightFont = textFont;
highlightFont.setWeight(QFont::DemiBold);
const QFontMetrics textMetrics(textFont);
const QString displayText =
textMetrics.elidedText(text, Qt::ElideRight, textRect.width());
const QString highlightText =
m_popup ? m_popup->highlightText().trimmed() : QString();
const int highlightPos =
highlightText.isEmpty()
? -1
: text.indexOf(highlightText, 0, Qt::CaseInsensitive);
if (displayText != text || highlightPos < 0)
{
painter->setPen(palette.text);
painter->setFont(textFont);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter,
displayText);
painter->restore();
return;
}
const QString prefixText = text.left(highlightPos);
const QString matchedText = text.mid(highlightPos,
highlightText.size());
const QString suffixText =
text.mid(highlightPos + highlightText.size());
const QFontMetrics normalMetrics(textFont);
const QFontMetrics matchedMetrics(highlightFont);
const int prefixWidth = normalMetrics.horizontalAdvance(prefixText);
const int matchedWidth =
matchedMetrics.horizontalAdvance(matchedText);
const int totalTextWidth = prefixWidth + matchedWidth +
normalMetrics.horizontalAdvance(suffixText);
int drawX = textRect.left();
if (totalTextWidth < textRect.width())
{
drawX += 0;
}
const int baselineY =
textRect.top() + (textRect.height() + normalMetrics.ascent() -
normalMetrics.descent()) /
2;
painter->setPen(palette.text);
painter->setFont(textFont);
painter->drawText(drawX, baselineY, prefixText);
drawX += prefixWidth;
painter->setPen(palette.textHighlight);
painter->setFont(highlightFont);
painter->drawText(drawX, baselineY, matchedText);
drawX += matchedWidth;
painter->setPen(palette.text);
painter->setFont(textFont);
painter->drawText(drawX, baselineY, suffixText);
painter->restore();
}
private:
const SearchCompleterPopup *m_popup = nullptr;
};
}
SearchCompleterPopup::SearchCompleterPopup(QWidget *parent)
: QListView(parent)
{
setObjectName(QStringLiteral("SearchCompleterPopup"));
setViewMode(QListView::ListMode);
setMovement(QListView::Static);
setResizeMode(QListView::Adjust);
setMouseTracking(true);
setSpacing(0);
setUniformItemSizes(true);
setAlternatingRowColors(false);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setSelectionBehavior(QAbstractItemView::SelectRows);
setSelectionMode(QAbstractItemView::SingleSelection);
setTextElideMode(Qt::ElideRight);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setFrameShape(QFrame::NoFrame);
setCursor(Qt::PointingHandCursor);
setAttribute(Qt::WA_TranslucentBackground, true);
setAttribute(Qt::WA_ShowWithoutActivating, true);
setAutoFillBackground(false);
setWindowFlags(Qt::Popup | Qt::FramelessWindowHint |
Qt::NoDropShadowWindowHint);
if (viewport())
{
viewport()->setAttribute(Qt::WA_Hover, true);
viewport()->setMouseTracking(true);
}
if (auto *bar = verticalScrollBar())
{
bar->setSingleStep(16);
}
m_itemDelegate = new SearchCompleterItemDelegate(this, this);
setItemDelegate(m_itemDelegate);
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this,
[this](ThemeManager::Theme) {
if (viewport())
{
viewport()->update();
}
});
}
void SearchCompleterPopup::setHighlightText(QString text)
{
text = text.trimmed();
if (m_highlightText == text)
{
return;
}
m_highlightText = std::move(text);
if (viewport())
{
viewport()->update();
}
}
QString SearchCompleterPopup::highlightText() const
{
return m_highlightText;
}
void SearchCompleterPopup::setModel(QAbstractItemModel *model)
{
QListView::setModel(model);
ensureDelegate();
}
void SearchCompleterPopup::showEvent(QShowEvent *event)
{
ensureDelegate();
QListView::showEvent(event);
}
void SearchCompleterPopup::syncWidthToAnchor(const QWidget *anchor)
{
if (!anchor)
{
return;
}
setFixedSize(anchor->width(), desiredPopupHeight());
}
void SearchCompleterPopup::setMaxVisibleRows(int rows)
{
m_maxVisibleRows = qMax(1, rows);
}
QRect SearchCompleterPopup::popupRectForAnchor(const QWidget *anchor,
int verticalOffset) const
{
if (!anchor)
{
return {};
}
return QRect(0, anchor->height() + verticalOffset, anchor->width(), 1);
}
int SearchCompleterPopup::sizeHintForRow(int row) const
{
Q_UNUSED(row);
return kPopupItemHeight;
}
int SearchCompleterPopup::desiredPopupHeight() const
{
const int rowCount = model() ? model()->rowCount(rootIndex()) : 0;
const int visibleRows = qMax(1, qMin(m_maxVisibleRows, rowCount));
return visibleRows * kPopupItemHeight + kPopupPanelChromeHeight;
}
void SearchCompleterPopup::ensureDelegate()
{
if (!m_itemDelegate)
{
m_itemDelegate = new SearchCompleterItemDelegate(this, this);
}
if (itemDelegate() != m_itemDelegate)
{
setItemDelegate(m_itemDelegate);
}
}
@@ -1,41 +0,0 @@
#ifndef SEARCHCOMPLETERPOPUP_H
#define SEARCHCOMPLETERPOPUP_H
#include <QListView>
#include <QRect>
#include <QString>
class QAbstractItemModel;
class QShowEvent;
class QStyledItemDelegate;
class QWidget;
class SearchCompleterPopup : public QListView
{
Q_OBJECT
public:
explicit SearchCompleterPopup(QWidget *parent = nullptr);
void setHighlightText(QString text);
QString highlightText() const;
void syncWidthToAnchor(const QWidget *anchor);
void setMaxVisibleRows(int rows);
QRect popupRectForAnchor(const QWidget *anchor,
int verticalOffset = 3) const;
protected:
void setModel(QAbstractItemModel *model) override;
void showEvent(QShowEvent *event) override;
int sizeHintForRow(int row) const override;
private:
int desiredPopupHeight() const;
void ensureDelegate();
QString m_highlightText;
QStyledItemDelegate *m_itemDelegate = nullptr;
int m_maxVisibleRows = 8;
};
#endif
@@ -1,626 +0,0 @@
#include "searchhistorychip.h"
#include "../managers/thememanager.h"
#include "../../qEmbyCore/config/config_keys.h"
#include "../../qEmbyCore/config/configstore.h"
#include <QColor>
#include <QEnterEvent>
#include <QEasingCurve>
#include <QFontMetrics>
#include <QLinearGradient>
#include <QMouseEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QPainterPath>
#include <QVariantAnimation>
#include <utility>
namespace
{
constexpr int kChipHeight = 26;
constexpr int kChipBodyHeight = 20;
constexpr int kChipTopOverflow = 5;
constexpr int kChipSideOverflow = 4;
constexpr int kChipMinWidth = 46;
constexpr int kChipMaxWidth = 158;
constexpr int kChipHorizontalPadding = 10;
constexpr int kChipBadgeTop = 0;
constexpr int kChipBadgeHeight = 12;
constexpr int kChipRemoveTop = 0;
constexpr int kChipRemoveButtonSize = 12;
constexpr int kChipRemoveHoverSize = 12;
constexpr int kChipHoverDecorFadeInDurationMs = 150;
constexpr int kChipHoverDecorFadeOutDurationMs = 120;
constexpr qreal kChipHoverDecorMinScale = 0.9;
constexpr qreal kChipHoverDecorMaxShiftY = 1.8;
constexpr qreal kChipRemoveDecorDelayProgress = 0.18;
struct ChipPalette
{
QColor backgroundTop;
QColor backgroundBottom;
QColor border;
QColor text;
QColor badgeBackground;
QColor badgeBorder;
QColor badgeText;
QColor removeBackground;
QColor removeIcon;
};
QColor withAlpha(QColor color, int alpha)
{
color.setAlpha(alpha);
return color;
}
int scaledAlpha(int alpha, qreal progress)
{
return qBound(0, qRound(alpha * progress), 255);
}
qreal delayedProgress(qreal progress, qreal delay)
{
if (progress <= delay)
{
return 0.0;
}
if (delay >= 0.999)
{
return 1.0;
}
return qBound(0.0, (progress - delay) / (1.0 - delay), 1.0);
}
QRectF animatedDecorationRect(const QRect &rect, qreal progress)
{
const qreal scale =
kChipHoverDecorMinScale + (1.0 - kChipHoverDecorMinScale) * progress;
const qreal width = rect.width() * scale;
const qreal height = rect.height() * scale;
const QPointF center(rect.center().x() + 0.5,
rect.center().y() + 0.5 +
(1.0 - progress) * kChipHoverDecorMaxShiftY);
return QRectF(center.x() - width / 2.0, center.y() - height / 2.0,
width, height);
}
QFont chipTextFont(const QWidget *widget)
{
QFont font = widget->font();
font.setPixelSize(qMax(11, qRound(12.0 * ThemeManager::fontScaleFactor())));
font.setWeight(QFont::Medium);
return font;
}
QFont chipBadgeFont(const QWidget *widget)
{
QFont font = widget->font();
font.setPixelSize(qMax(7, qRound(8.0 * ThemeManager::fontScaleFactor())));
font.setWeight(QFont::Bold);
return font;
}
ChipPalette basePalette(const QString &heatLevel, bool darkMode)
{
if (darkMode)
{
if (heatLevel == QLatin1String("top"))
{
return {
QColor(QStringLiteral("#23476D")),
QColor(QStringLiteral("#183B5F")),
QColor(QStringLiteral("#4F80B5")),
QColor(QStringLiteral("#F8FBFF")),
QColor(QStringLiteral("#315F93")),
QColor(QStringLiteral("#6499D1")),
QColor(QStringLiteral("#FFFFFF")),
QColor(QStringLiteral("#284F7B")),
QColor(QStringLiteral("#EAF3FF"))};
}
if (heatLevel == QLatin1String("high"))
{
return {
QColor(QStringLiteral("#1B3A59")),
QColor(QStringLiteral("#15314D")),
QColor(QStringLiteral("#3F71A4")),
QColor(QStringLiteral("#EAF3FF")),
QColor(QStringLiteral("#28527E")),
QColor(QStringLiteral("#5082B9")),
QColor(QStringLiteral("#F5FAFF")),
QColor(QStringLiteral("#24486F")),
QColor(QStringLiteral("#EAF3FF"))};
}
if (heatLevel == QLatin1String("mid"))
{
return {
QColor(QStringLiteral("#17304A")),
QColor(QStringLiteral("#122943")),
QColor(QStringLiteral("#355C84")),
QColor(QStringLiteral("#D6E7FF")),
QColor(QStringLiteral("#23486F")),
QColor(QStringLiteral("#4472A5")),
QColor(QStringLiteral("#E7F2FF")),
QColor(QStringLiteral("#203F61")),
QColor(QStringLiteral("#DDEBFF"))};
}
return {
QColor(QStringLiteral("#1B2535")),
QColor(QStringLiteral("#141E2D")),
QColor(QStringLiteral("#2F3D54")),
QColor(QStringLiteral("#D6E0EB")),
QColor(QStringLiteral("#253247")),
QColor(QStringLiteral("#43546C")),
QColor(QStringLiteral("#C8D4E2")),
QColor(QStringLiteral("#222F43")),
QColor(QStringLiteral("#CBD5E1"))};
}
if (heatLevel == QLatin1String("top"))
{
return {
QColor(QStringLiteral("#E2F0FF")),
QColor(QStringLiteral("#CFE2FF")),
QColor(QStringLiteral("#95B9F0")),
QColor(QStringLiteral("#173D8C")),
QColor(QStringLiteral("#CFE1FF")),
QColor(QStringLiteral("#95B9F0")),
QColor(QStringLiteral("#1D4ED8")),
QColor(QStringLiteral("#E9F2FF")),
QColor(QStringLiteral("#5A79B8"))};
}
if (heatLevel == QLatin1String("high"))
{
return {
QColor(QStringLiteral("#EDF5FF")),
QColor(QStringLiteral("#DDEBFF")),
QColor(QStringLiteral("#B9D2F8")),
QColor(QStringLiteral("#214AA5")),
QColor(QStringLiteral("#DDEBFF")),
QColor(QStringLiteral("#ABC8F6")),
QColor(QStringLiteral("#2563EB")),
QColor(QStringLiteral("#EEF4FC")),
QColor(QStringLiteral("#63758F"))};
}
if (heatLevel == QLatin1String("mid"))
{
return {
QColor(QStringLiteral("#F5F9FF")),
QColor(QStringLiteral("#E9F1FF")),
QColor(QStringLiteral("#CBDCF4")),
QColor(QStringLiteral("#2558BB")),
QColor(QStringLiteral("#EAF2FF")),
QColor(QStringLiteral("#C5D8F7")),
QColor(QStringLiteral("#2D69D1")),
QColor(QStringLiteral("#F2F6FB")),
QColor(QStringLiteral("#71839B"))};
}
return {
QColor(QStringLiteral("#FBFCFE")),
QColor(QStringLiteral("#F3F7FA")),
QColor(QStringLiteral("#DEE6EF")),
QColor(QStringLiteral("#334155")),
QColor(QStringLiteral("#FFFFFF")),
QColor(QStringLiteral("#D8E1EC")),
QColor(QStringLiteral("#64748B")),
QColor(QStringLiteral("#F3F6F9")),
QColor(QStringLiteral("#8090A4"))};
}
ChipPalette statePalette(const QString &heatLevel, bool darkMode, bool hovered, bool pressed)
{
ChipPalette palette = basePalette(heatLevel, darkMode);
if (hovered)
{
palette.backgroundTop = palette.backgroundTop.lighter(darkMode ? 116 : 103);
palette.backgroundBottom = palette.backgroundBottom.lighter(darkMode ? 118 : 104);
palette.border = palette.border.lighter(darkMode ? 128 : 108);
palette.badgeBackground = palette.badgeBackground.lighter(darkMode ? 108 : 103);
palette.badgeBorder = palette.badgeBorder.lighter(darkMode ? 122 : 108);
palette.removeBackground = palette.removeBackground.lighter(darkMode ? 110 : 103);
}
if (pressed)
{
palette.backgroundTop = palette.backgroundTop.darker(darkMode ? 112 : 104);
palette.backgroundBottom = palette.backgroundBottom.darker(darkMode ? 116 : 106);
palette.border = palette.border.darker(darkMode ? 112 : 108);
palette.badgeBackground = palette.badgeBackground.darker(darkMode ? 106 : 102);
palette.removeBackground = palette.removeBackground.darker(darkMode ? 106 : 102);
}
return palette;
}
}
SearchHistoryChip::SearchHistoryChip(QWidget *parent)
: QAbstractButton(parent)
{
setObjectName(QStringLiteral("SearchHistoryChip"));
setCursor(Qt::PointingHandCursor);
setFocusPolicy(Qt::NoFocus);
setAttribute(Qt::WA_Hover, true);
setMouseTracking(true);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
setFixedHeight(kChipHeight);
m_hoverDecorAnimation = new QVariantAnimation(this);
m_hoverDecorAnimation->setStartValue(0.0);
m_hoverDecorAnimation->setEndValue(1.0);
m_hoverDecorAnimation->setDuration(kChipHoverDecorFadeInDurationMs);
m_hoverDecorAnimation->setEasingCurve(QEasingCurve::OutCubic);
connect(m_hoverDecorAnimation, &QVariantAnimation::valueChanged, this,
[this](const QVariant &value) {
m_hoverDecorProgress = value.toReal();
update();
});
connect(ConfigStore::instance(), &ConfigStore::valueChanged, this,
[this](const QString &key, const QVariant &) {
if (key == ConfigKeys::FontSize)
{
updateMetrics();
}
else if (key == ConfigKeys::UiAnimations)
{
if (m_hoverDecorAnimation)
{
m_hoverDecorAnimation->stop();
}
m_hoverDecorProgress = shouldShowHoverDecorations() ? 1.0 : 0.0;
update();
}
});
updateMetrics();
}
void SearchHistoryChip::setTerm(QString term)
{
term = term.trimmed();
if (m_term == term)
{
return;
}
m_term = std::move(term);
setToolTip(m_term);
updateMetrics();
}
QString SearchHistoryChip::term() const
{
return m_term;
}
void SearchHistoryChip::setSearchCount(int count)
{
const int safeCount = qMax(1, count);
if (m_searchCount == safeCount)
{
return;
}
m_searchCount = safeCount;
update();
}
int SearchHistoryChip::searchCount() const
{
return m_searchCount;
}
void SearchHistoryChip::setHeatLevel(const QString &level)
{
const QString normalizedLevel = level.trimmed().isEmpty() ? QStringLiteral("low")
: level.trimmed();
if (m_heatLevel == normalizedLevel)
{
return;
}
m_heatLevel = normalizedLevel;
update();
}
QString SearchHistoryChip::heatLevel() const
{
return m_heatLevel;
}
QSize SearchHistoryChip::sizeHint() const
{
return {m_cachedWidth, kChipHeight};
}
QSize SearchHistoryChip::minimumSizeHint() const
{
return sizeHint();
}
bool SearchHistoryChip::hitButton(const QPoint &pos) const
{
return bodyRect().contains(pos) || badgeRect().contains(pos) ||
removeButtonRect().contains(pos);
}
void SearchHistoryChip::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
const ChipPalette palette = statePalette(m_heatLevel,
ThemeManager::instance()->isDarkMode(),
underMouse(), isDown());
const qreal decorProgress = qBound(0.0, m_hoverDecorProgress, 1.0);
const qreal removeBackgroundProgress = decorProgress;
const qreal removeIconProgress =
delayedProgress(decorProgress, kChipRemoveDecorDelayProgress);
const QRect chipBody = bodyRect();
const QRectF outerRect = QRectF(chipBody).adjusted(0.5, 0.5, -0.5, -0.5);
const qreal radius = outerRect.height() / 2.0;
QPainterPath chipPath;
chipPath.addRoundedRect(outerRect, radius, radius);
QLinearGradient backgroundGradient(0, outerRect.top(), 0, outerRect.bottom());
backgroundGradient.setColorAt(0.0, palette.backgroundTop);
backgroundGradient.setColorAt(1.0, palette.backgroundBottom);
painter.fillPath(chipPath, backgroundGradient);
painter.setPen(QPen(palette.border, 0.85));
painter.drawPath(chipPath);
painter.setPen(palette.text);
painter.setFont(chipTextFont(this));
const QRect badgeBox = badgeRect();
const QRect removeRect = removeButtonRect();
const QRect textRect(chipBody.left() + kChipHorizontalPadding,
chipBody.top() + 1,
qMax(1, chipBody.width() - kChipHorizontalPadding * 2),
chipBody.height() - 2);
const QFontMetrics textMetrics(chipTextFont(this));
const QString displayText =
textMetrics.elidedText(m_term, Qt::ElideRight, textRect.width());
const bool shouldCenterText =
textMetrics.horizontalAdvance(displayText) <= textRect.width();
painter.drawText(textRect,
(shouldCenterText ? Qt::AlignHCenter : Qt::AlignLeft) |
Qt::AlignVCenter,
displayText);
if (decorProgress <= 0.001)
{
return;
}
const QRectF animatedBadgeRect = animatedDecorationRect(badgeBox, decorProgress);
QPainterPath badgePath;
badgePath.addEllipse(animatedBadgeRect.adjusted(0.5, 0.5, -0.5, -0.5));
painter.fillPath(badgePath,
withAlpha(palette.badgeBackground,
scaledAlpha(235, decorProgress)));
painter.setPen(QPen(withAlpha(palette.badgeBorder,
scaledAlpha(190, decorProgress)),
0.85));
painter.drawPath(badgePath);
painter.setFont(chipBadgeFont(this));
painter.setPen(withAlpha(palette.badgeText, scaledAlpha(255, decorProgress)));
painter.drawText(animatedBadgeRect, Qt::AlignCenter, formattedCount());
if (removeBackgroundProgress <= 0.001)
{
return;
}
const QRect hoverRect(removeRect.center().x() - kChipRemoveHoverSize / 2,
removeRect.center().y() - kChipRemoveHoverSize / 2,
kChipRemoveHoverSize, kChipRemoveHoverSize);
const QRectF animatedRemoveRect =
animatedDecorationRect(hoverRect, removeBackgroundProgress);
QPainterPath removePath;
removePath.addEllipse(animatedRemoveRect.adjusted(0.5, 0.5, -0.5, -0.5));
painter.fillPath(removePath, withAlpha(palette.removeBackground,
scaledAlpha((m_removeHovered || m_removePressed) ? 226
: 188,
removeBackgroundProgress)));
painter.setPen(QPen(withAlpha(palette.border,
scaledAlpha(175, removeBackgroundProgress)),
0.85));
painter.drawPath(removePath);
if (removeIconProgress <= 0.001)
{
return;
}
painter.setPen(QPen(withAlpha(palette.removeIcon,
scaledAlpha(255, removeIconProgress)),
1.15, Qt::SolidLine, Qt::RoundCap));
const QPointF removeCenter = animatedRemoveRect.center();
const qreal removeHalfSize = 2.55;
painter.drawLine(QPointF(removeCenter.x() - removeHalfSize,
removeCenter.y() - removeHalfSize),
QPointF(removeCenter.x() + removeHalfSize,
removeCenter.y() + removeHalfSize));
painter.drawLine(QPointF(removeCenter.x() - removeHalfSize,
removeCenter.y() + removeHalfSize),
QPointF(removeCenter.x() + removeHalfSize,
removeCenter.y() - removeHalfSize));
}
void SearchHistoryChip::enterEvent(QEnterEvent *event)
{
QAbstractButton::enterEvent(event);
syncHoverDecorationVisibility();
}
void SearchHistoryChip::leaveEvent(QEvent *event)
{
QAbstractButton::leaveEvent(event);
m_removeHovered = false;
m_removePressed = false;
syncHoverDecorationVisibility();
}
void SearchHistoryChip::mouseMoveEvent(QMouseEvent *event)
{
updateRemoveHover(event->position().toPoint());
QAbstractButton::mouseMoveEvent(event);
}
void SearchHistoryChip::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton &&
removeButtonRect().contains(event->position().toPoint()))
{
m_removePressed = true;
m_removeHovered = true;
syncHoverDecorationVisibility();
event->accept();
return;
}
m_removePressed = false;
QAbstractButton::mousePressEvent(event);
}
void SearchHistoryChip::mouseReleaseEvent(QMouseEvent *event)
{
if (m_removePressed)
{
const bool triggered = event->button() == Qt::LeftButton &&
removeButtonRect().contains(event->position().toPoint());
m_removePressed = false;
updateRemoveHover(event->position().toPoint());
syncHoverDecorationVisibility();
event->accept();
if (triggered && !m_term.isEmpty())
{
Q_EMIT removeRequested(m_term);
}
return;
}
QAbstractButton::mouseReleaseEvent(event);
}
void SearchHistoryChip::syncHoverDecorationVisibility()
{
const bool visible = shouldShowHoverDecorations();
const qreal targetProgress = visible ? 1.0 : 0.0;
if (m_hoverDecorVisible == visible)
{
if (m_hoverDecorAnimation &&
m_hoverDecorAnimation->state() == QAbstractAnimation::Running)
{
return;
}
if (qAbs(m_hoverDecorProgress - targetProgress) < 0.001)
{
return;
}
}
m_hoverDecorVisible = visible;
const bool reduceAnimations =
ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
if (!m_hoverDecorAnimation || reduceAnimations)
{
if (m_hoverDecorAnimation)
{
m_hoverDecorAnimation->stop();
}
m_hoverDecorProgress = targetProgress;
update();
return;
}
m_hoverDecorAnimation->stop();
m_hoverDecorAnimation->setDuration(visible ? kChipHoverDecorFadeInDurationMs
: kChipHoverDecorFadeOutDurationMs);
m_hoverDecorAnimation->setEasingCurve(visible ? QEasingCurve::OutCubic
: QEasingCurve::InCubic);
m_hoverDecorAnimation->setStartValue(m_hoverDecorProgress);
m_hoverDecorAnimation->setEndValue(targetProgress);
m_hoverDecorAnimation->start();
}
bool SearchHistoryChip::shouldShowHoverDecorations() const
{
return underMouse() || m_removeHovered || m_removePressed;
}
void SearchHistoryChip::updateRemoveHover(const QPoint &pos)
{
const bool hovered = removeButtonRect().contains(pos);
if (m_removeHovered == hovered)
{
return;
}
m_removeHovered = hovered;
syncHoverDecorationVisibility();
}
void SearchHistoryChip::updateMetrics()
{
ensurePolished();
const QFontMetrics metrics(chipTextFont(this));
const int naturalTextWidth = metrics.horizontalAdvance(m_term);
m_cachedWidth = qBound(kChipMinWidth,
naturalTextWidth + kChipHorizontalPadding * 2 +
kChipSideOverflow * 2 + 2,
kChipMaxWidth);
setFixedSize(m_cachedWidth, kChipHeight);
updateGeometry();
update();
}
QString SearchHistoryChip::formattedCount() const
{
return (m_searchCount > 999) ? QStringLiteral("999+")
: QString::number(qMax(1, m_searchCount));
}
QRect SearchHistoryChip::bodyRect() const
{
return QRect(kChipSideOverflow, kChipTopOverflow,
qMax(1, width() - kChipSideOverflow * 2), kChipBodyHeight);
}
QRect SearchHistoryChip::removeButtonRect() const
{
return QRect(width() - kChipRemoveButtonSize - 1,
kChipRemoveTop, kChipRemoveButtonSize, kChipRemoveButtonSize);
}
QRect SearchHistoryChip::badgeRect() const
{
const QFontMetrics metrics(chipBadgeFont(this));
const int badgeWidth = qBound(kChipBadgeHeight,
metrics.horizontalAdvance(formattedCount()) + 6, 18);
return QRect(1, kChipBadgeTop, badgeWidth, kChipBadgeHeight);
}
@@ -1,65 +0,0 @@
#ifndef SEARCHHISTORYCHIP_H
#define SEARCHHISTORYCHIP_H
#include <QAbstractButton>
#include <QString>
class QEnterEvent;
class QEvent;
class QMouseEvent;
class QPaintEvent;
class QPoint;
class QVariantAnimation;
class SearchHistoryChip : public QAbstractButton
{
Q_OBJECT
public:
explicit SearchHistoryChip(QWidget *parent = nullptr);
void setTerm(QString term);
QString term() const;
void setSearchCount(int count);
int searchCount() const;
void setHeatLevel(const QString &level);
QString heatLevel() const;
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
Q_SIGNALS:
void removeRequested(const QString &term);
protected:
bool hitButton(const QPoint &pos) const override;
void paintEvent(QPaintEvent *event) override;
void enterEvent(QEnterEvent *event) override;
void leaveEvent(QEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private:
void updateMetrics();
void syncHoverDecorationVisibility();
bool shouldShowHoverDecorations() const;
void updateRemoveHover(const QPoint &pos);
QRect bodyRect() const;
QRect removeButtonRect() const;
QRect badgeRect() const;
QString formattedCount() const;
QString m_term;
QString m_heatLevel = QStringLiteral("low");
int m_searchCount = 1;
int m_cachedWidth = 40;
bool m_removeHovered = false;
bool m_removePressed = false;
bool m_hoverDecorVisible = false;
qreal m_hoverDecorProgress = 0.0;
QVariantAnimation *m_hoverDecorAnimation = nullptr;
};
#endif
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
#ifndef SEARCHHISTORYPOPUP_H
#define SEARCHHISTORYPOPUP_H
#include "../managers/searchhistorymanager.h"
#include <QFrame>
#include <QList>
class FlowLayout;
class QGraphicsOpacityEffect;
class QScrollArea;
class QToolButton;
class QVariantAnimation;
class QWidget;
class SearchHistoryPopup : public QFrame {
Q_OBJECT
public:
explicit SearchHistoryPopup(QWidget *parent = nullptr);
void setEntries(QList<SearchHistoryManager::SearchHistoryEntry> entries);
bool hasContent() const;
void showBelow(QWidget *anchor);
void dismiss(bool immediate = false);
Q_SIGNALS:
void termActivated(const QString &term);
void clearHistoryRequested();
void removeHistoryTermRequested(const QString &term);
private:
enum class SortMode {
Recent,
Count
};
void rebuildChips(bool preserveScrollPosition = false);
int resolvedCardWidth() const;
void relayoutPopup(int preferredCardWidth = -1);
void repositionToAnchor();
void resetScrollPosition();
void stopHeightAnimation();
void stopSortTransitionAnimation();
void stopVisibilityAnimation();
void syncReducedAnimationMode();
void applyVisibilityState(qreal opacity, int offsetY);
void applyAnimatedHeights(int scrollHeight, int bodyHeight, int popupHeight);
void updateLayoutMetrics(int cardWidth);
void refreshControls();
void updateControlIcons();
void setClearArmed(bool armed);
void setExpanded(bool expanded);
void setSortMode(SortMode mode);
QList<SearchHistoryManager::SearchHistoryEntry> sortedEntries() const;
static QString heatLevelForCount(int count);
QFrame *m_card = nullptr;
QWidget *m_body = nullptr;
QWidget *m_header = nullptr;
QFrame *m_headerDivider = nullptr;
QToolButton *m_recentButton = nullptr;
QToolButton *m_hotButton = nullptr;
QToolButton *m_clearButton = nullptr;
QToolButton *m_expandButton = nullptr;
QScrollArea *m_scrollArea = nullptr;
QWidget *m_flowHost = nullptr;
FlowLayout *m_flowLayout = nullptr;
QGraphicsOpacityEffect *m_cardOpacityEffect = nullptr;
QGraphicsOpacityEffect *m_flowOpacityEffect = nullptr;
QVariantAnimation *m_heightAnimation = nullptr;
QVariantAnimation *m_sortTransitionAnimation = nullptr;
QVariantAnimation *m_visibilityAnimation = nullptr;
QWidget *m_anchor = nullptr;
int m_lastCardWidth = 0;
int m_animationStartScrollHeight = 0;
int m_animationStartBodyHeight = 0;
int m_animationStartPopupHeight = 0;
int m_animationTargetScrollHeight = 0;
int m_animationTargetBodyHeight = 0;
int m_animationTargetPopupHeight = 0;
qreal m_visibilityStartOpacity = 1.0;
qreal m_visibilityTargetOpacity = 1.0;
int m_visibilityOffsetY = 0;
int m_visibilityStartOffsetY = 0;
int m_visibilityTargetOffsetY = 0;
bool m_visibilityHiding = false;
bool m_animateNextRelayout = false;
bool m_sortTransitionRebuilt = false;
QList<SearchHistoryManager::SearchHistoryEntry> m_entries;
SortMode m_sortMode = SortMode::Recent;
bool m_expanded = false;
bool m_clearArmed = false;
};
#endif
-340
View File
@@ -1,340 +0,0 @@
#include "serverwheelview.h"
#include <QVariantAnimation>
#include <QEasingCurve>
#include <QGraphicsOpacityEffect>
#include <QMouseEvent>
#include <QWheelEvent>
#include <QDateTime>
#include <QMap>
#include <QtMath>
#include <QLabel>
#include <QPushButton>
#include <QFont>
ServerWheelView::ServerWheelView(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet("ServerWheelView { background: transparent; }");
m_scrollAnim = new QVariantAnimation(this);
m_scrollAnim->setEasingCurve(QEasingCurve::OutExpo);
m_scrollAnim->setDuration(450);
connect(m_scrollAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& val){
m_scrollOffset = val.toReal();
updateLayout();
});
}
ServerWheelView::~ServerWheelView() {
clear();
}
void ServerWheelView::addCard(QWidget* card) {
card->setParent(this);
auto* effect = new QGraphicsOpacityEffect(card);
card->setGraphicsEffect(effect);
installFilterRecursively(card);
m_items.append(card);
updateLayout();
}
void ServerWheelView::clear() {
m_scrollAnim->stop();
m_scrollOffset = 0.0;
m_currentIndex = 0;
qDeleteAll(m_items);
m_items.clear();
}
int ServerWheelView::count() const {
return m_items.size();
}
void ServerWheelView::setCurrentIndex(int index, bool animated) {
int N = m_items.size();
if (N == 0) return;
index = index % N;
if (index < 0) index += N;
if (animated) {
qreal delta = index - m_scrollOffset;
qreal wrapped_delta = delta - N * qRound(delta / static_cast<qreal>(N));
scrollTo(m_scrollOffset + wrapped_delta);
} else {
m_scrollAnim->stop();
m_scrollOffset = index;
updateLayout();
}
}
int ServerWheelView::currentIndex() const {
return m_currentIndex;
}
void ServerWheelView::setTransitionMode(bool active) {
m_isTransitioning = active;
if (active) {
m_scrollAnim->stop();
m_scrollOffset = m_currentIndex;
}
updateLayout();
}
void ServerWheelView::resizeEvent(QResizeEvent*) {
updateLayout();
}
void ServerWheelView::handleMousePress(QPoint globalPos) {
if (m_items.isEmpty()) return;
m_scrollAnim->stop();
m_isDragging = true;
m_dragStarted = false;
m_lastPos = globalPos;
m_dragVelocity = 0.0;
m_lastTime = QDateTime::currentMSecsSinceEpoch();
}
void ServerWheelView::handleMouseMove(QPoint globalPos) {
if (!m_isDragging) return;
qreal dy = globalPos.y() - m_lastPos.y();
if (!m_dragStarted && qAbs(dy) > 5) m_dragStarted = true;
if (m_dragStarted) {
qreal deltaOffset = -dy / 80.0;
qint64 now = QDateTime::currentMSecsSinceEpoch();
qint64 dt = now - m_lastTime;
if (dt > 0) m_dragVelocity = deltaOffset / dt * 16.0;
m_scrollOffset += deltaOffset;
m_lastPos = globalPos;
m_lastTime = now;
updateLayout();
}
}
bool ServerWheelView::handleMouseRelease() {
if (!m_isDragging) return false;
m_isDragging = false;
if (m_dragStarted) {
if (!m_items.isEmpty()) {
qreal target = qRound(m_scrollOffset + m_dragVelocity * 10.0);
scrollTo(target);
}
return true;
}
return false;
}
void ServerWheelView::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) handleMousePress(event->globalPosition().toPoint());
QWidget::mousePressEvent(event);
}
void ServerWheelView::mouseMoveEvent(QMouseEvent* event) {
handleMouseMove(event->globalPosition().toPoint());
QWidget::mouseMoveEvent(event);
}
void ServerWheelView::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) handleMouseRelease();
QWidget::mouseReleaseEvent(event);
}
bool ServerWheelView::eventFilter(QObject* obj, QEvent* event) {
if (event->type() == QEvent::MouseButtonPress) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) handleMousePress(me->globalPosition().toPoint());
}
else if (event->type() == QEvent::MouseMove) {
auto* me = static_cast<QMouseEvent*>(event);
handleMouseMove(me->globalPosition().toPoint());
if (m_dragStarted) return true;
}
else if (event->type() == QEvent::MouseButtonRelease) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::LeftButton) {
if (handleMouseRelease()) return true;
}
}
return QWidget::eventFilter(obj, event);
}
void ServerWheelView::wheelEvent(QWheelEvent* event) {
if (m_items.isEmpty()) return;
qreal angleDelta = event->angleDelta().y();
qreal step = -angleDelta / 120.0;
qreal baseTarget = m_scrollAnim->state() == QAbstractAnimation::Running ?
m_scrollAnim->endValue().toReal() : qRound(m_scrollOffset);
scrollTo(baseTarget + step);
}
void ServerWheelView::installFilterRecursively(QWidget* w) {
w->installEventFilter(this);
for (auto* child : w->findChildren<QWidget*>()) {
child->installEventFilter(this);
}
}
void ServerWheelView::scrollTo(qreal target) {
m_scrollAnim->stop();
m_scrollAnim->setStartValue(m_scrollOffset);
m_scrollAnim->setEndValue(target);
m_scrollAnim->start();
}
void ServerWheelView::updateLayout() {
int N = m_items.size();
if (N == 0) return;
int vh = height();
int vw = width();
int centerY = vh / 2;
int baseItemHeight = 72;
QMap<qreal, QWidget*> zOrderMap;
int newIndex = qRound(m_scrollOffset) % N;
if (newIndex < 0) newIndex += N;
if (newIndex != m_currentIndex) {
m_currentIndex = newIndex;
Q_EMIT currentIndexChanged(m_currentIndex);
}
for (int i = 0; i < N; ++i) {
QWidget* card = m_items[i];
qreal rawDelta = i - m_scrollOffset;
qreal logicalIdx = rawDelta - N * qRound(rawDelta / static_cast<qreal>(N));
qreal distance = qAbs(logicalIdx);
qreal opacity = qMax(0.0, 1.0 - distance * 0.4);
bool shouldHide = (opacity <= 0.0 || (m_isTransitioning && i != m_currentIndex));
if (shouldHide) {
if (card->isVisible()) card->hide();
continue;
} else {
if (!card->isVisible()) card->show();
}
if (card->testAttribute(Qt::WA_TransparentForMouseEvents)) {
card->setAttribute(Qt::WA_TransparentForMouseEvents, false);
}
bool isDepthCard = (distance > 1.5);
if (card->property("isDepthCard").toBool() != isDepthCard) {
card->setProperty("isDepthCard", isDepthCard);
}
if (isDepthCard) {
QWidget* actionContainer = nullptr;
if (card->property("cachedActionPtr").isValid()) {
actionContainer = reinterpret_cast<QWidget*>(card->property("cachedActionPtr").toULongLong());
} else {
for (auto* btn : card->findChildren<QPushButton*>()) {
if (btn->objectName() == "action-edit-btn" && btn->parentWidget()) {
actionContainer = btn->parentWidget();
card->setProperty("cachedActionPtr", reinterpret_cast<qulonglong>(actionContainer));
break;
}
}
}
if (actionContainer && actionContainer->isVisible()) {
actionContainer->hide();
}
}
qreal angle = logicalIdx * (M_PI / 6.0);
qreal R = vh / 2.0;
qreal scale = qMax(0.5, 1.0 - distance * 0.2);
int projectedW = vw * scale;
int projectedH = baseItemHeight * scale;
int projectedX = (vw - projectedW) / 2;
int projectedY = centerY + R * qSin(angle) - projectedH / 2.0;
QRect newGeometry(projectedX, projectedY, projectedW, projectedH);
if (card->geometry() != newGeometry) {
card->setGeometry(newGeometry);
}
for (auto* child : card->findChildren<QWidget*>()) {
if (qobject_cast<QLabel*>(child) || qobject_cast<QPushButton*>(child)) {
if (!child->property("originalFontSize").isValid()) {
QFont f = child->font();
qreal size = f.pixelSize() > 0 ? f.pixelSize() : f.pointSizeF();
child->setProperty("originalFontSize", size);
child->setProperty("usePixel", f.pixelSize() > 0);
}
qreal origSize = child->property("originalFontSize").toReal();
bool usePixel = child->property("usePixel").toBool();
int newSizeInt = qMax(1, qRound(origSize * scale));
int lastSizeInt = child->property("lastAppliedFontSize").toInt();
if (newSizeInt != lastSizeInt) {
child->setProperty("lastAppliedFontSize", newSizeInt);
QFont newFont = child->font();
if (usePixel) {
newFont.setPixelSize(newSizeInt);
} else {
newFont.setPointSizeF(newSizeInt);
}
child->setFont(newFont);
}
}
if (QLabel* label = qobject_cast<QLabel*>(child)) {
if (!label->property("originalIconSize").isValid() &&
label->minimumWidth() == label->maximumWidth() &&
label->minimumHeight() == label->maximumHeight() &&
label->minimumWidth() > 0) {
label->setProperty("originalIconSize", label->minimumWidth());
}
if (label->property("originalIconSize").isValid()) {
int origIconSize = label->property("originalIconSize").toInt();
int newSize = qMax(1, qRound(origIconSize * scale));
int lastIconSize = label->property("lastAppliedIconSize").toInt();
if (newSize != lastIconSize) {
label->setProperty("lastAppliedIconSize", newSize);
label->setFixedSize(newSize, newSize);
}
}
}
}
if (auto* effect = qobject_cast<QGraphicsOpacityEffect*>(card->graphicsEffect())) {
if (!qFuzzyCompare(effect->opacity(), opacity)) {
effect->setOpacity(opacity);
}
bool enableEffect = opacity < 0.99;
if (effect->isEnabled() != enableEffect) {
effect->setEnabled(enableEffect);
}
}
zOrderMap.insert(-distance, card);
}
for (auto* card : zOrderMap.values()) {
card->raise();
}
}
-62
View File
@@ -1,62 +0,0 @@
#ifndef SERVERWHEELVIEW_H
#define SERVERWHEELVIEW_H
#include <QWidget>
#include <QList>
#include <QPoint>
class QVariantAnimation;
class ServerWheelView : public QWidget {
Q_OBJECT
public:
explicit ServerWheelView(QWidget* parent = nullptr);
~ServerWheelView() override;
void addCard(QWidget* card);
void clear();
int count() const;
void setCurrentIndex(int index, bool animated = false);
int currentIndex() const;
void setTransitionMode(bool active);
Q_SIGNALS:
void currentIndexChanged(int index);
protected:
void resizeEvent(QResizeEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void installFilterRecursively(QWidget* w);
void scrollTo(qreal target);
void updateLayout();
void handleMousePress(QPoint globalPos);
void handleMouseMove(QPoint globalPos);
bool handleMouseRelease();
QList<QWidget*> m_items;
qreal m_scrollOffset = 0.0;
int m_currentIndex = 0;
QVariantAnimation* m_scrollAnim;
bool m_isTransitioning = false;
bool m_isDragging = false;
bool m_dragStarted = false;
QPoint m_lastPos;
qint64 m_lastTime = 0;
qreal m_dragVelocity = 0.0;
};
#endif
-209
View File
@@ -1,209 +0,0 @@
#include "settingscard.h"
#include "elidedlabel.h"
#include "modernswitch.h"
#include "moderntaginput.h"
#include "../managers/thememanager.h"
#include <QComboBox>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QSlider>
#include <QPushButton>
#include <QSpinBox>
#include "../../qEmbyCore/config/configstore.h"
SettingsCard::SettingsCard(const QString &iconSvgPath, const QString &title,
const QString &description, QWidget *controlWidget,
const QString &configKey, QWidget *parent,
const QVariant &defaultValue)
: QFrame(parent), m_controlWidget(controlWidget), m_configKey(configKey),
m_iconSvgPath(iconSvgPath), m_defaultValue(defaultValue) {
setObjectName("SettingsCard");
if (m_controlWidget) {
m_controlWidget->setParent(this);
}
setupUi(title, description);
if (!m_configKey.isEmpty() && m_controlWidget) {
setupDataBinding();
}
connect(ThemeManager::instance(), &ThemeManager::themeChanged, this,
&SettingsCard::onThemeChanged);
}
SettingsCard::~SettingsCard() = default;
void SettingsCard::setupUi(const QString &title, const QString &description) {
m_mainLayout = new QHBoxLayout(this);
m_mainLayout->setContentsMargins(16, 12, 16, 12);
m_mainLayout->setSpacing(12);
m_iconLabel = new QLabel(this);
m_iconLabel->setFixedSize(16, 16);
m_iconLabel->setAlignment(Qt::AlignCenter);
m_iconLabel->setObjectName("SettingsCardIcon");
updateIcon();
auto *textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(2);
textLayout->setAlignment(Qt::AlignVCenter);
m_titleLabel = new QLabel(title, this);
m_titleLabel->setObjectName("SettingsCardTitle");
m_descLabel = new ElidedLabel(this);
m_descLabel->setObjectName("SettingsCardDesc");
m_descLabel->setFullText(description);
textLayout->addWidget(m_titleLabel);
if (!description.isEmpty()) {
textLayout->addWidget(m_descLabel);
} else {
m_descLabel->hide();
}
m_mainLayout->addWidget(m_iconLabel, 0, Qt::AlignVCenter);
m_mainLayout->addLayout(textLayout, 1);
if (m_controlWidget) {
if (auto *combo = qobject_cast<QComboBox *>(m_controlWidget)) {
combo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
} else if (auto *btn = qobject_cast<QPushButton *>(m_controlWidget)) {
btn->setObjectName("SettingsCardButton");
btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
} else if (auto *lineEdit = qobject_cast<QLineEdit *>(m_controlWidget)) {
lineEdit->setMinimumWidth(200);
lineEdit->setFixedHeight(32);
lineEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
} else if (auto *spinBox = qobject_cast<QSpinBox *>(m_controlWidget)) {
spinBox->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
} else if (qobject_cast<ModernTagInput *>(m_controlWidget)) {
m_controlWidget->setMinimumWidth(220);
m_controlWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
m_mainLayout->addWidget(m_controlWidget, 0, Qt::AlignVCenter);
}
}
void SettingsCard::updateIcon() {
if (m_iconSvgPath.isEmpty())
return;
QIcon icon = ThemeManager::getAdaptiveIcon(m_iconSvgPath);
m_iconLabel->setPixmap(icon.pixmap(16, 16));
}
void SettingsCard::onThemeChanged() { updateIcon(); }
void SettingsCard::setupDataBinding() {
auto *store = ConfigStore::instance();
if (auto *switchControl = qobject_cast<ModernSwitch *>(m_controlWidget)) {
switchControl->setChecked(store->get<bool>(m_configKey, m_defaultValue.toBool()));
connect(switchControl, &ModernSwitch::toggled, this,
[this, store](bool checked) { store->set(m_configKey, checked); });
}
else if (auto *comboControl = qobject_cast<QComboBox *>(m_controlWidget)) {
QString currentVal = store->get<QString>(m_configKey);
if (currentVal.isEmpty() && m_defaultValue.isValid()) {
currentVal = m_defaultValue.toString();
}
int idx = comboControl->findData(currentVal);
if (idx >= 0) {
comboControl->setCurrentIndex(idx);
}
connect(comboControl, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this, store, comboControl](int index) {
store->set(m_configKey, comboControl->itemData(index));
});
}
else if (auto *lineEdit = qobject_cast<QLineEdit *>(m_controlWidget)) {
lineEdit->setText(store->get<QString>(m_configKey, m_defaultValue.toString()));
connect(lineEdit, &QLineEdit::editingFinished, this,
[this, store, lineEdit]() { store->set(m_configKey, lineEdit->text()); });
}
else if (auto *spinBox = qobject_cast<QSpinBox *>(m_controlWidget)) {
spinBox->setValue(store->get<int>(m_configKey, m_defaultValue.toInt()));
connect(spinBox, QOverload<int>::of(&QSpinBox::valueChanged), this,
[this, store](int value) { store->set(m_configKey, value); });
}
else if (auto *tagInput = qobject_cast<ModernTagInput *>(m_controlWidget)) {
tagInput->setValue(store->get<QString>(m_configKey, m_defaultValue.toString()));
connect(tagInput, &ModernTagInput::valueChanged, this,
[this, store](const QString &val) { store->set(m_configKey, val); });
}
connect(store, &ConfigStore::valueChanged, this,
&SettingsCard::onConfigValueChanged);
}
void SettingsCard::onConfigValueChanged(const QString &key,
const QVariant &newValue) {
if (key != m_configKey || !m_controlWidget)
return;
m_controlWidget->blockSignals(true);
if (auto *switchControl = qobject_cast<ModernSwitch *>(m_controlWidget)) {
switchControl->setChecked(newValue.toBool());
} else if (auto *comboControl = qobject_cast<QComboBox *>(m_controlWidget)) {
int idx = comboControl->findData(newValue);
if (idx >= 0) {
comboControl->setCurrentIndex(idx);
}
} else if (auto *lineEdit = qobject_cast<QLineEdit *>(m_controlWidget)) {
lineEdit->setText(newValue.toString());
} else if (auto *spinBox = qobject_cast<QSpinBox *>(m_controlWidget)) {
spinBox->setValue(newValue.toInt());
} else if (auto *tagInput = qobject_cast<ModernTagInput *>(m_controlWidget)) {
tagInput->setValue(newValue.toString());
}
m_controlWidget->blockSignals(false);
}
-47
View File
@@ -1,47 +0,0 @@
#ifndef SETTINGSCARD_H
#define SETTINGSCARD_H
#include <QFrame>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QString>
#include <QVBoxLayout>
#include <QVariant>
#include <QWidget>
class ElidedLabel;
class SettingsCard : public QFrame {
Q_OBJECT
public:
explicit SettingsCard(const QString &iconSvgPath, const QString &title,
const QString &description, QWidget *controlWidget,
const QString &configKey, QWidget *parent = nullptr,
const QVariant &defaultValue = QVariant());
~SettingsCard() override;
private:
void setupUi(const QString &title, const QString &description);
void setupDataBinding();
void updateIcon();
private slots:
void onConfigValueChanged(const QString &key, const QVariant &newValue);
void onThemeChanged();
private:
QLabel *m_iconLabel;
QLabel *m_titleLabel;
ElidedLabel *m_descLabel;
QWidget *m_controlWidget;
QString m_configKey;
QString m_iconSvgPath;
QVariant m_defaultValue;
QHBoxLayout *m_mainLayout;
};
#endif
@@ -1,429 +0,0 @@
#include "settingssubpanel.h"
#include "../managers/thememanager.h"
#include <config/configstore.h>
#include <config/config_keys.h>
#include <QEasingCurve>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
SettingsSubPanel::SettingsSubPanel(const QString &iconPath, QWidget *parent)
: QFrame(parent), m_iconPath(iconPath) {
setObjectName("SettingsSubPanel");
setMaximumHeight(0);
setMinimumHeight(0);
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
m_outerLayout = new QHBoxLayout(this);
m_outerLayout->setContentsMargins(16, 8, 16, 8);
m_outerLayout->setSpacing(12);
m_iconLabel = new QLabel(this);
m_iconLabel->setFixedSize(16, 16);
m_iconLabel->setAlignment(Qt::AlignCenter);
m_iconLabel->setObjectName("SettingsCardIcon");
updateIcon();
m_outerLayout->addWidget(m_iconLabel, 0, Qt::AlignVCenter);
m_contentLayout = new QHBoxLayout();
m_contentLayout->setContentsMargins(0, 0, 0, 0);
m_contentLayout->setSpacing(12);
m_contentLayout->setAlignment(Qt::AlignVCenter);
m_outerLayout->addLayout(m_contentLayout, 1);
m_animation = new QPropertyAnimation(this, "panelHeight", this);
m_animation->setDuration(250);
m_animation->setEasingCurve(QEasingCurve::OutCubic);
connect(m_animation, &QPropertyAnimation::finished, this, [this]() {
if (!m_expanded) {
hide();
}
});
connect(ThemeManager::instance(), &ThemeManager::themeChanged,
this, [this]() {
updateStyleSheet();
updateIcon();
});
updateStyleSheet();
hide();
}
void SettingsSubPanel::updateIcon() {
if (m_iconPath.isEmpty()) return;
QIcon icon = ThemeManager::getAdaptiveIcon(m_iconPath);
m_iconLabel->setPixmap(icon.pixmap(16, 16));
}
void SettingsSubPanel::updateStyleSheet() {
bool isDark = ThemeManager::instance()->isDarkMode();
QString accent = isDark ? "#3B82F6" : "#4A90D9";
if (isDark) {
setStyleSheet(QString(
"#SettingsSubPanel {"
" background: rgba(255,255,255,0.025);"
" border: 1px solid rgba(255,255,255,0.06);"
" border-left: 3px solid %1;"
" border-radius: 12px;"
" margin-left: 20px;"
"}"
"#SettingsSubPanel ModernComboBox {"
" background-color: rgba(255,255,255,0.07);"
" border: 1px solid rgba(255,255,255,0.10);"
" border-radius: 8px;"
" color: #E2E8F0;"
" font-size: 13px; font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 5px 32px 5px 16px;"
" min-width: 120px;"
" selection-background-color: transparent;"
"}"
"#SettingsSubPanel ModernComboBox:hover {"
" background-color: rgba(255,255,255,0.10);"
" border-color: rgba(255,255,255,0.18);"
"}"
"#SettingsSubPanel ModernComboBox:focus {"
" border-color: #3B82F6;"
" background-color: rgba(59,130,246,0.08);"
" outline: none;"
"}"
"#SettingsSubPanel ModernComboBox::drop-down {"
" subcontrol-origin: padding; subcontrol-position: center right;"
" width: 32px; border: none; background: transparent;"
"}"
"#SettingsSubPanel ModernComboBox::down-arrow {"
" image: url(':/svg/dark/chevron-down.svg'); width: 14px; height: 14px;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView {"
" background-color: #1E293B;"
" border: 1px solid rgba(255,255,255,0.10);"
" border-radius: 8px; color: #E2E8F0; font-size: 13px;"
" outline: none; padding: 4px;"
" selection-background-color: rgba(59,130,246,0.20);"
" selection-color: #93C5FD;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item {"
" padding: 5px 12px; border-radius: 6px;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:hover {"
" background-color: rgba(255,255,255,0.07); color: #F8FAFC;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:selected {"
" background-color: rgba(59,130,246,0.20); color: #93C5FD;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:selected:hover {"
" background-color: rgba(59,130,246,0.20); color: #93C5FD;"
"}"
"#SettingsSubPanel #SettingsCardDesc {"
" color: #64748B;"
" font-size: 12px;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
"}"
"#SettingsSubPanel QLineEdit {"
" background-color: rgba(255,255,255,0.07);"
" border: 1px solid rgba(255,255,255,0.10);"
" border-radius: 8px;"
" color: #E2E8F0;"
" font-size: 13px; font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 5px 12px;"
" max-width: 280px;"
"}"
"#SettingsSubPanel QLineEdit:hover {"
" border-color: rgba(255,255,255,0.18);"
"}"
"#SettingsSubPanel QLineEdit:focus {"
" border-color: #3B82F6;"
" background-color: rgba(59,130,246,0.08);"
"}"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue,"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue:hover,"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue:focus,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue:hover,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue:focus {"
" background: transparent;"
" border: none;"
" border-radius: 0px;"
" color: #BFDBFE;"
" font-size: 13px;"
" font-weight: 700;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 0px;"
" min-width: 0px;"
" max-width: 16777215px;"
" selection-background-color: rgba(96,165,250,0.22);"
" selection-color: #F8FAFC;"
"}"
"#SettingsSubPanel QFrame#TagInputContainer {"
" background-color: rgba(255,255,255,0.07);"
" border: 1px solid rgba(255,255,255,0.10);"
" border-radius: 8px;"
"}"
"#SettingsSubPanel QFrame#TagInputContainer:hover {"
" border-color: rgba(255,255,255,0.18);"
"}"
"#SettingsSubPanel QFrame#TagInputContainer:focus-within {"
" border-color: #3B82F6;"
" background-color: rgba(255,255,255,0.07);"
"}"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit,"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit:hover,"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit:focus {"
" background: transparent;"
" border: none;"
" border-radius: 0px;"
" color: #E2E8F0;"
" font-size: 13px;"
" font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 0px 4px;"
" min-width: 0px;"
" max-width: 16777215px;"
" selection-background-color: rgba(59,130,246,0.25);"
" selection-color: #FFFFFF;"
"}"
).arg(accent));
} else {
setStyleSheet(QString(
"#SettingsSubPanel {"
" background: rgba(0,0,0,0.018);"
" border: 1px solid rgba(0,0,0,0.06);"
" border-left: 3px solid %1;"
" border-radius: 12px;"
" margin-left: 20px;"
"}"
"#SettingsSubPanel ModernComboBox {"
" background-color: #FFFFFF;"
" border: 1px solid #CBD5E1;"
" border-radius: 8px;"
" color: #1E293B;"
" font-size: 13px; font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 5px 32px 5px 16px;"
" min-width: 120px;"
" selection-background-color: transparent;"
"}"
"#SettingsSubPanel ModernComboBox:hover {"
" background-color: #F8FAFC; border-color: #94A3B8;"
"}"
"#SettingsSubPanel ModernComboBox:focus {"
" border-color: #3B82F6; background-color: #EFF6FF; outline: none;"
"}"
"#SettingsSubPanel ModernComboBox::drop-down {"
" subcontrol-origin: padding; subcontrol-position: center right;"
" width: 32px; border: none; background: transparent;"
"}"
"#SettingsSubPanel ModernComboBox::down-arrow {"
" image: url(':/svg/light/chevron-down.svg'); width: 14px; height: 14px;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView {"
" background-color: #FFFFFF;"
" border: 1px solid #E2E8F0;"
" border-radius: 8px; color: #1E293B; font-size: 13px;"
" outline: none; padding: 4px;"
" selection-background-color: rgba(59,130,246,0.10);"
" selection-color: #2563EB;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item {"
" padding: 5px 12px; border-radius: 6px;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:hover {"
" background-color: #F1F5F9; color: #0F172A;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:selected {"
" background-color: rgba(59,130,246,0.10); color: #2563EB;"
"}"
"#SettingsSubPanel ModernComboBox QAbstractItemView::item:selected:hover {"
" background-color: rgba(59,130,246,0.10); color: #2563EB;"
"}"
"#SettingsSubPanel #SettingsCardDesc {"
" color: #94A3B8;"
" font-size: 12px;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
"}"
"#SettingsSubPanel QLineEdit {"
" background-color: #FFFFFF;"
" border: 1px solid #CBD5E1;"
" border-radius: 8px;"
" color: #1E293B;"
" font-size: 13px; font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 5px 12px;"
" max-width: 280px;"
"}"
"#SettingsSubPanel QLineEdit:hover {"
" border-color: #94A3B8;"
"}"
"#SettingsSubPanel QLineEdit:focus {"
" border-color: #3B82F6; background-color: #EFF6FF;"
"}"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue,"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue:hover,"
"#SettingsSubPanel QLineEdit#DanmakuOptionSliderValue:focus,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue:hover,"
"#SettingsSubPanel QLineEdit#SubtitleOptionSliderValue:focus {"
" background: transparent;"
" border: none;"
" border-radius: 0px;"
" color: #2563EB;"
" font-size: 13px;"
" font-weight: 700;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 0px;"
" min-width: 0px;"
" max-width: 16777215px;"
" selection-background-color: rgba(59,130,246,0.16);"
" selection-color: #0F172A;"
"}"
"#SettingsSubPanel QFrame#TagInputContainer {"
" background-color: #FFFFFF;"
" border: 1px solid #CBD5E1;"
" border-radius: 8px;"
"}"
"#SettingsSubPanel QFrame#TagInputContainer:hover {"
" border-color: #94A3B8;"
"}"
"#SettingsSubPanel QFrame#TagInputContainer:focus-within {"
" border-color: #3B82F6;"
" background-color: #FFFFFF;"
"}"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit,"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit:hover,"
"#SettingsSubPanel QLineEdit#TagInputInlineEdit:focus {"
" background: transparent;"
" border: none;"
" border-radius: 0px;"
" color: #1E293B;"
" font-size: 13px;"
" font-weight: 500;"
" font-family: 'Segoe UI','Microsoft YaHei',sans-serif;"
" padding: 0px 4px;"
" min-width: 0px;"
" max-width: 16777215px;"
" selection-background-color: rgba(59,130,246,0.25);"
" selection-color: #1E293B;"
"}"
).arg(accent));
}
}
void SettingsSubPanel::expand() {
if (m_expanded) return;
m_expanded = true;
show();
auto scrollToSelf = [this]() {
QWidget *p = parentWidget();
while (p) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(p)) {
scrollArea->ensureWidgetVisible(this, 0, 60);
break;
}
p = p->parentWidget();
}
};
int targetH = contentHeight();
bool reduceAnimations = ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
if (reduceAnimations) {
setPanelHeight(targetH);
QTimer::singleShot(0, this, scrollToSelf);
} else {
m_animation->stop();
m_animation->setStartValue(0);
m_animation->setEndValue(targetH);
m_animation->start();
connect(m_animation, &QPropertyAnimation::finished,
this, scrollToSelf, Qt::SingleShotConnection);
}
emit expandedChanged(true);
}
void SettingsSubPanel::collapse() {
if (!m_expanded) return;
m_expanded = false;
bool reduceAnimations = ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
if (reduceAnimations) {
setPanelHeight(0);
hide();
} else {
m_animation->stop();
m_animation->setStartValue(maximumHeight());
m_animation->setEndValue(0);
m_animation->start();
}
emit expandedChanged(false);
}
void SettingsSubPanel::setExpanded(bool expanded) {
if (expanded) expand();
else collapse();
}
void SettingsSubPanel::setExpandedImmediately(bool expanded) {
m_animation->stop();
m_expanded = expanded;
if (expanded) {
setPanelHeight(contentHeight());
show();
} else {
setPanelHeight(0);
hide();
}
emit expandedChanged(expanded);
}
void SettingsSubPanel::initExpanded() {
setExpandedImmediately(true);
}
int SettingsSubPanel::panelHeight() const {
return maximumHeight();
}
void SettingsSubPanel::setPanelHeight(int h) {
setMaximumHeight(h);
setMinimumHeight(h);
}
int SettingsSubPanel::contentHeight() const {
if (!m_outerLayout) {
return 48;
}
m_outerLayout->invalidate();
return qMax(48, m_outerLayout->sizeHint().height());
}
@@ -1,50 +0,0 @@
#ifndef SETTINGSSUBPANEL_H
#define SETTINGSSUBPANEL_H
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPropertyAnimation>
class SettingsSubPanel : public QFrame {
Q_OBJECT
Q_PROPERTY(int panelHeight READ panelHeight WRITE setPanelHeight)
public:
explicit SettingsSubPanel(const QString &iconPath, QWidget *parent = nullptr);
QHBoxLayout *contentLayout() const { return m_contentLayout; }
bool isExpanded() const { return m_expanded; }
public slots:
void expand();
void collapse();
void setExpanded(bool expanded);
void setExpandedImmediately(bool expanded);
void initExpanded();
signals:
void expandedChanged(bool expanded);
private:
int panelHeight() const;
void setPanelHeight(int h);
int contentHeight() const;
void updateStyleSheet();
void updateIcon();
bool m_expanded = false;
QString m_iconPath;
QLabel *m_iconLabel;
QHBoxLayout *m_outerLayout;
QHBoxLayout *m_contentLayout;
QPropertyAnimation *m_animation;
};
#endif
-153
View File
@@ -1,153 +0,0 @@
#include "shimmerwidget.h"
#include "../managers/thememanager.h"
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
ShimmerWidget::ShimmerWidget(QWidget* parent)
: QWidget(parent)
{
setObjectName("shimmer-widget");
setAttribute(Qt::WA_TransparentForMouseEvents, true);
m_animation = new QPropertyAnimation(this, "shimmerPhase", this);
m_animation->setStartValue(0.0);
m_animation->setEndValue(1.0);
m_animation->setDuration(1800);
m_animation->setLoopCount(-1);
m_animation->setEasingCurve(QEasingCurve::Linear);
}
ShimmerWidget::~ShimmerWidget()
{
if (m_animation) {
m_animation->stop();
}
}
void ShimmerWidget::setCardSize(const QSize& size)
{
m_cardSize = size;
update();
}
void ShimmerWidget::setCardSpacing(int spacing)
{
m_cardSpacing = spacing;
update();
}
void ShimmerWidget::setCardRadius(int radius)
{
m_cardRadius = radius;
update();
}
void ShimmerWidget::setShowSubtitle(bool show)
{
m_showSubtitle = show;
update();
}
qreal ShimmerWidget::shimmerPhase() const
{
return m_shimmerPhase;
}
void ShimmerWidget::setShimmerPhase(qreal phase)
{
m_shimmerPhase = phase;
update();
}
void ShimmerWidget::startAnimation()
{
if (m_animation && m_animation->state() != QAbstractAnimation::Running) {
m_shimmerPhase = 0.0;
m_animation->start();
}
}
void ShimmerWidget::stopAnimation()
{
if (m_animation) {
m_animation->stop();
}
}
bool ShimmerWidget::isAnimating() const
{
return m_animation &&
m_animation->state() == QAbstractAnimation::Running;
}
void ShimmerWidget::paintEvent(QPaintEvent* event)
{
Q_UNUSED(event);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
const bool isDark = ThemeManager::instance()->isDarkMode();
const QColor baseBg = isDark ? QColor(50, 50, 55) : QColor(229, 231, 235);
const int padding = 8;
const int cardW = m_cardSize.width();
const int cardH = m_cardSize.height();
const int imgW = cardW - padding * 2;
const qreal ratio =
static_cast<qreal>(cardH) / cardW;
const int imgH =
(ratio < 1.0) ? qRound(imgW * 9.0 / 16.0) : qRound(imgW * 1.5);
QPainterPath allShapes;
const int maxCards = 60;
int count = 0;
int y = 0;
while (y < height() + cardH && count < maxCards) {
int x = 0;
while (x < width() + cardW && count < maxCards) {
QRectF imgRect(x + padding, y + padding, imgW, imgH);
allShapes.addRoundedRect(imgRect, m_cardRadius, m_cardRadius);
x += cardW + m_cardSpacing;
++count;
}
y += cardH + m_cardSpacing;
}
painter.setPen(Qt::NoPen);
painter.setBrush(baseBg);
painter.drawPath(allShapes);
const int totalW = width();
const int shimmerW = qRound(totalW * 0.4);
const int shimmerX =
qRound(m_shimmerPhase * (totalW + shimmerW)) - shimmerW;
QLinearGradient gradient(shimmerX, 0, shimmerX + shimmerW, 0);
const QColor shimmerHi =
isDark ? QColor(255, 255, 255, 14) : QColor(255, 255, 255, 100);
gradient.setColorAt(0.0, Qt::transparent);
gradient.setColorAt(0.5, shimmerHi);
gradient.setColorAt(1.0, Qt::transparent);
painter.save();
painter.setClipPath(allShapes);
painter.setBrush(gradient);
painter.drawRect(rect());
painter.restore();
}
-52
View File
@@ -1,52 +0,0 @@
#ifndef SHIMMERWIDGET_H
#define SHIMMERWIDGET_H
#include <QWidget>
#include <QSize>
class QPropertyAnimation;
class ShimmerWidget : public QWidget
{
Q_OBJECT
Q_PROPERTY(qreal shimmerPhase READ shimmerPhase WRITE setShimmerPhase)
public:
explicit ShimmerWidget(QWidget* parent = nullptr);
~ShimmerWidget() override;
void setCardSize(const QSize& size);
void setCardSpacing(int spacing);
void setCardRadius(int radius);
void setShowSubtitle(bool show);
qreal shimmerPhase() const;
void setShimmerPhase(qreal phase);
void startAnimation();
void stopAnimation();
bool isAnimating() const;
protected:
void paintEvent(QPaintEvent* event) override;
private:
QSize m_cardSize{160, 270};
int m_cardSpacing = 0;
int m_cardRadius = 8;
bool m_showSubtitle = true;
qreal m_shimmerPhase = 0.0;
QPropertyAnimation* m_animation = nullptr;
};
#endif
@@ -1,4 +1,5 @@
#include "slidingstackedwidget.h"
#include "../tokens/AnimationTokens.h"
#include <QWidget>
#include <QLabel>
#include <QScreen>
@@ -17,8 +18,8 @@ QPoint interpolatePoint(const QPoint &start, const QPoint &end, qreal progress)
SlidingStackedWidget::SlidingStackedWidget(QWidget *parent)
: QStackedWidget(parent),
m_speed(350),
m_animationType(QEasingCurve::OutCubic),
m_speed(AnimationTokens::DurationMs::kSlow),
m_animationType(AnimationTokens::Curve::enter().type()),
m_isAnimating(false),
m_nextIndex(0)
{
@@ -45,6 +46,35 @@ void SlidingStackedWidget::slideInWgt(QWidget *widget, SlideDirection direction)
}
}
void SlidingStackedWidget::jumpToWgt(QWidget *widget)
{
int idx = indexOf(widget);
if (idx != -1) {
jumpToIdx(idx);
}
}
void SlidingStackedWidget::jumpToIdx(int idx)
{
if (idx < 0 || idx >= count())
return;
if (currentIndex() == idx && !m_isAnimating)
return;
if (m_isAnimating) {
m_slideTimer.stop();
animationDoneSlot();
}
m_nextIndex = idx;
setCurrentIndex(idx);
QWidget *w = widget(idx);
if (w) w->raise();
flushPendingWidgetDisposals();
Q_EMIT animationFinished();
}
void SlidingStackedWidget::slideInIdx(int idx, SlideDirection direction)
{
if (idx < 0 || idx >= count())
@@ -55,21 +85,16 @@ void SlidingStackedWidget::slideInIdx(int idx, SlideDirection direction)
return;
if (m_isAnimating) {
m_slideTimer.stop();
animationDoneSlot();
animationDoneSlot();
}
bool reduceAnimations = ConfigStore::instance()->get<bool>(ConfigKeys::UiAnimations, false);
if (reduceAnimations) {
m_nextIndex = idx;
setCurrentIndex(idx);
QWidget *w = widget(idx);
if (w) w->raise();
flushPendingWidgetDisposals();
Q_EMIT animationFinished();
jumpToIdx(idx);
return;
}
@@ -30,9 +30,13 @@ public:
void setSpeed(int speed);
void setEasingCurve(QEasingCurve::Type curveType);
void slideInIdx(int idx, SlideDirection direction = Automatic);
void slideInWgt(QWidget *widget, SlideDirection direction = Automatic);
// Instant switch (no slide), safely finishing any in-flight animation.
// For targets with their own entrance animation (e.g. the player view).
void jumpToIdx(int idx);
void jumpToWgt(QWidget *widget);
void disposeWidgetWhenSafe(QWidget *widget);
Q_SIGNALS:

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