From 2ee3a234a6e32b50bc2cd1d296b5ec6bb820fcc9 Mon Sep 17 00:00:00 2001 From: dela Date: Mon, 27 Jul 2026 16:41:18 +0800 Subject: [PATCH] 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. --- CMakeLists.txt | 7 +- ai/qml-migration-status.md | 137 +- cmake/QEmbyPackaging.cmake | 4 +- packaging/arch/PKGBUILD | 9 +- packaging/arch/README.md | 49 +- src/qEmbyApp/CMakeLists.txt | 22 +- src/qEmbyApp/components/adminusercard.cpp | 202 - src/qEmbyApp/components/adminusercard.h | 32 - src/qEmbyApp/components/collectioncard.cpp | 342 - src/qEmbyApp/components/collectioncard.h | 71 - src/qEmbyApp/components/collectiongrid.cpp | 341 - src/qEmbyApp/components/collectiongrid.h | 67 - .../components/danmakuserverdialog.cpp | 887 - src/qEmbyApp/components/danmakuserverdialog.h | 76 - .../danmakuserverlistitemwidget.cpp | 230 - .../components/danmakuserverlistitemwidget.h | 49 - .../components/dashboardsectionorderstrip.cpp | 562 - .../components/dashboardsectionorderstrip.h | 78 - .../dashboardsectionorderwidget.cpp | 97 - .../components/dashboardsectionorderwidget.h | 30 - .../components/detailactionwidget.cpp | 459 - src/qEmbyApp/components/detailactionwidget.h | 63 - .../components/detailbottominfowidget.cpp | 494 - .../components/detailbottominfowidget.h | 67 - .../components/detailcontentwidget.cpp | 125 - src/qEmbyApp/components/detailcontentwidget.h | 48 - src/qEmbyApp/components/detailtagbutton.cpp | 27 - src/qEmbyApp/components/detailtagbutton.h | 19 - src/qEmbyApp/components/flowlayout.cpp | 225 - src/qEmbyApp/components/flowlayout.h | 43 - .../components/galleryoverlayfilter.cpp | 27 - .../components/galleryoverlayfilter.h | 19 - .../components/horizontallistviewgallery.cpp | 560 - .../components/horizontallistviewgallery.h | 97 - .../components/horizontalwidgetgallery.cpp | 285 - .../components/horizontalwidgetgallery.h | 57 - src/qEmbyApp/components/idanmakusurface.h | 23 + src/qEmbyApp/components/libraryactionmenu.cpp | 91 - src/qEmbyApp/components/libraryactionmenu.h | 31 - .../components/librarycontextmenurequest.h | 26 - src/qEmbyApp/components/mediagridwidget.cpp | 419 - src/qEmbyApp/components/mediagridwidget.h | 76 - .../components/mediasectionwidget.cpp | 126 - src/qEmbyApp/components/mediasectionwidget.h | 59 - src/qEmbyApp/components/modernmenubutton.cpp | 200 - src/qEmbyApp/components/modernmenubutton.h | 38 - .../components/modernmenuscrollbar.cpp | 116 - src/qEmbyApp/components/modernmenuscrollbar.h | 45 - src/qEmbyApp/components/modernnumberinput.cpp | 14 - src/qEmbyApp/components/modernnumberinput.h | 14 - src/qEmbyApp/components/modernscrollpanel.cpp | 248 - src/qEmbyApp/components/modernscrollpanel.h | 49 - src/qEmbyApp/components/modernsortbutton.cpp | 121 - src/qEmbyApp/components/modernsortbutton.h | 53 - src/qEmbyApp/components/mpvwidget.cpp | 344 - src/qEmbyApp/components/mpvwidget.h | 63 - src/qEmbyApp/components/playercombobox.cpp | 258 - src/qEmbyApp/components/playercombobox.h | 72 - .../components/playerdanmakucontroller.cpp | 131 +- .../components/playerdanmakucontroller.h | 15 +- .../components/playerlongpresshandler.cpp | 825 - .../components/playerlongpresshandler.h | 123 - .../components/playermediaswitcherpanel.cpp | 416 - .../components/playermediaswitcherpanel.h | 73 - src/qEmbyApp/components/playerosdlayer.cpp | 354 - src/qEmbyApp/components/playerosdlayer.h | 75 - .../components/playerstatisticsoverlay.cpp | 204 - .../components/playerstatisticsoverlay.h | 49 - src/qEmbyApp/components/playerwindow.cpp | 87 - src/qEmbyApp/components/playerwindow.h | 33 - .../components/searchcompleterpopup.cpp | 352 - .../components/searchcompleterpopup.h | 41 - src/qEmbyApp/components/searchhistorychip.cpp | 552 - src/qEmbyApp/components/searchhistorychip.h | 65 - .../components/searchhistorypopup.cpp | 1217 - src/qEmbyApp/components/searchhistorypopup.h | 95 - src/qEmbyApp/components/seektimebubble.cpp | 80 - src/qEmbyApp/components/seektimebubble.h | 37 - src/qEmbyApp/components/serverwheelview.cpp | 347 - src/qEmbyApp/components/serverwheelview.h | 62 - src/qEmbyApp/components/settingscard.cpp | 209 - src/qEmbyApp/components/settingscard.h | 47 - src/qEmbyApp/components/settingssubpanel.cpp | 429 - src/qEmbyApp/components/settingssubpanel.h | 50 - src/qEmbyApp/components/shadowoverlay.cpp | 90 - src/qEmbyApp/components/shadowoverlay.h | 51 - src/qEmbyApp/components/shimmerwidget.cpp | 161 - src/qEmbyApp/components/shimmerwidget.h | 52 - src/qEmbyApp/components/splitplayerbutton.cpp | 278 - src/qEmbyApp/components/splitplayerbutton.h | 93 - .../components/themetransitionwidget.cpp | 37 - .../components/themetransitionwidget.h | 28 - .../components/transcodingcodeclistwidget.cpp | 305 - .../components/transcodingcodeclistwidget.h | 54 - .../components/transcodingcodecrowwidget.cpp | 179 - .../components/transcodingcodecrowwidget.h | 52 - src/qEmbyApp/main.cpp | 162 +- src/qEmbyApp/mainwindow.cpp | 1210 - src/qEmbyApp/mainwindow.h | 68 - src/qEmbyApp/managers/playbackmanager.cpp | 97 +- src/qEmbyApp/managers/playbackmanager.h | 47 +- src/qEmbyApp/qml/AppShell.qml | 143 + src/qEmbyApp/qml/CategoryPage.qml | 38 +- src/qEmbyApp/qml/DashboardPage.qml | 100 +- src/qEmbyApp/qml/DetailPage.qml | 237 +- src/qEmbyApp/qml/EmptyState.qml | 56 +- src/qEmbyApp/qml/FavoritesPage.qml | 40 +- src/qEmbyApp/qml/IndependentPlayerWindow.qml | 39 + src/qEmbyApp/qml/LibraryPage.qml | 190 +- src/qEmbyApp/qml/LoginPage.qml | 129 +- src/qEmbyApp/qml/ManageDashboardPage.qml | 5 + src/qEmbyApp/qml/ManagePage.qml | 203 + src/qEmbyApp/qml/MpvSpikeHarness.qml | 17 + src/qEmbyApp/qml/MpvSpikePage.qml | 82 + src/qEmbyApp/qml/PlayerPage.qml | 1810 ++ src/qEmbyApp/qml/Router.qml | 87 +- src/qEmbyApp/qml/SearchPage.qml | 251 +- src/qEmbyApp/qml/SettingsGeneralPage.qml | 37 +- src/qEmbyApp/qml/SettingsLibraryPage.qml | 91 +- src/qEmbyApp/qml/SettingsPage.qml | 263 + src/qEmbyApp/qml/SettingsPlayerPage.qml | 214 + src/qEmbyApp/qml/SideBar.qml | 651 + src/qEmbyApp/qml/TitleBar.qml | 524 + .../qml/components/CollapsibleSection.qml | 51 +- src/qEmbyApp/qml/components/ConfirmDialog.qml | 61 + src/qEmbyApp/qml/components/ErrorState.qml | 62 +- src/qEmbyApp/qml/components/LoadingState.qml | 52 +- src/qEmbyApp/qml/components/MediaCard.qml | 183 +- src/qEmbyApp/qml/components/MediaGrid.qml | 33 +- src/qEmbyApp/qml/components/MediaList.qml | 12 +- src/qEmbyApp/qml/components/MediaSection.qml | 73 +- src/qEmbyApp/qml/components/ModernDialog.qml | 150 - src/qEmbyApp/qml/components/PageHeader.qml | 122 + .../qml/components/SearchHistoryPopup.qml | 339 + src/qEmbyApp/qml/components/SegmentedTabs.qml | 76 + src/qEmbyApp/qml/components/SettingsCard.qml | 40 +- src/qEmbyApp/qml/components/ThemedButton.qml | 43 +- .../qml/components/ThemedComboBox.qml | 45 +- src/qEmbyApp/qml/components/ThemedDialog.qml | 97 +- .../qml/components/ThemedScrollBar.qml | 27 +- src/qEmbyApp/qml/components/ThemedSlider.qml | 110 +- src/qEmbyApp/qml/components/ThemedSwitch.qml | 37 +- .../qml/components/ThemedTextField.qml | 24 +- src/qEmbyApp/qml/components/ToastHost.qml | 64 +- src/qEmbyApp/qml/theme/Theme.qml | 81 - src/qEmbyApp/quick/appshellhost.cpp | 360 + src/qEmbyApp/quick/appshellhost.h | 85 + .../danmakupainteditem.cpp} | 231 +- .../danmakupainteditem.h} | 51 +- src/qEmbyApp/quick/dashboardviewmodel.cpp | 41 +- src/qEmbyApp/quick/dashboardviewmodel.h | 22 +- src/qEmbyApp/quick/independentplayerhost.cpp | 146 + src/qEmbyApp/quick/independentplayerhost.h | 45 + src/qEmbyApp/quick/loginviewmodel.cpp | 35 +- src/qEmbyApp/quick/loginviewmodel.h | 16 + src/qEmbyApp/quick/manageroutecontroller.cpp | 179 + src/qEmbyApp/quick/manageroutecontroller.h | 82 + src/qEmbyApp/quick/mediaroutecontroller.cpp | 531 + src/qEmbyApp/quick/mediaroutecontroller.h | 102 + src/qEmbyApp/quick/mpvitem.cpp | 484 + src/qEmbyApp/quick/mpvitem.h | 116 + src/qEmbyApp/quick/playerroutecontroller.cpp | 82 + src/qEmbyApp/quick/playerroutecontroller.h | 51 + src/qEmbyApp/quick/playerviewmodel.cpp | 1180 + src/qEmbyApp/quick/playerviewmodel.h | 226 + src/qEmbyApp/quick/quickpagehost.cpp | 73 - src/qEmbyApp/quick/quickpagehost.h | 39 - src/qEmbyApp/quick/quickrouterhost.cpp | 164 - src/qEmbyApp/quick/quickrouterhost.h | 56 - src/qEmbyApp/quick/quicktheme.cpp | 44 +- src/qEmbyApp/quick/quicktheme.h | 15 +- src/qEmbyApp/quick/searchhistoryviewmodel.cpp | 119 + src/qEmbyApp/quick/searchhistoryviewmodel.h | 82 + src/qEmbyApp/quick/settingsviewmodel.cpp | 477 +- src/qEmbyApp/quick/settingsviewmodel.h | 64 +- .../quick/shelllifecyclecontroller.cpp | 160 + src/qEmbyApp/quick/shelllifecyclecontroller.h | 81 + src/qEmbyApp/quick/sidebarviewmodel.cpp | 273 + src/qEmbyApp/quick/sidebarviewmodel.h | 129 + src/qEmbyApp/resources/i18n/qEmby_fr_FR.ts | 21929 ++++++++++------ src/qEmbyApp/resources/i18n/qEmby_zh_CN.ts | 20774 ++++++++++----- .../views/admin/activitylogdelegate.cpp | 162 - .../views/admin/activitylogdelegate.h | 69 - src/qEmbyApp/views/admin/librarycard.cpp | 402 - src/qEmbyApp/views/admin/librarycard.h | 83 - src/qEmbyApp/views/admin/librarygrid.cpp | 296 - src/qEmbyApp/views/admin/librarygrid.h | 62 - src/qEmbyApp/views/admin/managepagebase.cpp | 17 - src/qEmbyApp/views/admin/managepagebase.h | 22 - src/qEmbyApp/views/admin/manageview.cpp | 273 - src/qEmbyApp/views/admin/manageview.h | 44 - src/qEmbyApp/views/admin/nowplayingcard.cpp | 281 - src/qEmbyApp/views/admin/nowplayingcard.h | 45 - src/qEmbyApp/views/admin/pagecollections.cpp | 40 - src/qEmbyApp/views/admin/pagecollections.h | 25 - src/qEmbyApp/views/admin/pagedashboard.cpp | 502 - src/qEmbyApp/views/admin/pagedashboard.h | 66 - src/qEmbyApp/views/admin/pagelibraries.cpp | 43 - src/qEmbyApp/views/admin/pagelibraries.h | 25 - src/qEmbyApp/views/admin/pagelogs.cpp | 36 - src/qEmbyApp/views/admin/pagelogs.h | 29 - src/qEmbyApp/views/admin/pagetasks.cpp | 50 - src/qEmbyApp/views/admin/pagetasks.h | 33 - src/qEmbyApp/views/admin/pagetranscoding.cpp | 76 - src/qEmbyApp/views/admin/pagetranscoding.h | 29 - src/qEmbyApp/views/admin/pageusers.cpp | 55 - src/qEmbyApp/views/admin/pageusers.h | 25 - src/qEmbyApp/views/media/detailview.cpp | 160 - src/qEmbyApp/views/media/detailview.h | 37 - src/qEmbyApp/views/media/libraryview.cpp | 91 - src/qEmbyApp/views/media/libraryview.h | 38 - .../views/media/mediacarddelegate.cpp | 1017 - src/qEmbyApp/views/media/mediacarddelegate.h | 146 - src/qEmbyApp/views/media/mediaitem.h | 41 - src/qEmbyApp/views/media/medialistmodel.cpp | 585 - src/qEmbyApp/views/media/medialistmodel.h | 101 - src/qEmbyApp/views/media/overviewdialog.cpp | 90 - src/qEmbyApp/views/media/overviewdialog.h | 22 - src/qEmbyApp/views/media/playerview.cpp | 5459 ---- src/qEmbyApp/views/media/playerview.h | 348 - src/qEmbyApp/views/media/seasonview.cpp | 72 - src/qEmbyApp/views/media/seasonview.h | 33 - src/qEmbyApp/views/public/loginview.cpp | 35 - src/qEmbyApp/views/public/loginview.h | 30 - src/qEmbyApp/views/search/searchview.cpp | 58 - src/qEmbyApp/views/search/searchview.h | 25 - src/qEmbyApp/views/settings/pageabout.cpp | 158 - src/qEmbyApp/views/settings/pageabout.h | 32 - .../views/settings/pageappearance.cpp | 179 - src/qEmbyApp/views/settings/pageappearance.h | 12 - src/qEmbyApp/views/settings/pagegeneral.cpp | 255 - src/qEmbyApp/views/settings/pagegeneral.h | 12 - src/qEmbyApp/views/settings/pagelibrary.cpp | 340 - src/qEmbyApp/views/settings/pagelibrary.h | 24 - src/qEmbyApp/views/settings/pageplayer.cpp | 1302 - src/qEmbyApp/views/settings/pageplayer.h | 12 - .../views/settings/settingspagebase.cpp | 19 - .../views/settings/settingspagebase.h | 23 - src/qEmbyApp/views/settings/settingsview.cpp | 309 - src/qEmbyApp/views/settings/settingsview.h | 58 - src/qEmbyApp/views/user/categoryview.cpp | 78 - src/qEmbyApp/views/user/categoryview.h | 33 - src/qEmbyApp/views/user/dashboardview.cpp | 249 - src/qEmbyApp/views/user/dashboardview.h | 46 - src/qEmbyApp/views/user/favoritesview.cpp | 72 - src/qEmbyApp/views/user/favoritesview.h | 30 - .../views/user/homequickrouterview.cpp | 550 - src/qEmbyApp/views/user/homequickrouterview.h | 68 - src/qEmbyApp/views/user/homeview.cpp | 2438 -- src/qEmbyApp/views/user/homeview.h | 200 - src/qEmbyCore/services/media/mediaservice.cpp | 9 +- 251 files changed, 40121 insertions(+), 50423 deletions(-) delete mode 100644 src/qEmbyApp/components/adminusercard.cpp delete mode 100644 src/qEmbyApp/components/adminusercard.h delete mode 100644 src/qEmbyApp/components/collectioncard.cpp delete mode 100644 src/qEmbyApp/components/collectioncard.h delete mode 100644 src/qEmbyApp/components/collectiongrid.cpp delete mode 100644 src/qEmbyApp/components/collectiongrid.h delete mode 100644 src/qEmbyApp/components/danmakuserverdialog.cpp delete mode 100644 src/qEmbyApp/components/danmakuserverdialog.h delete mode 100644 src/qEmbyApp/components/danmakuserverlistitemwidget.cpp delete mode 100644 src/qEmbyApp/components/danmakuserverlistitemwidget.h delete mode 100644 src/qEmbyApp/components/dashboardsectionorderstrip.cpp delete mode 100644 src/qEmbyApp/components/dashboardsectionorderstrip.h delete mode 100644 src/qEmbyApp/components/dashboardsectionorderwidget.cpp delete mode 100644 src/qEmbyApp/components/dashboardsectionorderwidget.h delete mode 100644 src/qEmbyApp/components/detailactionwidget.cpp delete mode 100644 src/qEmbyApp/components/detailactionwidget.h delete mode 100644 src/qEmbyApp/components/detailbottominfowidget.cpp delete mode 100644 src/qEmbyApp/components/detailbottominfowidget.h delete mode 100644 src/qEmbyApp/components/detailcontentwidget.cpp delete mode 100644 src/qEmbyApp/components/detailcontentwidget.h delete mode 100644 src/qEmbyApp/components/detailtagbutton.cpp delete mode 100644 src/qEmbyApp/components/detailtagbutton.h delete mode 100644 src/qEmbyApp/components/flowlayout.cpp delete mode 100644 src/qEmbyApp/components/flowlayout.h delete mode 100644 src/qEmbyApp/components/galleryoverlayfilter.cpp delete mode 100644 src/qEmbyApp/components/galleryoverlayfilter.h delete mode 100644 src/qEmbyApp/components/horizontallistviewgallery.cpp delete mode 100644 src/qEmbyApp/components/horizontallistviewgallery.h delete mode 100644 src/qEmbyApp/components/horizontalwidgetgallery.cpp delete mode 100644 src/qEmbyApp/components/horizontalwidgetgallery.h create mode 100644 src/qEmbyApp/components/idanmakusurface.h delete mode 100644 src/qEmbyApp/components/libraryactionmenu.cpp delete mode 100644 src/qEmbyApp/components/libraryactionmenu.h delete mode 100644 src/qEmbyApp/components/librarycontextmenurequest.h delete mode 100644 src/qEmbyApp/components/mediagridwidget.cpp delete mode 100644 src/qEmbyApp/components/mediagridwidget.h delete mode 100644 src/qEmbyApp/components/mediasectionwidget.cpp delete mode 100644 src/qEmbyApp/components/mediasectionwidget.h delete mode 100644 src/qEmbyApp/components/modernmenubutton.cpp delete mode 100644 src/qEmbyApp/components/modernmenubutton.h delete mode 100644 src/qEmbyApp/components/modernmenuscrollbar.cpp delete mode 100644 src/qEmbyApp/components/modernmenuscrollbar.h delete mode 100644 src/qEmbyApp/components/modernnumberinput.cpp delete mode 100644 src/qEmbyApp/components/modernnumberinput.h delete mode 100644 src/qEmbyApp/components/modernscrollpanel.cpp delete mode 100644 src/qEmbyApp/components/modernscrollpanel.h delete mode 100644 src/qEmbyApp/components/modernsortbutton.cpp delete mode 100644 src/qEmbyApp/components/modernsortbutton.h delete mode 100644 src/qEmbyApp/components/mpvwidget.cpp delete mode 100644 src/qEmbyApp/components/mpvwidget.h delete mode 100644 src/qEmbyApp/components/playercombobox.cpp delete mode 100644 src/qEmbyApp/components/playercombobox.h delete mode 100644 src/qEmbyApp/components/playerlongpresshandler.cpp delete mode 100644 src/qEmbyApp/components/playerlongpresshandler.h delete mode 100644 src/qEmbyApp/components/playermediaswitcherpanel.cpp delete mode 100644 src/qEmbyApp/components/playermediaswitcherpanel.h delete mode 100644 src/qEmbyApp/components/playerosdlayer.cpp delete mode 100644 src/qEmbyApp/components/playerosdlayer.h delete mode 100644 src/qEmbyApp/components/playerstatisticsoverlay.cpp delete mode 100644 src/qEmbyApp/components/playerstatisticsoverlay.h delete mode 100644 src/qEmbyApp/components/playerwindow.cpp delete mode 100644 src/qEmbyApp/components/playerwindow.h delete mode 100644 src/qEmbyApp/components/searchcompleterpopup.cpp delete mode 100644 src/qEmbyApp/components/searchcompleterpopup.h delete mode 100644 src/qEmbyApp/components/searchhistorychip.cpp delete mode 100644 src/qEmbyApp/components/searchhistorychip.h delete mode 100644 src/qEmbyApp/components/searchhistorypopup.cpp delete mode 100644 src/qEmbyApp/components/searchhistorypopup.h delete mode 100644 src/qEmbyApp/components/seektimebubble.cpp delete mode 100644 src/qEmbyApp/components/seektimebubble.h delete mode 100644 src/qEmbyApp/components/serverwheelview.cpp delete mode 100644 src/qEmbyApp/components/serverwheelview.h delete mode 100644 src/qEmbyApp/components/settingscard.cpp delete mode 100644 src/qEmbyApp/components/settingscard.h delete mode 100644 src/qEmbyApp/components/settingssubpanel.cpp delete mode 100644 src/qEmbyApp/components/settingssubpanel.h delete mode 100644 src/qEmbyApp/components/shadowoverlay.cpp delete mode 100644 src/qEmbyApp/components/shadowoverlay.h delete mode 100644 src/qEmbyApp/components/shimmerwidget.cpp delete mode 100644 src/qEmbyApp/components/shimmerwidget.h delete mode 100644 src/qEmbyApp/components/splitplayerbutton.cpp delete mode 100644 src/qEmbyApp/components/splitplayerbutton.h delete mode 100644 src/qEmbyApp/components/themetransitionwidget.cpp delete mode 100644 src/qEmbyApp/components/themetransitionwidget.h delete mode 100644 src/qEmbyApp/components/transcodingcodeclistwidget.cpp delete mode 100644 src/qEmbyApp/components/transcodingcodeclistwidget.h delete mode 100644 src/qEmbyApp/components/transcodingcodecrowwidget.cpp delete mode 100644 src/qEmbyApp/components/transcodingcodecrowwidget.h delete mode 100644 src/qEmbyApp/mainwindow.cpp delete mode 100644 src/qEmbyApp/mainwindow.h create mode 100644 src/qEmbyApp/qml/AppShell.qml create mode 100644 src/qEmbyApp/qml/IndependentPlayerWindow.qml create mode 100644 src/qEmbyApp/qml/ManagePage.qml create mode 100644 src/qEmbyApp/qml/MpvSpikeHarness.qml create mode 100644 src/qEmbyApp/qml/MpvSpikePage.qml create mode 100644 src/qEmbyApp/qml/PlayerPage.qml create mode 100644 src/qEmbyApp/qml/SettingsPage.qml create mode 100644 src/qEmbyApp/qml/SideBar.qml create mode 100644 src/qEmbyApp/qml/TitleBar.qml create mode 100644 src/qEmbyApp/qml/components/ConfirmDialog.qml delete mode 100644 src/qEmbyApp/qml/components/ModernDialog.qml create mode 100644 src/qEmbyApp/qml/components/PageHeader.qml create mode 100644 src/qEmbyApp/qml/components/SearchHistoryPopup.qml create mode 100644 src/qEmbyApp/qml/components/SegmentedTabs.qml delete mode 100644 src/qEmbyApp/qml/theme/Theme.qml create mode 100644 src/qEmbyApp/quick/appshellhost.cpp create mode 100644 src/qEmbyApp/quick/appshellhost.h rename src/qEmbyApp/{components/nativedanmakuoverlay.cpp => quick/danmakupainteditem.cpp} (81%) rename src/qEmbyApp/{components/nativedanmakuoverlay.h => quick/danmakupainteditem.h} (71%) create mode 100644 src/qEmbyApp/quick/independentplayerhost.cpp create mode 100644 src/qEmbyApp/quick/independentplayerhost.h create mode 100644 src/qEmbyApp/quick/manageroutecontroller.cpp create mode 100644 src/qEmbyApp/quick/manageroutecontroller.h create mode 100644 src/qEmbyApp/quick/mediaroutecontroller.cpp create mode 100644 src/qEmbyApp/quick/mediaroutecontroller.h create mode 100644 src/qEmbyApp/quick/mpvitem.cpp create mode 100644 src/qEmbyApp/quick/mpvitem.h create mode 100644 src/qEmbyApp/quick/playerroutecontroller.cpp create mode 100644 src/qEmbyApp/quick/playerroutecontroller.h create mode 100644 src/qEmbyApp/quick/playerviewmodel.cpp create mode 100644 src/qEmbyApp/quick/playerviewmodel.h delete mode 100644 src/qEmbyApp/quick/quickpagehost.cpp delete mode 100644 src/qEmbyApp/quick/quickpagehost.h delete mode 100644 src/qEmbyApp/quick/quickrouterhost.cpp delete mode 100644 src/qEmbyApp/quick/quickrouterhost.h create mode 100644 src/qEmbyApp/quick/searchhistoryviewmodel.cpp create mode 100644 src/qEmbyApp/quick/searchhistoryviewmodel.h create mode 100644 src/qEmbyApp/quick/shelllifecyclecontroller.cpp create mode 100644 src/qEmbyApp/quick/shelllifecyclecontroller.h create mode 100644 src/qEmbyApp/quick/sidebarviewmodel.cpp create mode 100644 src/qEmbyApp/quick/sidebarviewmodel.h delete mode 100644 src/qEmbyApp/views/admin/activitylogdelegate.cpp delete mode 100644 src/qEmbyApp/views/admin/activitylogdelegate.h delete mode 100644 src/qEmbyApp/views/admin/librarycard.cpp delete mode 100644 src/qEmbyApp/views/admin/librarycard.h delete mode 100644 src/qEmbyApp/views/admin/librarygrid.cpp delete mode 100644 src/qEmbyApp/views/admin/librarygrid.h delete mode 100644 src/qEmbyApp/views/admin/managepagebase.cpp delete mode 100644 src/qEmbyApp/views/admin/managepagebase.h delete mode 100644 src/qEmbyApp/views/admin/manageview.cpp delete mode 100644 src/qEmbyApp/views/admin/manageview.h delete mode 100644 src/qEmbyApp/views/admin/nowplayingcard.cpp delete mode 100644 src/qEmbyApp/views/admin/nowplayingcard.h delete mode 100644 src/qEmbyApp/views/admin/pagecollections.cpp delete mode 100644 src/qEmbyApp/views/admin/pagecollections.h delete mode 100644 src/qEmbyApp/views/admin/pagedashboard.cpp delete mode 100644 src/qEmbyApp/views/admin/pagedashboard.h delete mode 100644 src/qEmbyApp/views/admin/pagelibraries.cpp delete mode 100644 src/qEmbyApp/views/admin/pagelibraries.h delete mode 100644 src/qEmbyApp/views/admin/pagelogs.cpp delete mode 100644 src/qEmbyApp/views/admin/pagelogs.h delete mode 100644 src/qEmbyApp/views/admin/pagetasks.cpp delete mode 100644 src/qEmbyApp/views/admin/pagetasks.h delete mode 100644 src/qEmbyApp/views/admin/pagetranscoding.cpp delete mode 100644 src/qEmbyApp/views/admin/pagetranscoding.h delete mode 100644 src/qEmbyApp/views/admin/pageusers.cpp delete mode 100644 src/qEmbyApp/views/admin/pageusers.h delete mode 100644 src/qEmbyApp/views/media/detailview.cpp delete mode 100644 src/qEmbyApp/views/media/detailview.h delete mode 100644 src/qEmbyApp/views/media/libraryview.cpp delete mode 100644 src/qEmbyApp/views/media/libraryview.h delete mode 100644 src/qEmbyApp/views/media/mediacarddelegate.cpp delete mode 100644 src/qEmbyApp/views/media/mediacarddelegate.h delete mode 100644 src/qEmbyApp/views/media/mediaitem.h delete mode 100644 src/qEmbyApp/views/media/medialistmodel.cpp delete mode 100644 src/qEmbyApp/views/media/medialistmodel.h delete mode 100644 src/qEmbyApp/views/media/overviewdialog.cpp delete mode 100644 src/qEmbyApp/views/media/overviewdialog.h delete mode 100644 src/qEmbyApp/views/media/playerview.cpp delete mode 100644 src/qEmbyApp/views/media/playerview.h delete mode 100644 src/qEmbyApp/views/media/seasonview.cpp delete mode 100644 src/qEmbyApp/views/media/seasonview.h delete mode 100644 src/qEmbyApp/views/public/loginview.cpp delete mode 100644 src/qEmbyApp/views/public/loginview.h delete mode 100644 src/qEmbyApp/views/search/searchview.cpp delete mode 100644 src/qEmbyApp/views/search/searchview.h delete mode 100644 src/qEmbyApp/views/settings/pageabout.cpp delete mode 100644 src/qEmbyApp/views/settings/pageabout.h delete mode 100644 src/qEmbyApp/views/settings/pageappearance.cpp delete mode 100644 src/qEmbyApp/views/settings/pageappearance.h delete mode 100644 src/qEmbyApp/views/settings/pagegeneral.cpp delete mode 100644 src/qEmbyApp/views/settings/pagegeneral.h delete mode 100644 src/qEmbyApp/views/settings/pagelibrary.cpp delete mode 100644 src/qEmbyApp/views/settings/pagelibrary.h delete mode 100644 src/qEmbyApp/views/settings/pageplayer.cpp delete mode 100644 src/qEmbyApp/views/settings/pageplayer.h delete mode 100644 src/qEmbyApp/views/settings/settingspagebase.cpp delete mode 100644 src/qEmbyApp/views/settings/settingspagebase.h delete mode 100644 src/qEmbyApp/views/settings/settingsview.cpp delete mode 100644 src/qEmbyApp/views/settings/settingsview.h delete mode 100644 src/qEmbyApp/views/user/categoryview.cpp delete mode 100644 src/qEmbyApp/views/user/categoryview.h delete mode 100644 src/qEmbyApp/views/user/dashboardview.cpp delete mode 100644 src/qEmbyApp/views/user/dashboardview.h delete mode 100644 src/qEmbyApp/views/user/favoritesview.cpp delete mode 100644 src/qEmbyApp/views/user/favoritesview.h delete mode 100644 src/qEmbyApp/views/user/homequickrouterview.cpp delete mode 100644 src/qEmbyApp/views/user/homequickrouterview.h delete mode 100644 src/qEmbyApp/views/user/homeview.cpp delete mode 100644 src/qEmbyApp/views/user/homeview.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 53c26db..0b68a5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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) diff --git a/ai/qml-migration-status.md b/ai/qml-migration-status.md index 3c493e3..a7897d5 100644 --- a/ai/qml-migration-status.md +++ b/ai/qml-migration-status.md @@ -1,104 +1,73 @@ # qEmby QML 迁移状态 -更新时间:2026-07-15 +更新时间:2026-07-27(设置缺口补全) -## 结论与口径 +## 结论 -当前架构是 **Widgets 应用壳 + `QQuickWidget` 局部页面**,还不是纯 QML 应用:`MainWindow`、登录/主页导航、侧栏、标题栏和页面栈仍由 Widgets 负责;`QuickPageHost` 把 QML 页面嵌入 `QStackedWidget`/`SlidingStackedWidget`。迁移完成的判定必须同时满足“QML 页面存在、由实际导航入口加载、核心交互可用”,不能只以 `.qml` 文件存在为准。 +**唯一外壳 = 全 QML**(`AppShellHost` + `AppShell.qml`)。 +Widgets 页面壳与播放器栈已删除。 +**独立播放窗 = 第二个 QML `ApplicationWindow`**(`IndependentPlayerHost` + `IndependentPlayerWindow.qml`)。 -代码依据: +保留 `QApplication`:托盘、文件对话框、业务 Widget 对话框(弹幕设置/识别、字幕设置、库/用户管理等)仍需要。 -- `src/qEmbyApp/CMakeLists.txt` 的 `qt_add_qml_module(qEmbyApp ...)` 是当前 QML 模块清单。 -- `src/qEmbyApp/quick/quickpagehost.cpp` 使用 `QQuickWidget`,并处理主题底色、context property、image provider 与加载错误。 -- `src/qEmbyApp/views/settings/settingsview.cpp` 实际加载 5 个设置 QML 页面。 -- `src/qEmbyApp/views/admin/manageview.cpp` 保留 Widgets 管理壳,但仪表盘和六个管理页的页面主体均已由各自的 `QuickPageHost` 加载 QML。 -- `src/qEmbyApp/views/user/homeview.cpp` 仍负责主路由,并创建 Dashboard/Favorites/Category/Library/Detail/Season/Search/Player/Settings/Manage 等 QWidget 页面。 -- `src/qEmbyApp/quick/navigationcontroller.*` 已实现规范 route、参数/权限校验、会话清理和 UI state;`HomeView` 已旁路接入并与旧 `m_navStack` 并行运行。 +## 播放路径 -状态口径:`done` = 已接入实际入口;`in-progress` = 已分配并正在迁移;`ready` = 前置基本具备,可领取;`blocked` = 应先完成列出的依赖;`legacy` = 迁移期间刻意保留的原生边界。 +| 模式 | 实现 | +|---|---| +| 壳内嵌入 | `PlayerRouteController` → 路由 `player` → `PlayerPage` | +| 独立窗口 | `IndependentPlayerHost` → 二级引擎 → `IndependentPlayerWindow` | +| 外部播放器 | `PlaybackManager` + `ExternalPlayerIPC` | -## 已迁移并接入 +## 已删除(播放器栈 + 无引用 Widgets) -| 子系统 | 页面 | QML / 数据层 | owner | status | 验收命令 | -|---|---|---|---|---|---| -| 设置 | 通用 | `SettingsGeneralPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` | -| 设置 | 外观 | `SettingsAppearancePage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` | -| 设置 | 媒体库 | `SettingsLibraryPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` | -| 设置 | 播放器 | `SettingsPlayerPage.qml` / `SettingsViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` | -| 设置 | 关于 | `SettingsAboutPage.qml` / `AboutViewModel` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2` | -| 管理 | 仪表盘 | `ManageDashboardPage.qml` / `DashboardViewModel`、`EmbyImageProvider` | core | done | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;登录管理员账号后检查 2 秒刷新、重启/关机确认框 | +**播放器:** `PlayerWindow` `PlayerView` `NativeDanmakuOverlay` `MpvWidget` 及 OSD/切集/统计/长按/seek bubble 等。 -说明:设置与管理的左侧导航容器仍是 Widgets;About 的许可证窗口以及管理仪表盘的确认框/Toast 仍通过 Widgets 打开,因此这里的 `done` 指页面主体,不代表其父壳和所有弹窗都已迁移。 +**孤儿 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 -以下 owner 是任务归属,不是 C++ 类所有者。`login-agent` 与 `search-agent` 已按本轮协作安排标为进行中。 +| 角色 | 代表 | +|---|---| +| 播放对话框 | `PlayerDanmaku*Dialog` `PlayerSubtitleSettingsDialog` `PlayerOverlayDialog` | +| 管理/库 | `LibraryCreate/EditDlg` `UserEditDialog` `TaskEditDialog` `*Codec*Dialog` | +| 媒体操作 | `MediaActionMenu` `MediaIdentify/ImageEditDialog` `AddToPlaylistDialog` | +| 通用 | `ModernDialogBase` `ModernToast` `ModernMessageBox` `ProxySettingsDialog` `WebDav*` | +| 播放核心 | `MpvController` `PlayerDanmakuController` | -| 阶段 | 子系统 | 页面/范围(现有 Widgets 事实) | owner | status | 依赖 | 验收命令 | -|---|---|---|---|---|---|---| -| 1 | 公共入口 | 登录 `views/public/LoginView` | login-agent | done | 主题输入框、按钮、忙碌/错误状态;认证 ViewModel | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;冷启动后完成服务器选择、登录成功/失败各一次 | -| 1 | 搜索 | 搜索结果 `views/search/SearchView` | search-agent | done | 媒体列表模型、MediaCard、分页/空态 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;提交关键词并验证结果、空结果、进入详情、返回 | -| 1 | 公共基础 | QML 弹窗/Toast/确认框、菜单、空态/错误态 | feedback-agent | done | `Theme`、现有 `ModernDialog.qml` | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;键盘 Esc/Enter、焦点回收、明暗主题各冒烟一次 | -| 1 | 公共基础 | MediaCard + 可复用 List/Grid + 图片加载/占位/错误 | media-primitives-agent | done | `EmbyImageProvider` 泛化、QAbstractListModel 角色契约 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;100+ 项滚动、图片失败、键鼠激活检查 | -| 2 | 用户首页 | 首页 `views/user/DashboardView` | dashboard-agent | done | MediaCard/List、首页 section 模型、请求取消 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;继续观看/最新/推荐/媒体库分区加载与跳转 | -| 2 | 用户首页 | 收藏 `views/user/FavoritesView` | favorites-agent | done | MediaCard/List、收藏模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类、详情、文件夹、人物跳转及返回 | -| 2 | 用户首页 | 分类 `views/user/CategoryView` | category-agent | done | MediaCard/List、筛选/分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;分类切换、分页、空态、返回状态保持 | -| 2 | 媒体浏览 | 媒体库/人物/过滤结果(共用 `views/media/LibraryView`) | library-agent | done | MediaCard/Grid、排序筛选菜单、分页模型 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;海报/平铺视图、排序筛选、滚动续载、人物页 | -| 2 | 应用壳 | `MainWindow` 标题栏、全局搜索、HomeView 侧栏与路由栈 | core | in-progress | `NavigationController`、单引擎 `Router.qml`、Home/Favorites/Search/Category/Library/Person/Filtered 已接入;待 Detail/Season、侧栏与标题导航 | `cmake --build cmake-build-debug --target qEmbyNavigationTest qEmbyApp_qmllint qEmbyApp -j2 && ./cmake-build-debug/bin/qEmbyNavigationTest`;登录→首页→分类→媒体库→人物/过滤→搜索→返回→详情→返回→退出全链路,检查窗口按钮与托盘 | -| 3 | 媒体详情 | 详情 `views/media/DetailView` | detail-agent | done | MediaCard、Flow/Tag、动作菜单、编辑/识别弹窗桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;电影/剧集/人物各一项,播放、收藏、版本选择、编辑入口 | -| 3 | 媒体详情 | 季 `views/media/SeasonView` | season-agent | done | Episode 列表模型、MediaCard、播放动作 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;切季、剧集续播/播放、已播状态、返回 | -| 3 | 管理 | 媒体库 `PageLibraries` | admin-libraries-agent | done | 管理卡片/Grid、拖拽排序、Library CRUD 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增/编辑/删除/扫描/取消扫描/排序 | -| 3 | 管理 | 合集/播放列表 `PageCollections` | admin-collections-agent | done | 管理卡片/Grid、拖拽排序、文本/确认弹窗 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;创建/重命名/删除/批量删除/排序 | -| 3 | 管理 | 转码 `PageTranscoding` | admin-transcoding-agent | done | 表单组件、折叠区、编解码器模型与复杂校验 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;加载、修改、保存、校验失败、编解码器参数弹窗 | -| 3 | 管理 | 用户 `PageUsers` | admin-users-agent | done | 用户卡片模型、Add/Edit User 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;新增、编辑、启停、删除及权限错误 | -| 3 | 管理 | 任务 `PageTasks` | admin-tasks-agent | done | 任务列表模型、TaskEdit 对话框 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;启动、停止、编辑任务及轮询刷新 | -| 3 | 管理 | 日志 `PageLogs` | admin-logs-agent | done | 日志树/列表、文件保存桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;日志列表、内容加载、失败态、另存为 | -| 4 | 播放 | 播放页面/OSD `views/media/PlayerView` 与 `components/player*` | unassigned | blocked | QML 视频承载方案、PlaybackManager 契约、所有 OSD/字幕/弹幕组件 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;libmpv 播放/暂停/seek/音量/字幕/弹幕/全屏/退出,重复 10 次无崩溃 | -| 4 | 对话框 | 媒体编辑/识别/图片、播放列表、下载、WebDAV、代理、许可证等 `components/*dialog*` | unassigned | blocked | QML Overlay/Dialog 框架;文件选择器与原生能力桥接 | `cmake --build cmake-build-debug --target qEmbyApp_qmllint qEmbyApp -j2`;按对话框建立交互矩阵,覆盖接受/取消/错误/窗口关闭 | -| 4 | 平台集成 | 系统托盘、无边框窗口、文件选择器、外部播放器/IPC | platform | legacy | 纯 QML 壳稳定后再决定是否包装;不要求强行 QML 重写 | `cmake --build cmake-build-debug --target qEmbyApp -j2`;Linux/Windows/macOS 各执行关闭到托盘、恢复、退出和窗口按钮冒烟 | +## 启动 -## 建议阶段与依赖顺序 +```bash +cmake --build cmake-build-debug --target qEmbyApp -j$(nproc) +./cmake-build-debug/bin/qEmbyApp +``` -1. **固定 QML 契约**:先完成登录、搜索,以及弹窗/Toast、MediaCard/List/Grid;统一 ViewModel 的 loading/error/empty/cancelled 生命周期。没有这些基础件,后续页面会复制临时控件和异步处理。 -2. **迁移高复用浏览链路**:Dashboard、Favorites、Category、Library/Person/Filtered。它们共享卡片、图片、分页和导航,是验证模型契约与性能的最佳批次。 -3. **替换应用壳**:浏览链路稳定后,再把 MainWindow/HomeView 的标题栏、侧栏、路由栈换成 QML;期间 `QuickPageHost` 继续充当兼容层。壳迁移必须保留托盘、窗口按钮、主题切换和返回栈行为。 -4. **迁移复杂业务页**:详情/季与六个剩余管理页。先提取 C++ ViewModel/`QAbstractListModel`,QML 不直接持有 service 协程或业务缓存。 -5. **最后处理播放与长尾弹窗**:播放器涉及 libmpv/OpenGL/原生窗口和输入焦点,是最高风险项。平台集成可以保留 C++,目标应是“QML 表现层 + C++ 原生能力”,而不是消灭所有 QWidget/C++。 +## 本轮设置补全 -当前开发批次(2026-07-15):导航 Phase 0/1 已落地,Phase 2 首批完成。新增强类型 `NavigationRoute`、`NavigationController` 和轻量契约测试;Widgets 页面统一先转换为规范 route,再进入旧页面栈。`QuickRouterHost` 以单个 QML engine 承载 `Router.qml`,Home/Favorites 共享该 host 与图片 provider,并由 `HomeQuickRouterView` 协调 ViewModel 生命周期和 BaseView 媒体动作。其他 route 暂时保持旧 Widgets host,切入时 Router 保留底层页面且暂停生命周期,避免过渡闪屏。可用 `QEMBY_USE_LEGACY_HOME_PAGES=1` 临时回退旧 Home/Favorites 双 host。 +| 功能 | 位置 | +|---|---| +| 主页区块自定义排序(↑↓ + 重置) | `SettingsLibraryPage` + `SettingsViewModel::{customHomeSectionOrderEnabled,homeSectionOrder,moveHomeSection}` | +| 弹幕 API 服务器管理(选/启停/增删改) | `SettingsPlayerPage` + `DanmakuSettings` 桥接 | +| 缺失弹幕属性 | `danmakuSourceMode` / `AutoLoad` / `AutoMatch` / `WithRelated` / `CacheHours` | +| 库开关按服务器作用域 | `showContinueWatching` 等写入 `server//library/...`(与 Dashboard 一致,兼容读全局旧值) | -Phase 2 第二批(2026-07-15):Search 已加入根 Router。`HomeView::triggerSearch()` 现在生成规范 `search` route;同一 Quick host 内的 Home/Favorites/Search 切换只更新 Controller/Loader,进入 legacy 详情页时仍保存兼容历史。`SearchViewModel` 新增 active/generation 生命周期,隐藏或离开搜索时淘汰在途结果,返回时仅按需恢复;旧 `SearchView` 默认 active 行为保持兼容。 +## 本轮优化(2026-07-27) -Phase 2 第三批(2026-07-15):Category、Library、Person、Filtered 已加入根 Router。`HomeView` 的首页媒体库、侧栏媒体库、文件夹、人物和过滤入口统一生成规范 route;Favorite Category 在 key 中具有独立身份。Category/Library ViewModel 在 inactive 时只保存最新参数,并用 generation 淘汰详情探测、首屏与 loadMore 的过期回写;旧包装器继续保持默认 active 兼容。当前 Category 和 Library 系列各使用一个会话级 ViewModel,返回已完成 route 时可复用数据,切换参数时重建当前状态;尚未实现每个历史 route 独立的 LRU ViewModel 与滚动位置。下一批优先接 Detail/Season,再统一 `uiState.scrollY`。 +| 项 | 说明 | +|---|---| +| 设置缓存清理 | `clearRecommendCache` / `clearImageCache` 已接线 + toast | +| 代理设置 | 设置-通用「配置…」打开 `ProxySettingsDialog` | +| 列表性能 | MediaCard DPR sourceSize、滑动关 smooth/mipmap;Grid/Section 减小 cacheBuffer | +| 首页 | 区块 Loader 异步;reduceMotion 跳过入场动画 | +| 路由 | reduceMotion / 播放器页瞬时切页,少缩放动画 | -阶段门禁:每阶段至少通过 QML 静态检查、Debug 构建、明暗主题、窗口缩放、键盘焦点、页面反复进入退出、网络失败/空数据检查;依赖真实服务器的行为必须用 Emby/Jellyfin 各冒烟一次。 +## 后续可选 -## 公共组件与基础设施缺口 - -- **导航**:声明式 route、参数、返回栈、页面缓存/销毁策略;当前契约散落在 `HomeView::create*View()` 与动态 `routeType` 属性中。 -- **异步状态**:统一 `loading/error/empty/refreshing`、请求去重、页面销毁后的取消/弱引用;当前 Dashboard VM 是可参考样例,但尚无通用基类。 -- **媒体模型**:统一 `QAbstractListModel` roles、分页、排序、筛选、收藏/已播状态增量更新,避免 QML 读取复杂 Core DTO。 -- **视觉组件**:MediaCard、Section、IconButton、Menu/ContextMenu、Dialog/MessageBox、Toast、Busy/Skeleton、Error/Empty、Tabs、Tag/Chip、拖拽排序、文件/目录选择入口。 -- **图片**:现有 `EmbyImageProvider`/`AdaptiveIconProvider` 只在管理仪表盘 host 中显式注册;全局 QML 引擎需要统一注册、缓存策略、取消和失败占位。 -- **可访问性与输入**:焦点环、Tab 顺序、快捷键、右键菜单、触屏/高 DPI、屏幕阅读器语义。 -- **测试**:仓库当前没有 qEmbyApp 页面级自动测试;应增加 Qt Quick Test(组件/VM)与最小导航冒烟,避免只靠截图判断。 -- **国际化**:新 QML 文案需进入现有 `qEmby_zh_CN.ts`/`qEmby_fr_FR.ts` 流程,并核验运行时语言切换/重启语义。 - -## 高风险边界 - -1. **QQuickWidget 只是过渡层**:它使用离屏 FBO;仓库已经为透明合成黑底加了不透明 clear color。嵌入页越多,渲染、焦点、弹窗层级和显存成本越高,应避免把每个小组件都拆成独立 `QQuickWidget`。 -2. **播放器**:`PlayerView`、`MpvWidget`、OpenGL 与大量 `player*` 控件耦合,且有全屏、长按、字幕、弹幕、媒体源切换。先做单一播放器原型和压力冒烟,再决定 `QQuickFramebufferObject`/原生纹理/保留 QWidget 视频面方案。 -3. **生命周期与协程**:页面快速返回、切服务器或退出时,QCoro 任务、WebSocket 与定时刷新不能回调已销毁的 QML 对象。管理仪表盘已有 active/hidden 控制,但尚未形成全局规则。 -4. **业务逻辑回流 QML**:现有 Widgets 页面体量大(尤其详情、转码、媒体库管理);迁移应先抽取 ViewModel/模型,不能把 API 编排、配置序列化和缓存策略复制进 JavaScript。 -5. **导航状态兼容**:`HomeView` 当前重建历史页面并依赖 routeId/routeTitle/routeExtraId;切换为 QML 路由时必须保留人物、过滤、季、管理、设置等返回参数和滚动状态。 -6. **原生能力**:托盘、无边框窗口、文件保存、外部播放器 IPC 适合继续放在 C++;“全面迁移到 QML”应限定为 UI/交互层迁移。 -7. **主题双栈**:Widgets 仍走 QSS/`ThemeManager`,QML 走 `QuickTheme`/Themed controls。过渡期必须以同一 token 为源,验证实时明暗切换,避免两套颜色和尺寸继续分叉。 - -## 每个迁移任务的完成定义 - -- 实际导航已切到 QML,旧 Widgets 页面不再是默认路径;需要回退时必须有明确 feature flag。 -- C++ 只暴露稳定属性、模型、信号和 invokable;QML 不直接编排网络 service 或持有 Core DTO。 -- `qEmbyApp_qmllint` 与 `qEmbyApp` 构建通过,无新增 QML runtime error/warning。 -- loading/error/empty/disabled、键鼠焦点、明暗主题、窗口缩放和中英文至少完成一次验证。 -- 页面进入/退出及刷新至少重复 10 次;涉及异步或播放器的任务额外验证退出、切服务器和应用关闭。 -- 删除旧实现应单独提交,在 QML 路径稳定后进行,便于回退与差异核对。 +- 播放器内弹幕设置 / 识别 / 字幕对话框 QML 化 +- 主页排序改为拖拽(当前为上下按钮,足够用) diff --git a/cmake/QEmbyPackaging.cmake b/cmake/QEmbyPackaging.cmake index cc3b325..993d389 100644 --- a/cmake/QEmbyPackaging.cmake +++ b/cmake/QEmbyPackaging.cmake @@ -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() diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 220de87..d0f88ee 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: local pkgname=qemby -pkgver=0.0.7 +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" } diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 19cf458..90564fd 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -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 ``` diff --git a/src/qEmbyApp/CMakeLists.txt b/src/qEmbyApp/CMakeLists.txt index e295e78..a1b050e 100644 --- a/src/qEmbyApp/CMakeLists.txt +++ b/src/qEmbyApp/CMakeLists.txt @@ -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) @@ -35,7 +35,12 @@ qt_add_qml_module(qEmbyApp 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 @@ -45,6 +50,8 @@ qt_add_qml_module(qEmbyApp qml/LibraryPage.qml qml/DetailPage.qml qml/SeasonPage.qml + qml/PlayerPage.qml + qml/IndependentPlayerWindow.qml qml/AdminUsersPage.qml qml/AdminTasksPage.qml qml/AdminLibrariesPage.qml @@ -52,12 +59,15 @@ qt_add_qml_module(qEmbyApp 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 @@ -67,6 +77,7 @@ qt_add_qml_module(qEmbyApp 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 @@ -74,11 +85,19 @@ qt_add_qml_module(qEmbyApp 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 生成的注册代码以 形式包含头文件 @@ -148,6 +167,7 @@ target_link_libraries(qEmbyApp PRIVATE Qt${QT_VERSION_MAJOR}::QuickControls2 qEmbyCore QWKWidgets + QWKQuick WidgetFrame QCoro6::Core QCoro6::Network diff --git a/src/qEmbyApp/components/adminusercard.cpp b/src/qEmbyApp/components/adminusercard.cpp deleted file mode 100644 index a8d2645..0000000 --- a/src/qEmbyApp/components/adminusercard.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#include "adminusercard.h" -#include "../utils/layoututils.h" -#include "elidedlabel.h" -#include "modernswitch.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -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); -} diff --git a/src/qEmbyApp/components/adminusercard.h b/src/qEmbyApp/components/adminusercard.h deleted file mode 100644 index aedfe1f..0000000 --- a/src/qEmbyApp/components/adminusercard.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifndef ADMINUSERCARD_H -#define ADMINUSERCARD_H - -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/collectioncard.cpp b/src/qEmbyApp/components/collectioncard.cpp deleted file mode 100644 index 930e257..0000000 --- a/src/qEmbyApp/components/collectioncard.cpp +++ /dev/null @@ -1,342 +0,0 @@ -#include "collectioncard.h" -#include "collectiongrid.h" -#include "../managers/thememanager.h" -#include "../tokens/ColorTokens.h" -#include "../utils/hoveranimator.h" - -#include -#include -#include -#include - -namespace { -QColor lerpColor(const QColor& from, const QColor& to, qreal t) -{ - return QColor(qRound(from.red() + (to.red() - from.red()) * t), - qRound(from.green() + (to.green() - from.green()) * t), - qRound(from.blue() + (to.blue() - from.blue()) * t), - qRound(from.alpha() + (to.alpha() - from.alpha()) * t)); -} -} // namespace - - - - -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")); - - m_hover = new HoverAnimator(this); - m_hover->setCallback([this](qreal) { update(); }); - - 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(); - - - using namespace ColorTokens; - QColor cardBg = isDark ? withAlpha(Slate::k700, 140) : withAlpha(Qt::white, 245); - QColor border = isDark ? withAlpha(Slate::k600, 100) : Slate::k200; - QColor nameFg = isDark ? Slate::k100 : Slate::k900; - QColor typeFg = isDark ? Slate::k400 : Slate::k500; - const QColor accent = Blue::k500; - - const qreal hp = m_hover->progress(); - if (hp > 0.0 && !m_selected && !m_dragging) { - const QColor hoverBorder = withAlpha(isDark ? Blue::k400 : Blue::k500, 190); - const QColor hoverBg = isDark ? withAlpha(Slate::k700, 190) : QColor(Qt::white); - border = lerpColor(border, hoverBorder, hp); - cardBg = lerpColor(cardBg, hoverBg, hp); - } - - if (m_selected) { - cardBg = isDark ? withAlpha(Slate::k800, 220) : withAlpha(Blue::k50, 245); - border = withAlpha(isDark ? Blue::k400 : Blue::k500, 220); - } - - if (m_dragging) { - cardBg = isDark ? withAlpha(Slate::k900, 235) : withAlpha(Slate::k50, 252); - border = isDark ? withAlpha(Blue::k500, 235) : withAlpha(Blue::k600, 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 ? withAlpha(Slate::k500, 160) - : withAlpha(Slate::k400, 180)), - 1.5)); - p.setBrush(m_selected ? accent - : (isDark ? withAlpha(Slate::k900, 180) - : withAlpha(Qt::white, 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 ? Slate::k800 : Slate::k100); - grad.setColorAt(1, isDark ? Slate::k700 : Slate::k200); - - 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(withAlpha(isDark ? Slate::k400 : Slate::k500, 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(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(parentWidget())) { - grid->onCardDragStart(this, globalPos); - } - } - } - - if (m_dragging) { - if (auto* grid = qobject_cast(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(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::enterEvent(QEnterEvent* event) -{ - QFrame::enterEvent(event); - m_hover->setHovered(true); -} - -void CollectionCard::leaveEvent(QEvent* event) -{ - QFrame::leaveEvent(event); - m_hover->setHovered(false); -} - -void CollectionCard::toggleSelected() -{ - m_selected = !m_selected; - update(); - emit selectionChanged(m_itemId, m_selected); -} diff --git a/src/qEmbyApp/components/collectioncard.h b/src/qEmbyApp/components/collectioncard.h deleted file mode 100644 index 0bc0098..0000000 --- a/src/qEmbyApp/components/collectioncard.h +++ /dev/null @@ -1,71 +0,0 @@ -#ifndef COLLECTIONCARD_H -#define COLLECTIONCARD_H - -#include -#include -#include -#include - -class CollectionGrid; -class HoverAnimator; -class QEnterEvent; -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; - void enterEvent(QEnterEvent* event) override; - void leaveEvent(QEvent* 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; - HoverAnimator* m_hover = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/components/collectiongrid.cpp b/src/qEmbyApp/components/collectiongrid.cpp deleted file mode 100644 index 8c8d68e..0000000 --- a/src/qEmbyApp/components/collectiongrid.cpp +++ /dev/null @@ -1,341 +0,0 @@ -#include "collectiongrid.h" -#include "collectioncard.h" - -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" - -namespace { - -void stopPosAnimations(CollectionCard* card) -{ - const auto children = card->children(); - for (auto* child : children) { - if (auto* anim = qobject_cast(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& cards) -{ - m_dragCard = nullptr; - m_dragOriginalIndex = -1; - m_dragCurrentIndex = -1; - if (m_autoScrollTimer) { - m_autoScrollTimer->stop(); - } - - QSet 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(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 && !MotionPrefs::reduceMotion() && card->pos() != target) { - stopPosAnimations(card); - - auto* anim = new QPropertyAnimation(card, "pos", card); - anim->setDuration(AnimationTokens::DurationMs::kNormal); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - 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); - if (MotionPrefs::reduceMotion()) { - card->move(target); - } else { - auto* anim = new QPropertyAnimation(card, "pos", card); - anim->setDuration(AnimationTokens::DurationMs::kFast); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - anim->setEndValue(target); - anim->start(QAbstractAnimation::DeleteWhenStopped); - } - - if (changed && dragCard) { - QStringList newOrderIds; - for (auto* currentCard : m_cards) { - newOrderIds.append(currentCard->itemId()); - } - emit orderChanged(newOrderIds); - } -} diff --git a/src/qEmbyApp/components/collectiongrid.h b/src/qEmbyApp/components/collectiongrid.h deleted file mode 100644 index 295148e..0000000 --- a/src/qEmbyApp/components/collectiongrid.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifndef COLLECTIONGRID_H -#define COLLECTIONGRID_H - -#include -#include -#include -#include - -class CollectionCard; -class QResizeEvent; -class QScrollArea; -class QTimer; - - - - -class CollectionGrid : public QWidget { - Q_OBJECT -public: - explicit CollectionGrid(QWidget* parent = nullptr); - - void setCards(const QList& cards); - QList 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 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 diff --git a/src/qEmbyApp/components/danmakuserverdialog.cpp b/src/qEmbyApp/components/danmakuserverdialog.cpp deleted file mode 100644 index d0ab499..0000000 --- a/src/qEmbyApp/components/danmakuserverdialog.cpp +++ /dev/null @@ -1,887 +0,0 @@ -#include "danmakuserverdialog.h" - -#include "danmakuserverlistitemwidget.h" -#include "modernmessagebox.h" -#include "services/danmaku/danmakusettings.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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 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 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( - 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( - 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( - 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( - 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; -} diff --git a/src/qEmbyApp/components/danmakuserverdialog.h b/src/qEmbyApp/components/danmakuserverdialog.h deleted file mode 100644 index 09676b9..0000000 --- a/src/qEmbyApp/components/danmakuserverdialog.h +++ /dev/null @@ -1,76 +0,0 @@ -#ifndef DANMAKUSERVERDIALOG_H -#define DANMAKUSERVERDIALOG_H - -#include "moderndialogbase.h" - -#include "models/danmaku/danmakumodels.h" - -#include -#include - -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 servers, - QString selectedServerId = QString()); - QList 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 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 diff --git a/src/qEmbyApp/components/danmakuserverlistitemwidget.cpp b/src/qEmbyApp/components/danmakuserverlistitemwidget.cpp deleted file mode 100644 index d51ed69..0000000 --- a/src/qEmbyApp/components/danmakuserverlistitemwidget.cpp +++ /dev/null @@ -1,230 +0,0 @@ -#include "danmakuserverlistitemwidget.h" - -#include "../managers/thememanager.h" -#include "modernswitch.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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(); -} diff --git a/src/qEmbyApp/components/danmakuserverlistitemwidget.h b/src/qEmbyApp/components/danmakuserverlistitemwidget.h deleted file mode 100644 index d46ce41..0000000 --- a/src/qEmbyApp/components/danmakuserverlistitemwidget.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef DANMAKUSERVERLISTITEMWIDGET_H -#define DANMAKUSERVERLISTITEMWIDGET_H - -#include - -#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 diff --git a/src/qEmbyApp/components/dashboardsectionorderstrip.cpp b/src/qEmbyApp/components/dashboardsectionorderstrip.cpp deleted file mode 100644 index f242388..0000000 --- a/src/qEmbyApp/components/dashboardsectionorderstrip.cpp +++ /dev/null @@ -1,562 +0,0 @@ -#include "dashboardsectionorderstrip.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" - -namespace { -constexpr int StripHeight = 36; -constexpr int ChipHeight = 28; -constexpr int ChipSpacing = 8; -constexpr int ChipHorizontalPadding = 12; -constexpr int DragPreviewYOffset = 5; -constexpr int ChipAnimationDuration = AnimationTokens::DurationMs::kNormal; -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& 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(watched); - if (!chip) { - return QFrame::eventFilter(watched, event); - } - - switch (event->type()) { - case QEvent::MouseButtonPress: { - auto* mouseEvent = static_cast(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(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(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("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 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 || MotionPrefs::reduceMotion() || widget->pos() == targetPos) { - widget->move(targetPos); - return; - } - - auto* animation = new QPropertyAnimation(widget, "pos", widget); - animation->setDuration(duration); - animation->setEasingCurve(AnimationTokens::Curve::enter()); - 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(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(); -} diff --git a/src/qEmbyApp/components/dashboardsectionorderstrip.h b/src/qEmbyApp/components/dashboardsectionorderstrip.h deleted file mode 100644 index 5b05acb..0000000 --- a/src/qEmbyApp/components/dashboardsectionorderstrip.h +++ /dev/null @@ -1,78 +0,0 @@ -#ifndef DASHBOARDSECTIONORDERSTRIP_H -#define DASHBOARDSECTIONORDERSTRIP_H - -#include -#include -#include -#include -#include - -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& 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 m_chipItems; - QStringList m_order; - QHash m_titles; - QPointer m_pressedChip; - QPointer m_draggedChip; - QPoint m_pressGlobalPos; - QPoint m_pressChipOffset; - int m_dragSourceIndex = -1; - int m_indicatorIndex = -1; - bool m_dragging = false; -}; - -#endif diff --git a/src/qEmbyApp/components/dashboardsectionorderwidget.cpp b/src/qEmbyApp/components/dashboardsectionorderwidget.cpp deleted file mode 100644 index 6492c1e..0000000 --- a/src/qEmbyApp/components/dashboardsectionorderwidget.cpp +++ /dev/null @@ -1,97 +0,0 @@ -#include "dashboardsectionorderwidget.h" - -#include "dashboardsectionorderstrip.h" -#include "../utils/dashboardsectionorderutils.h" -#include -#include -#include - -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 titles; - const QStringList normalizedOrder = - DashboardSectionOrderUtils::normalizeSectionOrder(order); - - for (const QString& sectionId : normalizedOrder) { - titles.insert(sectionId, titleForSectionId(sectionId)); - } - - m_orderStrip->setSectionOrder(normalizedOrder, titles); -} diff --git a/src/qEmbyApp/components/dashboardsectionorderwidget.h b/src/qEmbyApp/components/dashboardsectionorderwidget.h deleted file mode 100644 index 8ebee8d..0000000 --- a/src/qEmbyApp/components/dashboardsectionorderwidget.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef DASHBOARDSECTIONORDERWIDGET_H -#define DASHBOARDSECTIONORDERWIDGET_H - -#include -#include - -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 diff --git a/src/qEmbyApp/components/detailactionwidget.cpp b/src/qEmbyApp/components/detailactionwidget.cpp deleted file mode 100644 index 7f8f9ae..0000000 --- a/src/qEmbyApp/components/detailactionwidget.cpp +++ /dev/null @@ -1,459 +0,0 @@ -#include "detailactionwidget.h" -#include "../managers/externalplayerdetector.h" -#include "../managers/thememanager.h" -#include "../utils/mediasourcepreferenceutils.h" -#include "../utils/playerpreferenceutils.h" -#include "../utils/popfeedback.h" -#include "modernmenubutton.h" -#include "splitplayerbutton.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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, [this]() { - PopFeedback::play(m_favBtn, [btn = m_favBtn](qreal scale) { - btn->setIconSize(QSize(qRound(20 * scale), qRound(20 * scale))); - }); - emit favoriteRequested(); - }); - connect(m_playedBtn, &QPushButton::clicked, this, [this]() { - PopFeedback::play(m_playedBtn, [btn = m_playedBtn](qreal scale) { - btn->setIconSize(QSize(qRound(20 * scale), qRound(20 * scale))); - }); - emit 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(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(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 &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(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( - ConfigKeys::PlayerAudioLang, "auto"); - QString prefSubLang = - ConfigStore::instance()->get(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(ConfigKeys::ExtPlayerEnable, false); - if (!extEnabled) { - m_extPlayerBtn->hide(); - return; - } - - QString currentPath = - ConfigStore::instance()->get(ConfigKeys::ExtPlayerPath); - - - QList allPlayers = ExternalPlayerDetector::loadFromConfig(); - - - QSet knownPaths; - for (const auto &p : allPlayers) - knownPaths.insert(p.path); - if (!currentPath.isEmpty() && currentPath != "custom" && - !knownPaths.contains(currentPath) && QFileInfo::exists(currentPath)) { - allPlayers.prepend({QFileInfo(currentPath).baseName(), currentPath}); - } - - if (allPlayers.isEmpty()) { - m_extPlayerBtn->hide(); - return; - } - - - QString activePlayerPath = currentPath; - if (activePlayerPath.isEmpty() || activePlayerPath == "custom") { - activePlayerPath = allPlayers.first().path; - } - m_extPlayerBtn->setPlayers(allPlayers, activePlayerPath); -} diff --git a/src/qEmbyApp/components/detailactionwidget.h b/src/qEmbyApp/components/detailactionwidget.h deleted file mode 100644 index c973419..0000000 --- a/src/qEmbyApp/components/detailactionwidget.h +++ /dev/null @@ -1,63 +0,0 @@ -#ifndef DETAILACTIONWIDGET_H -#define DETAILACTIONWIDGET_H - -#include -#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& 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 diff --git a/src/qEmbyApp/components/detailbottominfowidget.cpp b/src/qEmbyApp/components/detailbottominfowidget.cpp deleted file mode 100644 index fb354ce..0000000 --- a/src/qEmbyApp/components/detailbottominfowidget.cpp +++ /dev/null @@ -1,494 +0,0 @@ -#include "detailbottominfowidget.h" -#include "detailtagbutton.h" -#include "flowlayout.h" -#include "horizontalwidgetgallery.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - - -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 &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(); -} diff --git a/src/qEmbyApp/components/detailbottominfowidget.h b/src/qEmbyApp/components/detailbottominfowidget.h deleted file mode 100644 index 284be1e..0000000 --- a/src/qEmbyApp/components/detailbottominfowidget.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifndef DETAILBOTTOMINFOWIDGET_H -#define DETAILBOTTOMINFOWIDGET_H - -#include -#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& 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 diff --git a/src/qEmbyApp/components/detailcontentwidget.cpp b/src/qEmbyApp/components/detailcontentwidget.cpp deleted file mode 100644 index b6e1f1d..0000000 --- a/src/qEmbyApp/components/detailcontentwidget.cpp +++ /dev/null @@ -1,125 +0,0 @@ -#include "detailcontentwidget.h" -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" - -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) { - const bool hadBackdrop = !m_backdrop.isNull(); - m_backdrop = pix; - m_cachedWidth = -1; - - // Fade the hero backdrop in only on first reveal (null -> image), not when a - // network refresh replaces an already-shown backdrop (that would flicker). - if (!pix.isNull() && !hadBackdrop) { - if (MotionPrefs::reduceMotion()) { - m_backdropOpacity = 1.0; - } else { - if (!m_backdropFade) { - m_backdropFade = new QPropertyAnimation(this, "backdropOpacity", this); - m_backdropFade->setDuration(AnimationTokens::DurationMs::kNormal); - m_backdropFade->setEasingCurve(AnimationTokens::Curve::fade()); - } - m_backdropFade->stop(); - m_backdropFade->setStartValue(0.0); - m_backdropFade->setEndValue(1.0); - m_backdropFade->start(); - } - } - - update(); -} - -qreal DetailContentWidget::backdropOpacity() const { - return m_backdropOpacity; -} - -void DetailContentWidget::setBackdropOpacity(qreal opacity) { - m_backdropOpacity = opacity; - 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.setOpacity(m_backdropOpacity); - painter.drawPixmap(0, 0, m_scaledBackdrop); - painter.setOpacity(1.0); - - - 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); -} diff --git a/src/qEmbyApp/components/detailcontentwidget.h b/src/qEmbyApp/components/detailcontentwidget.h deleted file mode 100644 index 3fac663..0000000 --- a/src/qEmbyApp/components/detailcontentwidget.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef DETAILCONTENTWIDGET_H -#define DETAILCONTENTWIDGET_H - -#include -#include -#include - -class QPropertyAnimation; - -class DetailContentWidget : public QWidget { - Q_OBJECT - Q_PROPERTY(QColor baseColor READ baseColor WRITE setBaseColor) - Q_PROPERTY(QColor gradientColor READ gradientColor WRITE setGradientColor) - Q_PROPERTY(qreal backdropOpacity READ backdropOpacity WRITE setBackdropOpacity) - -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); - - qreal backdropOpacity() const; - void setBackdropOpacity(qreal opacity); - -protected: - void paintEvent(QPaintEvent* event) override; - -private: - QPixmap m_backdrop; - QPixmap m_scaledBackdrop; - int m_cachedWidth; - - - QColor m_baseColor; - QColor m_gradientColor; - - qreal m_backdropOpacity = 1.0; - QPropertyAnimation* m_backdropFade = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/components/detailtagbutton.cpp b/src/qEmbyApp/components/detailtagbutton.cpp deleted file mode 100644 index 6e3445b..0000000 --- a/src/qEmbyApp/components/detailtagbutton.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "detailtagbutton.h" - -#include - -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; -} diff --git a/src/qEmbyApp/components/detailtagbutton.h b/src/qEmbyApp/components/detailtagbutton.h deleted file mode 100644 index 906fc97..0000000 --- a/src/qEmbyApp/components/detailtagbutton.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef DETAILTAGBUTTON_H -#define DETAILTAGBUTTON_H - -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/flowlayout.cpp b/src/qEmbyApp/components/flowlayout.cpp deleted file mode 100644 index 2d39544..0000000 --- a/src/qEmbyApp/components/flowlayout.cpp +++ /dev/null @@ -1,225 +0,0 @@ -#include "flowlayout.h" -#include - -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(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(parent); - return pw->style()->pixelMetric(pm, nullptr, pw); - } else { - return static_cast(parent)->spacing(); - } -} - -void FlowLayout::updateParentGeometry() const -{ - auto *parentWidget = qobject_cast(parent()); - if (!parentWidget) { - return; - } - - parentWidget->updateGeometry(); - if (QWidget *outerWidget = parentWidget->parentWidget()) { - outerWidget->updateGeometry(); - } -} diff --git a/src/qEmbyApp/components/flowlayout.h b/src/qEmbyApp/components/flowlayout.h deleted file mode 100644 index 6defaf2..0000000 --- a/src/qEmbyApp/components/flowlayout.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifndef FLOWLAYOUT_H -#define FLOWLAYOUT_H - -#include -#include -#include -#include - -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 itemList; - int m_hSpace; - int m_vSpace; - int m_firstLineTrailingSpacing = 0; -}; - -#endif diff --git a/src/qEmbyApp/components/galleryoverlayfilter.cpp b/src/qEmbyApp/components/galleryoverlayfilter.cpp deleted file mode 100644 index 59cc5dd..0000000 --- a/src/qEmbyApp/components/galleryoverlayfilter.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "galleryoverlayfilter.h" -#include -#include -#include -#include -#include - - -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); -} diff --git a/src/qEmbyApp/components/galleryoverlayfilter.h b/src/qEmbyApp/components/galleryoverlayfilter.h deleted file mode 100644 index 899d356..0000000 --- a/src/qEmbyApp/components/galleryoverlayfilter.h +++ /dev/null @@ -1,19 +0,0 @@ -#include - - - - - -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; -}; diff --git a/src/qEmbyApp/components/horizontallistviewgallery.cpp b/src/qEmbyApp/components/horizontallistviewgallery.cpp deleted file mode 100644 index 38e4e53..0000000 --- a/src/qEmbyApp/components/horizontallistviewgallery.cpp +++ /dev/null @@ -1,560 +0,0 @@ -#include "horizontallistviewgallery.h" -#include "shimmerwidget.h" -#include "../utils/textwraputils.h" -#include "../views/media/medialistmodel.h" -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/fadehelper.h" -#include "../utils/motionprefs.h" - -namespace { -// Fade the nav arrows instead of popping; only reacts to desired-visibility -// changes (this runs on every mouse move). -void setNavButtonVisible(QPushButton* btn, bool visible) -{ - const QVariant last = btn->property("navVisible"); - if (!last.isNull() && last.toBool() == visible) { - return; - } - btn->setProperty("navVisible", visible); - if (visible) { - FadeHelper::fadeIn(btn, AnimationTokens::DurationMs::kQuick); - } else { - FadeHelper::fadeOut(btn, AnimationTokens::DurationMs::kMicro, - AnimationTokens::Curve::exit(), [btn]() { btn->hide(); }); - } -} -} // namespace -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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(AnimationTokens::Curve::enter()); - m_hScrollAnim->setDuration(AnimationTokens::DurationMs::kSlow); - - 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()); - - if (MotionPrefs::reduceMotion()) { - bar->setValue(targetValue); - return; - } - auto* anim = new QPropertyAnimation(bar, "value", this); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - 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& items) -{ - if (m_listModel) { - m_listModel->setItems(items); - QTimer::singleShot(0, this, - [this]() { updateVisibleImagePriority(); }); - } - - if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) { - // Cross-fade the skeleton out to reveal the now-populated list underneath. - FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal, - AnimationTokens::Curve::fade(), [this]() { - if (m_shimmer) { - 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(); - 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(); - // Show + snap opacity back to 1.0 (a prior reveal fade left the effect at 0). - FadeHelper::fadeIn(m_shimmer, 0); - m_shimmer->startAnimation(); - } else if (m_shimmer->isVisible()) { - FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal, - AnimationTokens::Curve::fade(), [this]() { - if (m_shimmer) { - m_shimmer->stopAnimation(); - m_shimmer->hide(); - } - }); - } 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))) { - setNavButtonVisible(m_btnLeft, false); - setNavButtonVisible(m_btnRight, false); - } - } 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(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(); - if (MotionPrefs::reduceMotion()) { - hBar->setValue(m_hScrollTarget); - } else { - 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)) { - setNavButtonVisible(m_btnLeft, false); - setNavButtonVisible(m_btnRight, false); - return; - } - - int currentWidth = this->width(); - QScrollBar* bar = m_listView->horizontalScrollBar(); - - bool isLeftHalf = localPos.x() < (currentWidth / 2); - - setNavButtonVisible(m_btnLeft, isLeftHalf && bar->value() > 0); - setNavButtonVisible(m_btnRight, !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 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 rows = rowSet.values(); - std::sort(rows.begin(), rows.end()); - m_listModel->setPriorityRows(rows); -} diff --git a/src/qEmbyApp/components/horizontallistviewgallery.h b/src/qEmbyApp/components/horizontallistviewgallery.h deleted file mode 100644 index 95c0ea0..0000000 --- a/src/qEmbyApp/components/horizontallistviewgallery.h +++ /dev/null @@ -1,97 +0,0 @@ -#ifndef HORIZONTALLISTVIEWGALLERY_H -#define HORIZONTALLISTVIEWGALLERY_H - -#include -#include -#include -#include -#include - -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& 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 diff --git a/src/qEmbyApp/components/horizontalwidgetgallery.cpp b/src/qEmbyApp/components/horizontalwidgetgallery.cpp deleted file mode 100644 index 8a018b6..0000000 --- a/src/qEmbyApp/components/horizontalwidgetgallery.cpp +++ /dev/null @@ -1,285 +0,0 @@ -#include "horizontalwidgetgallery.h" -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/fadehelper.h" -#include "../utils/motionprefs.h" - -namespace { -// Fade the nav arrows instead of popping; only reacts to desired-visibility -// changes (this runs on every mouse move). -void setNavButtonVisible(QPushButton* btn, bool visible) -{ - const QVariant last = btn->property("navVisible"); - if (!last.isNull() && last.toBool() == visible) { - return; - } - btn->setProperty("navVisible", visible); - if (visible) { - FadeHelper::fadeIn(btn, AnimationTokens::DurationMs::kQuick); - } else { - FadeHelper::fadeOut(btn, AnimationTokens::DurationMs::kMicro, - AnimationTokens::Curve::exit(), [btn]() { btn->hide(); }); - } -} -} // namespace -#include -#include -#include -#include - -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(AnimationTokens::Curve::enter()); - m_hScrollAnim->setDuration(AnimationTokens::DurationMs::kSlow); - - 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()); - - if (MotionPrefs::reduceMotion()) { - bar->setValue(targetValue); - return; - } - auto* anim = new QPropertyAnimation(bar, "value", this); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - 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))) { - setNavButtonVisible(m_btnLeft, false); - setNavButtonVisible(m_btnRight, false); - } - } else if (event->type() == QEvent::Wheel) { - - QWheelEvent* wheelEvent = static_cast(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(); - if (MotionPrefs::reduceMotion()) { - hBar->setValue(m_hScrollTarget); - } else { - 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)) { - setNavButtonVisible(m_btnLeft, false); - setNavButtonVisible(m_btnRight, false); - return; - } - - int currentWidth = this->width(); - QScrollBar* bar = m_scrollArea->horizontalScrollBar(); - - bool isLeftHalf = localPos.x() < (currentWidth / 2); - - setNavButtonVisible(m_btnLeft, isLeftHalf && bar->value() > 0); - setNavButtonVisible(m_btnRight, !isLeftHalf && bar->value() < bar->maximum()); -} diff --git a/src/qEmbyApp/components/horizontalwidgetgallery.h b/src/qEmbyApp/components/horizontalwidgetgallery.h deleted file mode 100644 index 876ff43..0000000 --- a/src/qEmbyApp/components/horizontalwidgetgallery.h +++ /dev/null @@ -1,57 +0,0 @@ -#ifndef HORIZONTALWIDGETGALLERY_H -#define HORIZONTALWIDGETGALLERY_H - -#include - -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 diff --git a/src/qEmbyApp/components/idanmakusurface.h b/src/qEmbyApp/components/idanmakusurface.h new file mode 100644 index 0000000..6eb16ce --- /dev/null +++ b/src/qEmbyApp/components/idanmakusurface.h @@ -0,0 +1,23 @@ +#ifndef IDANMAKUSURFACE_H +#define IDANMAKUSURFACE_H + +#include + +// 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 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 diff --git a/src/qEmbyApp/components/libraryactionmenu.cpp b/src/qEmbyApp/components/libraryactionmenu.cpp deleted file mode 100644 index e829842..0000000 --- a/src/qEmbyApp/components/libraryactionmenu.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "libraryactionmenu.h" - -#include "../managers/thememanager.h" -#include "../utils/popupfade.h" -#include -#include -#include -#include - -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); - - PopupFade::attach(this); - - 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()) { - return {}; - } - - return requestData.value(); -} - -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); -} diff --git a/src/qEmbyApp/components/libraryactionmenu.h b/src/qEmbyApp/components/libraryactionmenu.h deleted file mode 100644 index 22db3b3..0000000 --- a/src/qEmbyApp/components/libraryactionmenu.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifndef LIBRARYACTIONMENU_H -#define LIBRARYACTIONMENU_H - -#include "librarycontextmenurequest.h" -#include -#include - -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 diff --git a/src/qEmbyApp/components/librarycontextmenurequest.h b/src/qEmbyApp/components/librarycontextmenurequest.h deleted file mode 100644 index 22fd793..0000000 --- a/src/qEmbyApp/components/librarycontextmenurequest.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef LIBRARYCONTEXTMENUREQUEST_H -#define LIBRARYCONTEXTMENUREQUEST_H - -#include -#include - -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 diff --git a/src/qEmbyApp/components/mediagridwidget.cpp b/src/qEmbyApp/components/mediagridwidget.cpp deleted file mode 100644 index 33d07e6..0000000 --- a/src/qEmbyApp/components/mediagridwidget.cpp +++ /dev/null @@ -1,419 +0,0 @@ -#include "mediagridwidget.h" -#include "shimmerwidget.h" -#include "../utils/smoothscrollcontroller.h" -#include "../utils/textwraputils.h" -#include "../views/media/medialistmodel.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/fadehelper.h" - -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(AnimationTokens::DurationMs::kFast); - - - 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(); - // Show + snap opacity back to 1.0 (a prior reveal fade left the effect at 0). - FadeHelper::fadeIn(m_shimmer, 0); - m_shimmer->startAnimation(); - } else if (m_shimmer->isVisible()) { - FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal, - AnimationTokens::Curve::fade(), [this]() { - if (m_shimmer) { - m_shimmer->stopAnimation(); - m_shimmer->hide(); - } - }); - } else { - m_shimmer->stopAnimation(); - m_shimmer->hide(); - } -} - -void MediaGridWidget::setItems(const QList& items) { - m_listModel->setItems(items); - adjustGrid(); - - - if (m_shimmer && m_shimmer->isVisible() && !items.isEmpty()) { - // Cross-fade the skeleton out to reveal the now-populated grid underneath. - FadeHelper::fadeOut(m_shimmer, AnimationTokens::DurationMs::kNormal, - AnimationTokens::Curve::fade(), [this]() { - if (m_shimmer) { - 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(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 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 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(); -} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/qEmbyApp/components/mediagridwidget.h b/src/qEmbyApp/components/mediagridwidget.h deleted file mode 100644 index db0179d..0000000 --- a/src/qEmbyApp/components/mediagridwidget.h +++ /dev/null @@ -1,76 +0,0 @@ -#ifndef MEDIAGRIDWIDGET_H -#define MEDIAGRIDWIDGET_H - -#include -#include -#include -#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& 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 diff --git a/src/qEmbyApp/components/mediasectionwidget.cpp b/src/qEmbyApp/components/mediasectionwidget.cpp deleted file mode 100644 index 1641523..0000000 --- a/src/qEmbyApp/components/mediasectionwidget.cpp +++ /dev/null @@ -1,126 +0,0 @@ -#include "mediasectionwidget.h" -#include "horizontallistviewgallery.h" -#include -#include -#include -#include -#include -#include - -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()); - hide(); -} - -void MediaSectionWidget::setItems(const QList& items) { - if (items.isEmpty()) { - hide(); - } else { - m_gallery->setItems(items); - show(); - } -} - -void MediaSectionWidget::updateItem(const MediaItem& item) { - m_gallery->updateItem(item); -} - - -QCoro::Task MediaSectionWidget::loadAsync(FetchFunction fetcher) { - if (!fetcher) co_return; - - - QPointer safeThis(this); - - try { - QList items = co_await fetcher(); - - if (safeThis) { - safeThis->setItems(items); - Q_EMIT safeThis->dataLoaded(items.size()); - } - } catch(...) { - - if (safeThis) { - safeThis->hide(); - } - } -} diff --git a/src/qEmbyApp/components/mediasectionwidget.h b/src/qEmbyApp/components/mediasectionwidget.h deleted file mode 100644 index 028e85d..0000000 --- a/src/qEmbyApp/components/mediasectionwidget.h +++ /dev/null @@ -1,59 +0,0 @@ -#ifndef MEDIASECTIONWIDGET_H -#define MEDIASECTIONWIDGET_H - -#include -#include -#include -#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& items); - void updateItem(const MediaItem& item); - - - using FetchFunction = std::function>()>; - QCoro::Task 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 diff --git a/src/qEmbyApp/components/modernmenubutton.cpp b/src/qEmbyApp/components/modernmenubutton.cpp deleted file mode 100644 index 1e9785b..0000000 --- a/src/qEmbyApp/components/modernmenubutton.cpp +++ /dev/null @@ -1,200 +0,0 @@ -#include "modernmenubutton.h" -#include "../utils/popupfade.h" -#include -#include -#include -#include -#include -#include - -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( - "stream-menu-item", Qt::FindChildrenRecursively); -} - -void refreshStreamMenuItemState(QAction *action, bool selected) { - action->setChecked(selected); - - auto *widgetAction = qobject_cast(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(); - 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); - PopupFade::attach(m_menu); - - 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(""); - } -} diff --git a/src/qEmbyApp/components/modernmenubutton.h b/src/qEmbyApp/components/modernmenubutton.h deleted file mode 100644 index 65a4450..0000000 --- a/src/qEmbyApp/components/modernmenubutton.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef MODERNMENUBUTTON_H -#define MODERNMENUBUTTON_H - -#include -#include -#include -#include - -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 m_actions; - int m_currentIndex; -}; - -#endif diff --git a/src/qEmbyApp/components/modernmenuscrollbar.cpp b/src/qEmbyApp/components/modernmenuscrollbar.cpp deleted file mode 100644 index be40543..0000000 --- a/src/qEmbyApp/components/modernmenuscrollbar.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "modernmenuscrollbar.h" - -#include -#include - -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(static_cast(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( - static_cast(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); -} diff --git a/src/qEmbyApp/components/modernmenuscrollbar.h b/src/qEmbyApp/components/modernmenuscrollbar.h deleted file mode 100644 index 5b24110..0000000 --- a/src/qEmbyApp/components/modernmenuscrollbar.h +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef MODERNMENUSCROLLBAR_H -#define MODERNMENUSCROLLBAR_H - -#include -#include - - - - - - - - - - - - - - - - - - -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 diff --git a/src/qEmbyApp/components/modernnumberinput.cpp b/src/qEmbyApp/components/modernnumberinput.cpp deleted file mode 100644 index e1d5d94..0000000 --- a/src/qEmbyApp/components/modernnumberinput.cpp +++ /dev/null @@ -1,14 +0,0 @@ -#include "modernnumberinput.h" - -#include - -ModernNumberInput::ModernNumberInput(QWidget *parent) - : QSpinBox(parent) -{ - setObjectName("ModernNumberInput"); - setRange(0, 10000); - setAlignment(Qt::AlignCenter); - setButtonSymbols(QAbstractSpinBox::NoButtons); - setKeyboardTracking(false); - setFixedSize(118, 34); -} diff --git a/src/qEmbyApp/components/modernnumberinput.h b/src/qEmbyApp/components/modernnumberinput.h deleted file mode 100644 index 1037f80..0000000 --- a/src/qEmbyApp/components/modernnumberinput.h +++ /dev/null @@ -1,14 +0,0 @@ -#ifndef MODERNNUMBERINPUT_H -#define MODERNNUMBERINPUT_H - -#include - -class ModernNumberInput : public QSpinBox -{ - Q_OBJECT - -public: - explicit ModernNumberInput(QWidget *parent = nullptr); -}; - -#endif diff --git a/src/qEmbyApp/components/modernscrollpanel.cpp b/src/qEmbyApp/components/modernscrollpanel.cpp deleted file mode 100644 index a030887..0000000 --- a/src/qEmbyApp/components/modernscrollpanel.cpp +++ /dev/null @@ -1,248 +0,0 @@ -#include "modernscrollpanel.h" -#include "modernmenuscrollbar.h" -#include -#include -#include -#include -#include -#include - -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); -} diff --git a/src/qEmbyApp/components/modernscrollpanel.h b/src/qEmbyApp/components/modernscrollpanel.h deleted file mode 100644 index b61d1b7..0000000 --- a/src/qEmbyApp/components/modernscrollpanel.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef MODERNSCROLLPANEL_H -#define MODERNSCROLLPANEL_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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 m_items; - int m_maxContentWidth; -}; - -#endif diff --git a/src/qEmbyApp/components/modernsortbutton.cpp b/src/qEmbyApp/components/modernsortbutton.cpp deleted file mode 100644 index 96d7a32..0000000 --- a/src/qEmbyApp/components/modernsortbutton.cpp +++ /dev/null @@ -1,121 +0,0 @@ -#include "modernsortbutton.h" -#include "../utils/popupfade.h" -#include - -ModernSortButton::ModernSortButton(QWidget *parent) - : QPushButton(parent), m_currentIndex(-1), m_descending(true) -{ - m_menu = new QMenu(this); - PopupFade::attach(m_menu); - - - - - 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(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 ? " ↓" : " ↑")); - } -} diff --git a/src/qEmbyApp/components/modernsortbutton.h b/src/qEmbyApp/components/modernsortbutton.h deleted file mode 100644 index 42135eb..0000000 --- a/src/qEmbyApp/components/modernsortbutton.h +++ /dev/null @@ -1,53 +0,0 @@ -#ifndef MODERNSORTBUTTON_H -#define MODERNSORTBUTTON_H - -#include -#include -#include -#include - - -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 m_options; - int m_currentIndex; - bool m_descending; -}; - -#endif diff --git a/src/qEmbyApp/components/mpvwidget.cpp b/src/qEmbyApp/components/mpvwidget.cpp deleted file mode 100644 index e425d53..0000000 --- a/src/qEmbyApp/components/mpvwidget.cpp +++ /dev/null @@ -1,344 +0,0 @@ -#include "mpvwidget.h" -#include "mpvhttpstreamrelay.h" -#include "../utils/logredactionutils.h" -#include -#include -#include -#include -#include -#include -#include -#include "api/proxymanager.h" - -namespace { - -QSurfaceFormat createMpvSurfaceFormat() { - QSurfaceFormat format = QSurfaceFormat::defaultFormat(); - format.setRenderableType(QSurfaceFormat::OpenGL); - format.setVersion(3, 3); - format.setProfile(QSurfaceFormat::CoreProfile); - format.setDepthBufferSize(24); - format.setStencilBufferSize(8); - format.setSwapInterval(1); - format.setSwapBehavior(QSurfaceFormat::DoubleBuffer); - return format; -} - -QString glString(GLenum name) { - const auto *value = reinterpret_cast(glGetString(name)); - return value ? QString::fromLatin1(value) : QStringLiteral(""); -} - -} // namespace - -MpvWidget::MpvWidget(QWidget *parent) - : QOpenGLWidget(parent), m_mpv_gl(nullptr) { - - - - - this->setFormat(createMpvSurfaceFormat()); - - 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(glctx->getProcAddress(QByteArray(name))) : nullptr; -} - -void MpvWidget::onMpvRenderUpdate(void *ctx) { - - auto *self = static_cast(ctx); - QMetaObject::invokeMethod(self, "maybeUpdate", Qt::QueuedConnection); -} - -void MpvWidget::maybeUpdate() { - Q_EMIT renderUpdateRequested(); - update(); -} - -void MpvWidget::initializeGL() { - initializeOpenGLFunctions(); - - QOpenGLContext *ctx = QOpenGLContext::currentContext(); - if (!ctx) { - qCritical() << "[MpvWidget] Fatal Error: no current OpenGL context."; - emit errorOccurred(tr("当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。")); - return; - } - - const QSurfaceFormat actualFormat = ctx->format(); - qInfo().noquote() - << QStringLiteral("[MpvWidget] OpenGL context") - << "| requested: desktop OpenGL 3.3 core" - << "| actual:" - << QStringLiteral("%1.%2").arg(actualFormat.majorVersion()).arg(actualFormat.minorVersion()) - << "| profile:" << actualFormat.profile() - << "| renderableType:" << actualFormat.renderableType() - << "| isOpenGLES:" << ctx->isOpenGLES() - << "| vendor:" << glString(GL_VENDOR) - << "| renderer:" << glString(GL_RENDERER) - << "| version:" << glString(GL_VERSION); - - if (actualFormat.majorVersion() < 2) { - qCritical() << "Fatal Error: OpenGL version is too low."; - emit errorOccurred(tr("当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。")); - return; - } - - if (ctx->isOpenGLES() || actualFormat.majorVersion() < 3) { - qWarning() << "[MpvWidget] OpenGL context cannot safely upload hardware-decoded textures;" - << "disabling MPV hardware decoding to avoid corrupted video" - << "| isOpenGLES:" << ctx->isOpenGLES() - << "| version:" - << QStringLiteral("%1.%2").arg(actualFormat.majorVersion()).arg(actualFormat.minorVersion()); - m_controller->setProperty(QStringLiteral("hwdec"), QStringLiteral("no")); - } - - - - 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(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(width() * devicePixelRatio()); - int h = static_cast(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("") - : serverId); - } - - qInfo() << (wasPending - ? QStringLiteral("[MpvWidget] http-proxy applied (pending)") - : QStringLiteral("[MpvWidget] http-proxy applied")) - << "| url:" << LogRedactionUtils::url(loadQUrl) - << "| serverId:" << (serverId.isEmpty() - ? QStringLiteral("") - : serverId) - << "| mpvProxyValue:" << LogRedactionUtils::proxy(mpvProxyValue) - << "| relay:" << usingRelay - << "| playbackUrl:" << (usingRelay - ? playbackUrl - : QStringLiteral("")) - << "| 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"}); -} diff --git a/src/qEmbyApp/components/mpvwidget.h b/src/qEmbyApp/components/mpvwidget.h deleted file mode 100644 index 5b368c1..0000000 --- a/src/qEmbyApp/components/mpvwidget.h +++ /dev/null @@ -1,63 +0,0 @@ -#ifndef MPVWIDGET_H -#define MPVWIDGET_H - -#include -#include -#include -#include -#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 renderUpdateRequested(); - 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 diff --git a/src/qEmbyApp/components/playercombobox.cpp b/src/qEmbyApp/components/playercombobox.cpp deleted file mode 100644 index 8c88931..0000000 --- a/src/qEmbyApp/components/playercombobox.cpp +++ /dev/null @@ -1,258 +0,0 @@ -#include "playercombobox.h" -#include "../managers/thememanager.h" -#include -#include -#include -#include -#include -#include -#include - - - -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(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); -} diff --git a/src/qEmbyApp/components/playercombobox.h b/src/qEmbyApp/components/playercombobox.h deleted file mode 100644 index 1c2bfe1..0000000 --- a/src/qEmbyApp/components/playercombobox.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifndef PLAYERCOMBOBOX_H -#define PLAYERCOMBOBOX_H - -#include "moderncombobox.h" -#include -#include - - -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 diff --git a/src/qEmbyApp/components/playerdanmakucontroller.cpp b/src/qEmbyApp/components/playerdanmakucontroller.cpp index fc22da8..ab477de 100644 --- a/src/qEmbyApp/components/playerdanmakucontroller.cpp +++ b/src/qEmbyApp/components/playerdanmakucontroller.cpp @@ -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 @@ -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 PlayerDanmakuController::contentSubtitleTracks() const { QList 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 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( 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 PlayerDanmakuController::loadDanmakuTask(quint64 requestId, diff --git a/src/qEmbyApp/components/playerdanmakucontroller.h b/src/qEmbyApp/components/playerdanmakucontroller.h index ca9e259..a94e48b 100644 --- a/src/qEmbyApp/components/playerdanmakucontroller.h +++ b/src/qEmbyApp/components/playerdanmakucontroller.h @@ -10,18 +10,21 @@ #include 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 m_core; - QPointer m_mpvWidget; - QPointer m_nativeDanmakuOverlay; + QPointer m_mpvController; + IDanmakuSurface *m_danmakuSurface = nullptr; PlayerLaunchContext m_launchContext; DanmakuMediaContext m_mediaContext; QString m_assFilePath; diff --git a/src/qEmbyApp/components/playerlongpresshandler.cpp b/src/qEmbyApp/components/playerlongpresshandler.cpp deleted file mode 100644 index 5a5d7b2..0000000 --- a/src/qEmbyApp/components/playerlongpresshandler.cpp +++ /dev/null @@ -1,825 +0,0 @@ -#include "playerlongpresshandler.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.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(AnimationTokens::Curve::enter()); - - geometryAnim = new QPropertyAnimation(overlay, "geometry", this); - geometryAnim->setEasingCurve(AnimationTokens::Curve::enter()); - - iconAnim = new QPropertyAnimation(iconLabel, "pos", this); - iconAnim->setDuration(AnimationTokens::DurationMs::kNormal); - iconAnim->setEasingCurve(AnimationTokens::Curve::oscillate()); - - 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(ConfigKeys::PlayerLongPressMode, "seek"); - return mode == QStringLiteral("speed") ? mode : QStringLiteral("seek"); -} - -int PlayerLongPressHandler::longPressTriggerMs() const -{ - const int triggerMs = - ConfigStore::instance()->get(ConfigKeys::PlayerLongPressTriggerMs, "500").toInt(); - return triggerMs > 0 ? triggerMs : 500; -} - -double PlayerLongPressHandler::nextPlaybackRate(double currentRate) const -{ - static const QList 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() && !MotionPrefs::reduceMotion(); -} - -bool PlayerLongPressHandler::isMouseEdgeLongPressEnabled() const -{ - return ConfigStore::instance()->get(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(ConfigKeys::PlayerSeekStep, "10").toDouble(); - const bool longPressEnabled = - ConfigStore::instance()->get(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(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 ? AnimationTokens::DurationMs::kMicro - : AnimationTokens::DurationMs::kFast); - opacityAnim->setStartValue(startOpacity); - opacityAnim->setEndValue(targetOpacity); - - // Geometry lags the opacity slightly (140/200 vs 120/180) so the pill - // reads as sliding into place — tuned choreography, intentionally off - // the shared duration scale. - 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(AnimationTokens::DurationMs::kMicro); - opacityAnim->setStartValue(opacityEffect->opacity()); - opacityAnim->setEndValue(0.0); - - geometryAnim->setDuration(AnimationTokens::DurationMs::kMicro); - 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 (!visible) - { - // The animated path hides via the opacityAnim finished handler; - // when snapping we must hide directly. - overlay->hide(); - } - } - - 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() && !MotionPrefs::reduceMotion()) - { - m_pulseForward = false; - updateIndicatorPulse(); - m_pulseTimer->start(); - } -} - -void PlayerLongPressHandler::stopIndicatorAnimations() -{ - if (m_pulseTimer) - { - m_pulseTimer->stop(); - } - - const QList 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); -} diff --git a/src/qEmbyApp/components/playerlongpresshandler.h b/src/qEmbyApp/components/playerlongpresshandler.h deleted file mode 100644 index 23a4f7c..0000000 --- a/src/qEmbyApp/components/playerlongpresshandler.h +++ /dev/null @@ -1,123 +0,0 @@ -#ifndef PLAYERLONGPRESSHANDLER_H -#define PLAYERLONGPRESSHANDLER_H - -#include -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/playermediaswitcherpanel.cpp b/src/qEmbyApp/components/playermediaswitcherpanel.cpp deleted file mode 100644 index a82cc0d..0000000 --- a/src/qEmbyApp/components/playermediaswitcherpanel.cpp +++ /dev/null @@ -1,416 +0,0 @@ -#include "playermediaswitcherpanel.h" - -#include "horizontallistviewgallery.h" -#include "../utils/mediaitemutils.h" - -#include - -#include -#include -#include -#include -#include - -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(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(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{}); - m_seasonGallery->setItems(QList{}); - m_episodeGallery->setItems(QList{}); - - setStatusMessage(message); - applySeriesMode(); -} - -void PlayerMediaSwitcherPanel::setMovieItems(const QList &items, - const QString ¤tMediaId) { - 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 &seasons, - const QHash> &episodesBySeasonId, - const QString ¤tMediaId) { - 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 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(); -} diff --git a/src/qEmbyApp/components/playermediaswitcherpanel.h b/src/qEmbyApp/components/playermediaswitcherpanel.h deleted file mode 100644 index 9e4d2c7..0000000 --- a/src/qEmbyApp/components/playermediaswitcherpanel.h +++ /dev/null @@ -1,73 +0,0 @@ -#ifndef PLAYERMEDIASWITCHERPANEL_H -#define PLAYERMEDIASWITCHERPANEL_H - -#include -#include -#include - -#include - -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 &items, const QString ¤tMediaId); - void setSeriesData(const QString &seriesName, - const QList &seasons, - const QHash> &episodesBySeasonId, - const QString ¤tMediaId); - -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 m_movieItems; - QList m_seasons; - QHash> m_episodesBySeasonId; - QString m_currentMediaId; - QString m_currentSeriesName; - QString m_selectedSeasonId; - bool m_isLoading = false; - bool m_loadingSeriesMode = false; - bool m_isSeriesMode = false; -}; - -#endif diff --git a/src/qEmbyApp/components/playerosdlayer.cpp b/src/qEmbyApp/components/playerosdlayer.cpp deleted file mode 100644 index 2691cca..0000000 --- a/src/qEmbyApp/components/playerosdlayer.cpp +++ /dev/null @@ -1,354 +0,0 @@ -#include "playerosdlayer.h" -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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(AnimationTokens::DurationMs::kNormal); - m_fadeAnim->setEasingCurve(AnimationTokens::Curve::fade()); - - - 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(std::ceil(m_seekDuration))); - m_seekLine->setRange(0, maximum); - m_seekLine->setValue(qBound(0, static_cast(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(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() && !MotionPrefs::reduceMotion(); -} - -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(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(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); -} diff --git a/src/qEmbyApp/components/playerosdlayer.h b/src/qEmbyApp/components/playerosdlayer.h deleted file mode 100644 index 8d60d3a..0000000 --- a/src/qEmbyApp/components/playerosdlayer.h +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef PLAYEROSDLAYER_H -#define PLAYEROSDLAYER_H - -#include - -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 diff --git a/src/qEmbyApp/components/playerstatisticsoverlay.cpp b/src/qEmbyApp/components/playerstatisticsoverlay.cpp deleted file mode 100644 index b49fbc4..0000000 --- a/src/qEmbyApp/components/playerstatisticsoverlay.cpp +++ /dev/null @@ -1,204 +0,0 @@ -#include "playerstatisticsoverlay.h" - -#include -#include -#include -#include -#include - -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(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(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(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 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(); -} diff --git a/src/qEmbyApp/components/playerstatisticsoverlay.h b/src/qEmbyApp/components/playerstatisticsoverlay.h deleted file mode 100644 index dd388da..0000000 --- a/src/qEmbyApp/components/playerstatisticsoverlay.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef PLAYERSTATISTICSOVERLAY_H -#define PLAYERSTATISTICSOVERLAY_H - -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/playerwindow.cpp b/src/qEmbyApp/components/playerwindow.cpp deleted file mode 100644 index b8910f7..0000000 --- a/src/qEmbyApp/components/playerwindow.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#include "playerwindow.h" -#include "../views/media/playerview.h" -#include -#include -#include -#include -#include - -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("playerTopHUD"); - if (topHUD) { - m_windowAgent->setTitleBar(topHUD); - } - - -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - if (auto* minBtn = m_playerView->findChild("hud-min-btn")) - m_windowAgent->setSystemButton(QWK::WindowAgentBase::Minimize, minBtn); - if (auto* maxBtn = m_playerView->findChild("hud-max-btn")) - m_windowAgent->setSystemButton(QWK::WindowAgentBase::Maximize, maxBtn); - if (auto* closeBtn = m_playerView->findChild("hud-close-btn")) - m_windowAgent->setSystemButton(QWK::WindowAgentBase::Close, closeBtn); -#endif - - - if (auto* backBtn = m_playerView->findChild("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 -} diff --git a/src/qEmbyApp/components/playerwindow.h b/src/qEmbyApp/components/playerwindow.h deleted file mode 100644 index 85f4fb6..0000000 --- a/src/qEmbyApp/components/playerwindow.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef PLAYERWINDOW_H -#define PLAYERWINDOW_H - -#include -#include - -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 diff --git a/src/qEmbyApp/components/searchcompleterpopup.cpp b/src/qEmbyApp/components/searchcompleterpopup.cpp deleted file mode 100644 index 873b9d1..0000000 --- a/src/qEmbyApp/components/searchcompleterpopup.cpp +++ /dev/null @@ -1,352 +0,0 @@ -#include "searchcompleterpopup.h" - -#include "../managers/thememanager.h" -#include "../tokens/ColorTokens.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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) -{ - using namespace ColorTokens; - const int level = selected ? 3 : (hovered ? 2 : 0); - const SearchTint::Surface s = SearchTint::surface(level, darkMode); - - PopupItemPalette palette; - palette.backgroundTop = s.bgTop; - palette.backgroundBottom = s.bgBottom; - palette.border = s.border; - palette.text = s.text; - palette.textHighlight = darkMode ? Blue::k200 : Blue::k700; - palette.iconBubble = mix(s.bgTop, s.accent, darkMode ? 0.25 : 0.15); - palette.icon = s.accent; - return palette; -} - -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); - } -} diff --git a/src/qEmbyApp/components/searchcompleterpopup.h b/src/qEmbyApp/components/searchcompleterpopup.h deleted file mode 100644 index 48909be..0000000 --- a/src/qEmbyApp/components/searchcompleterpopup.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef SEARCHCOMPLETERPOPUP_H -#define SEARCHCOMPLETERPOPUP_H - -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/searchhistorychip.cpp b/src/qEmbyApp/components/searchhistorychip.cpp deleted file mode 100644 index 4e3a5f2..0000000 --- a/src/qEmbyApp/components/searchhistorychip.cpp +++ /dev/null @@ -1,552 +0,0 @@ -#include "searchhistorychip.h" - -#include "../managers/thememanager.h" -#include "../../qEmbyCore/config/config_keys.h" -#include "../../qEmbyCore/config/configstore.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../tokens/ColorTokens.h" - -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 = AnimationTokens::DurationMs::kQuick; -constexpr int kChipHoverDecorFadeOutDurationMs = AnimationTokens::DurationMs::kMicro; -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; -} - -int heatTintLevel(const QString &heatLevel) -{ - if (heatLevel == QLatin1String("top")) - { - return 3; - } - if (heatLevel == QLatin1String("high")) - { - return 2; - } - if (heatLevel == QLatin1String("mid")) - { - return 1; - } - return 0; -} - -ChipPalette basePalette(const QString &heatLevel, bool darkMode) -{ - using namespace ColorTokens; - const SearchTint::Surface s = - SearchTint::surface(heatTintLevel(heatLevel), darkMode); - - ChipPalette palette; - palette.backgroundTop = s.bgTop; - palette.backgroundBottom = s.bgBottom; - palette.border = s.border; - palette.text = s.text; - palette.badgeBackground = mix(s.bgTop, s.accent, darkMode ? 0.35 : 0.20); - palette.badgeBorder = mix(s.border, s.accent, 0.5); - palette.badgeText = darkMode ? Slate::k50 : Blue::k700; - palette.removeBackground = mix(s.bgTop, s.border, 0.5); - palette.removeIcon = darkMode ? Slate::k200 : Slate::k500; - return palette; -} - -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(AnimationTokens::Curve::enter()); - 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(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 ? AnimationTokens::Curve::enter() - : AnimationTokens::Curve::exit()); - 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); -} diff --git a/src/qEmbyApp/components/searchhistorychip.h b/src/qEmbyApp/components/searchhistorychip.h deleted file mode 100644 index c40552f..0000000 --- a/src/qEmbyApp/components/searchhistorychip.h +++ /dev/null @@ -1,65 +0,0 @@ -#ifndef SEARCHHISTORYCHIP_H -#define SEARCHHISTORYCHIP_H - -#include -#include - -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 diff --git a/src/qEmbyApp/components/searchhistorypopup.cpp b/src/qEmbyApp/components/searchhistorypopup.cpp deleted file mode 100644 index 4c5d539..0000000 --- a/src/qEmbyApp/components/searchhistorypopup.cpp +++ /dev/null @@ -1,1217 +0,0 @@ -#include "searchhistorypopup.h" - -#include "flowlayout.h" -#include "searchhistorychip.h" -#include "../managers/thememanager.h" -#include "../../qEmbyCore/config/config_keys.h" -#include "../../qEmbyCore/config/configstore.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" - -namespace { - -constexpr int kSearchHistoryPopupMinWidth = 360; -constexpr int kSearchHistoryPopupVisibleCount = 20; -constexpr int kSearchHistoryPopupOuterMargin = 4; -constexpr int kSearchHistoryPopupVerticalOffset = 3; -constexpr int kSearchHistoryPopupBodyPadding = 8; -constexpr int kSearchHistoryPopupBodyGap = 6; -constexpr int kSearchHistoryPopupControlsTop = 2; -constexpr int kSearchHistoryPopupBodyBottomPadding = 6; -constexpr int kSearchHistoryPopupEdgeScrollBarWidth = 4; -constexpr int kSearchHistoryPopupContentRightInset = 6; -constexpr int kSearchHistoryPopupCollapsedBodyMaxHeight = 228; -constexpr int kSearchHistoryPopupExpandedBodyMaxHeight = 320; -// Expand/collapse/sort/hide are a deliberately-tuned multi-stage choreography for -// this popup and intentionally sit off the shared duration scale; only the show -// duration lines up with a design tier (kFast). -constexpr int kSearchHistoryPopupExpandDurationMs = 240; -constexpr int kSearchHistoryPopupCollapseDurationMs = 200; -constexpr int kSearchHistoryPopupSortTransitionDurationMs = 190; -constexpr qreal kSearchHistoryPopupSortTransitionMidpoint = 0.44; -constexpr qreal kSearchHistoryPopupSortTransitionMinOpacity = 0.18; -constexpr int kSearchHistoryPopupShowDurationMs = AnimationTokens::DurationMs::kFast; -constexpr int kSearchHistoryPopupHideDurationMs = 140; -constexpr int kSearchHistoryPopupVisibilityOffsetY = 8; - -QString normalizeHistoryTerm(QString term) -{ - return term.simplified().toCaseFolded(); -} - -void repolishWidget(QWidget *widget) -{ - if (!widget) - { - return; - } - - widget->style()->unpolish(widget); - widget->style()->polish(widget); - widget->update(); -} - -void clearFlowLayout(FlowLayout *layout) -{ - while (layout && layout->count() > 0) - { - QLayoutItem *item = layout->takeAt(0); - if (item && item->widget()) - { - delete item->widget(); - } - delete item; - } -} - -QColor controlIconColor(bool active) -{ - if (ThemeManager::instance()->isDarkMode()) - { - return active ? QColor(QStringLiteral("#F8FAFC")) - : QColor(QStringLiteral("#8CA0B7")); - } - - return active ? QColor(QStringLiteral("#1D4ED8")) - : QColor(QStringLiteral("#64748B")); -} - -QColor clearIconColor(bool armed) -{ - if (ThemeManager::instance()->isDarkMode()) - { - return armed ? QColor(QStringLiteral("#FCA5A5")) - : QColor(QStringLiteral("#8CA0B7")); - } - - return armed ? QColor(QStringLiteral("#DC2626")) - : QColor(QStringLiteral("#64748B")); -} - -} - -SearchHistoryPopup::SearchHistoryPopup(QWidget *parent) - : QFrame(parent) { - setObjectName("SearchHistoryPopup"); - setAttribute(Qt::WA_StyledBackground, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAutoFillBackground(false); - setFocusPolicy(Qt::NoFocus); - hide(); - - auto *hostLayout = new QVBoxLayout(this); - hostLayout->setContentsMargins(kSearchHistoryPopupOuterMargin, - kSearchHistoryPopupOuterMargin, - kSearchHistoryPopupOuterMargin, - kSearchHistoryPopupOuterMargin); - hostLayout->setSpacing(0); - - m_card = new QFrame(this); - m_card->setObjectName("SearchHistoryPopupCard"); - m_card->setAttribute(Qt::WA_StyledBackground, true); - - // Drives the show/hide opacity fade. Without this the effect is never created - // and every `if (m_cardOpacityEffect)` branch is dead — the popup pops instead - // of fading. m_card owns no other graphics effect, so this is safe. - m_cardOpacityEffect = new QGraphicsOpacityEffect(m_card); - m_cardOpacityEffect->setOpacity(1.0); - m_card->setGraphicsEffect(m_cardOpacityEffect); - - auto *mainLayout = new QVBoxLayout(m_card); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - m_body = new QWidget(m_card); - m_body->setObjectName("SearchHistoryPopupBody"); - auto *bodyLayout = new QVBoxLayout(m_body); - bodyLayout->setContentsMargins(kSearchHistoryPopupBodyPadding, kSearchHistoryPopupControlsTop, - 0, - kSearchHistoryPopupBodyBottomPadding); - bodyLayout->setSpacing(0); - - m_scrollArea = new QScrollArea(m_body); - m_scrollArea->setObjectName("SearchHistoryScrollArea"); - m_scrollArea->setWidgetResizable(false); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->setAlignment(Qt::AlignLeft | Qt::AlignTop); - m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->viewport()->setObjectName("SearchHistoryScrollViewport"); - if (auto *verticalBar = m_scrollArea->verticalScrollBar()) - { - verticalBar->setSingleStep(20); - verticalBar->setPageStep(56); - } - - m_flowHost = new QWidget(); - m_flowHost->setObjectName("SearchHistoryFlowHost"); - m_flowHost->setAttribute(Qt::WA_StyledBackground, true); - m_flowHost->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - m_flowOpacityEffect = new QGraphicsOpacityEffect(m_flowHost); - m_flowOpacityEffect->setOpacity(1.0); - m_flowHost->setGraphicsEffect(m_flowOpacityEffect); - m_flowLayout = new FlowLayout(m_flowHost, 0, 2, 2); - m_scrollArea->setWidget(m_flowHost); - bodyLayout->addWidget(m_scrollArea); - - m_headerDivider = new QFrame(m_body); - m_headerDivider->setObjectName("SearchHistoryHeaderDivider"); - m_headerDivider->setFixedSize(1, 13); - - m_header = new QWidget(m_body); - m_header->setObjectName("SearchHistoryPopupHeader"); - auto *headerLayout = new QHBoxLayout(m_header); - headerLayout->setContentsMargins(0, 0, 0, 0); - headerLayout->setSpacing(2); - m_header->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - auto createHeaderButton = [this](const QString &objectName) { - auto *button = new QToolButton(m_header); - button->setObjectName(objectName); - button->setCursor(Qt::PointingHandCursor); - button->setToolButtonStyle(Qt::ToolButtonIconOnly); - button->setAutoRaise(true); - button->setCheckable(true); - button->setFocusPolicy(Qt::NoFocus); - button->setFixedSize(16, 16); - button->setIconSize(QSize(13, 13)); - return button; - }; - - m_recentButton = createHeaderButton(QStringLiteral("SearchHistoryModeButton")); - connect(m_recentButton, &QToolButton::clicked, this, [this]() { - setSortMode(SortMode::Recent); - }); - headerLayout->addWidget(m_recentButton, 0, Qt::AlignCenter); - - m_hotButton = createHeaderButton(QStringLiteral("SearchHistoryModeButton")); - connect(m_hotButton, &QToolButton::clicked, this, [this]() { - setSortMode(SortMode::Count); - }); - headerLayout->addWidget(m_hotButton, 0, Qt::AlignCenter); - - m_clearButton = new QToolButton(m_header); - m_clearButton->setObjectName("SearchHistoryClearButton"); - m_clearButton->setCursor(Qt::PointingHandCursor); - m_clearButton->setToolButtonStyle(Qt::ToolButtonIconOnly); - m_clearButton->setAutoRaise(true); - m_clearButton->setFocusPolicy(Qt::NoFocus); - m_clearButton->setFixedSize(16, 16); - m_clearButton->setIconSize(QSize(13, 13)); - connect(m_clearButton, &QToolButton::clicked, this, [this]() { - if (!m_clearArmed) - { - setClearArmed(true); - return; - } - - setClearArmed(false); - Q_EMIT clearHistoryRequested(); - dismiss(); - }); - headerLayout->addWidget(m_clearButton, 0, Qt::AlignCenter); - - m_expandButton = new QToolButton(m_header); - m_expandButton->setObjectName("SearchHistoryExpandButton"); - m_expandButton->setCursor(Qt::PointingHandCursor); - m_expandButton->setToolButtonStyle(Qt::ToolButtonIconOnly); - m_expandButton->setAutoRaise(true); - m_expandButton->setCheckable(true); - m_expandButton->setFocusPolicy(Qt::NoFocus); - m_expandButton->setFixedSize(16, 16); - m_expandButton->setIconSize(QSize(13, 13)); - connect(m_expandButton, &QToolButton::clicked, this, [this]() { - setExpanded(!m_expanded); - }); - headerLayout->addWidget(m_expandButton, 0, Qt::AlignCenter); - mainLayout->addWidget(m_body); - hostLayout->addWidget(m_card); - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [this](ThemeManager::Theme) { updateControlIcons(); }); - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [this](const QString &key, const QVariant &) { - if (key == ConfigKeys::UiAnimations) - { - syncReducedAnimationMode(); - } - }); - - m_heightAnimation = new QVariantAnimation(this); - m_heightAnimation->setStartValue(0.0); - m_heightAnimation->setEndValue(1.0); - m_heightAnimation->setDuration(kSearchHistoryPopupExpandDurationMs); - m_heightAnimation->setEasingCurve(AnimationTokens::Curve::enter()); - connect(m_heightAnimation, &QVariantAnimation::valueChanged, this, - [this](const QVariant &value) { - const qreal progress = value.toReal(); - const int scrollHeight = - qRound(m_animationStartScrollHeight + - (m_animationTargetScrollHeight - - m_animationStartScrollHeight) * - progress); - const int bodyHeight = - qRound(m_animationStartBodyHeight + - (m_animationTargetBodyHeight - - m_animationStartBodyHeight) * - progress); - const int popupHeight = - qRound(m_animationStartPopupHeight + - (m_animationTargetPopupHeight - - m_animationStartPopupHeight) * - progress); - applyAnimatedHeights(scrollHeight, bodyHeight, popupHeight); - }); - connect(m_heightAnimation, &QVariantAnimation::finished, this, - [this]() { - applyAnimatedHeights(m_animationTargetScrollHeight, - m_animationTargetBodyHeight, - m_animationTargetPopupHeight); - }); - - m_sortTransitionAnimation = new QVariantAnimation(this); - m_sortTransitionAnimation->setStartValue(0.0); - m_sortTransitionAnimation->setEndValue(1.0); - m_sortTransitionAnimation->setDuration(kSearchHistoryPopupSortTransitionDurationMs); - m_sortTransitionAnimation->setEasingCurve(AnimationTokens::Curve::enter()); - connect(m_sortTransitionAnimation, &QVariantAnimation::valueChanged, this, - [this](const QVariant &value) { - if (!m_flowOpacityEffect) - { - return; - } - - const qreal progress = value.toReal(); - if (!m_sortTransitionRebuilt && - progress >= kSearchHistoryPopupSortTransitionMidpoint) - { - m_sortTransitionRebuilt = true; - rebuildChips(); - } - - qreal opacity = 1.0; - if (progress < kSearchHistoryPopupSortTransitionMidpoint) - { - const qreal fadeOutProgress = - progress / kSearchHistoryPopupSortTransitionMidpoint; - opacity = 1.0 - (1.0 - kSearchHistoryPopupSortTransitionMinOpacity) * - fadeOutProgress; - } - else - { - const qreal fadeInProgress = - (progress - kSearchHistoryPopupSortTransitionMidpoint) / - (1.0 - kSearchHistoryPopupSortTransitionMidpoint); - opacity = kSearchHistoryPopupSortTransitionMinOpacity + - (1.0 - kSearchHistoryPopupSortTransitionMinOpacity) * - fadeInProgress; - } - - m_flowOpacityEffect->setOpacity(opacity); - }); - connect(m_sortTransitionAnimation, &QVariantAnimation::finished, this, - [this]() { - if (!m_sortTransitionRebuilt) - { - m_sortTransitionRebuilt = true; - rebuildChips(); - } - if (m_flowOpacityEffect) - { - m_flowOpacityEffect->setOpacity(1.0); - } - }); - - m_visibilityAnimation = new QVariantAnimation(this); - m_visibilityAnimation->setStartValue(0.0); - m_visibilityAnimation->setEndValue(1.0); - m_visibilityAnimation->setDuration(kSearchHistoryPopupShowDurationMs); - m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::enter()); - connect(m_visibilityAnimation, &QVariantAnimation::valueChanged, this, - [this](const QVariant &value) { - const qreal progress = value.toReal(); - const qreal opacity = - m_visibilityStartOpacity + - (m_visibilityTargetOpacity - m_visibilityStartOpacity) * - progress; - const int offsetY = - qRound(m_visibilityStartOffsetY + - (m_visibilityTargetOffsetY - - m_visibilityStartOffsetY) * - progress); - applyVisibilityState(opacity, offsetY); - }); - connect(m_visibilityAnimation, &QVariantAnimation::finished, this, - [this]() { - applyVisibilityState(m_visibilityTargetOpacity, - m_visibilityTargetOffsetY); - if (!m_visibilityHiding) - { - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - return; - } - - m_visibilityHiding = false; - QFrame::hide(); - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - m_visibilityOffsetY = 0; - m_anchor = nullptr; - }); - - refreshControls(); - m_lastCardWidth = kSearchHistoryPopupMinWidth; - syncReducedAnimationMode(); -} - -void SearchHistoryPopup::setEntries( - QList entries) -{ - stopSortTransitionAnimation(); - const bool keepExpanded = - m_expanded && entries.size() > kSearchHistoryPopupVisibleCount; - m_entries = std::move(entries); - m_expanded = keepExpanded; - setClearArmed(false); - rebuildChips(); -} - -bool SearchHistoryPopup::hasContent() const { - return !m_entries.isEmpty(); -} - -void SearchHistoryPopup::showBelow(QWidget *anchor) { - if (!anchor || !hasContent()) { - dismiss(true); - return; - } - - stopVisibilityAnimation(); - m_anchor = anchor; - relayoutPopup(qMax(kSearchHistoryPopupMinWidth, anchor->width() + 12)); - - const bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - const bool wasVisible = isVisible(); - - if (reduceAnimations) - { - if (!wasVisible) - { - QFrame::show(); - } - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - m_visibilityHiding = false; - applyVisibilityState(1.0, 0); - raise(); - return; - } - - if (!wasVisible) - { - m_visibilityStartOpacity = 0.0; - m_visibilityStartOffsetY = kSearchHistoryPopupVisibilityOffsetY; - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setEnabled(true); - m_cardOpacityEffect->setOpacity(0.0); - } - m_visibilityOffsetY = kSearchHistoryPopupVisibilityOffsetY; - QFrame::show(); - repositionToAnchor(); - } - else - { - m_visibilityStartOpacity = - m_cardOpacityEffect ? m_cardOpacityEffect->opacity() : 1.0; - m_visibilityStartOffsetY = m_visibilityOffsetY; - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setEnabled(true); - } - } - - m_visibilityHiding = false; - m_visibilityTargetOpacity = 1.0; - m_visibilityTargetOffsetY = 0; - m_visibilityAnimation->setDuration(kSearchHistoryPopupShowDurationMs); - m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::enter()); - m_visibilityAnimation->setStartValue(0.0); - m_visibilityAnimation->setEndValue(1.0); - m_visibilityAnimation->start(); - raise(); -} - -void SearchHistoryPopup::dismiss(bool immediate) -{ - stopHeightAnimation(); - stopSortTransitionAnimation(); - - const bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - const bool shouldHideImmediately = immediate || reduceAnimations; - - if (!isVisible() && (!m_visibilityAnimation || - m_visibilityAnimation->state() != QAbstractAnimation::Running)) - { - m_anchor = nullptr; - return; - } - - if (shouldHideImmediately) - { - stopVisibilityAnimation(); - m_visibilityHiding = false; - m_visibilityOffsetY = 0; - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - QFrame::hide(); - m_anchor = nullptr; - return; - } - - stopVisibilityAnimation(); - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setEnabled(true); - } - m_visibilityStartOpacity = - m_cardOpacityEffect ? m_cardOpacityEffect->opacity() : 1.0; - m_visibilityTargetOpacity = 0.0; - m_visibilityStartOffsetY = m_visibilityOffsetY; - m_visibilityTargetOffsetY = kSearchHistoryPopupVisibilityOffsetY; - m_visibilityHiding = true; - - if (!isVisible()) - { - QFrame::show(); - } - - m_visibilityAnimation->setDuration(kSearchHistoryPopupHideDurationMs); - m_visibilityAnimation->setEasingCurve(AnimationTokens::Curve::exit()); - m_visibilityAnimation->setStartValue(0.0); - m_visibilityAnimation->setEndValue(1.0); - m_visibilityAnimation->start(); -} - -void SearchHistoryPopup::rebuildChips(bool preserveScrollPosition) -{ - stopHeightAnimation(); - int preservedVerticalScrollValue = 0; - int preservedHorizontalScrollValue = 0; - if (preserveScrollPosition && m_scrollArea) - { - if (auto *verticalBar = m_scrollArea->verticalScrollBar()) - { - preservedVerticalScrollValue = verticalBar->value(); - } - - if (auto *horizontalBar = m_scrollArea->horizontalScrollBar()) - { - preservedHorizontalScrollValue = horizontalBar->value(); - } - } - - clearFlowLayout(m_flowLayout); - - const auto entries = sortedEntries(); - const int visibleCount = m_expanded ? entries.size() - : qMin(kSearchHistoryPopupVisibleCount, - entries.size()); - - for (int i = 0; i < visibleCount; ++i) - { - const auto &entry = entries.at(i); - auto *chip = new SearchHistoryChip(m_flowHost); - chip->setTerm(entry.term); - chip->setSearchCount(entry.searchCount); - chip->setHeatLevel(heatLevelForCount(entry.searchCount)); - - connect(chip, &QAbstractButton::clicked, this, [this, term = entry.term]() { - if (!term.isEmpty()) - { - Q_EMIT termActivated(term); - } - dismiss(); - }); - - connect(chip, &SearchHistoryChip::removeRequested, this, - [this](const QString &term) { - QTimer::singleShot(0, this, [this, term]() { - const QString normalizedTerm = normalizeHistoryTerm(term); - m_entries.erase(std::remove_if(m_entries.begin(), m_entries.end(), - [&normalizedTerm](const auto &entry) { - return normalizeHistoryTerm(entry.term) == - normalizedTerm; - }), - m_entries.end()); - - setClearArmed(false); - rebuildChips(true); - if (!hasContent()) - { - dismiss(); - } - - if (!term.isEmpty()) - { - Q_EMIT removeHistoryTermRequested(term); - } - }); - }); - m_flowLayout->addWidget(chip); - chip->show(); - } - - refreshControls(); - relayoutPopup(); - if (preserveScrollPosition && m_scrollArea) - { - if (auto *verticalBar = m_scrollArea->verticalScrollBar()) - { - verticalBar->setValue(qBound(verticalBar->minimum(), - preservedVerticalScrollValue, - verticalBar->maximum())); - } - - if (auto *horizontalBar = m_scrollArea->horizontalScrollBar()) - { - horizontalBar->setValue(qBound(horizontalBar->minimum(), - preservedHorizontalScrollValue, - horizontalBar->maximum())); - } - } - else - { - resetScrollPosition(); - } -} - -int SearchHistoryPopup::resolvedCardWidth() const -{ - if (m_anchor) - { - return qMax(kSearchHistoryPopupMinWidth, m_anchor->width() + 12); - } - - if (m_body && m_body->width() > 0) - { - return qMax(kSearchHistoryPopupMinWidth, m_body->width()); - } - - if (m_card && m_card->width() > 0) - { - return qMax(kSearchHistoryPopupMinWidth, m_card->width()); - } - - if (width() > kSearchHistoryPopupOuterMargin * 2) - { - return qMax(kSearchHistoryPopupMinWidth, - width() - kSearchHistoryPopupOuterMargin * 2); - } - - return qMax(kSearchHistoryPopupMinWidth, m_lastCardWidth); -} - -void SearchHistoryPopup::relayoutPopup(int preferredCardWidth) -{ - const int requestedCardWidth = preferredCardWidth > 0 - ? qMax(kSearchHistoryPopupMinWidth, - preferredCardWidth) - : resolvedCardWidth(); - updateLayoutMetrics(requestedCardWidth); - - if (m_card && m_card->layout()) - { - m_card->layout()->activate(); - } - if (layout()) - { - layout()->activate(); - } - - const int totalWidth = requestedCardWidth + kSearchHistoryPopupOuterMargin * 2; - const int totalHeight = m_card - ? m_card->height() + kSearchHistoryPopupOuterMargin * 2 - : sizeHint().height(); - resize(totalWidth, totalHeight); - - if (m_card && m_card->layout()) - { - m_card->layout()->activate(); - } - if (layout()) - { - layout()->activate(); - } - - const int actualCardWidth = qMax(kSearchHistoryPopupMinWidth, - width() - kSearchHistoryPopupOuterMargin * 2); - updateLayoutMetrics(actualCardWidth); - if (m_card && m_card->layout()) - { - m_card->layout()->activate(); - } - if (layout()) - { - layout()->activate(); - } - const int actualTotalHeight = m_card - ? m_card->height() + kSearchHistoryPopupOuterMargin * 2 - : sizeHint().height(); - resize(actualCardWidth + kSearchHistoryPopupOuterMargin * 2, - actualTotalHeight); - - m_lastCardWidth = actualCardWidth; - - if (m_animateNextRelayout && isVisible() && m_scrollArea && m_body && m_card && - m_heightAnimation) - { - m_animationTargetScrollHeight = m_scrollArea->height(); - m_animationTargetBodyHeight = m_body->height(); - m_animationTargetPopupHeight = height(); - - const bool hasHeightDelta = - m_animationStartScrollHeight != m_animationTargetScrollHeight || - m_animationStartBodyHeight != m_animationTargetBodyHeight || - m_animationStartPopupHeight != m_animationTargetPopupHeight; - - if (hasHeightDelta) - { - applyAnimatedHeights(m_animationStartScrollHeight, - m_animationStartBodyHeight, - m_animationStartPopupHeight); - m_heightAnimation->stop(); - m_heightAnimation->setStartValue(0.0); - m_heightAnimation->setEndValue(1.0); - m_heightAnimation->start(); - } - } - m_animateNextRelayout = false; - - if (isVisible()) - { - repositionToAnchor(); - raise(); - } -} - -void SearchHistoryPopup::repositionToAnchor() -{ - QWidget *host = parentWidget(); - if (!host || !m_anchor) - { - return; - } - - const int popupWidth = qMax(width(), resolvedCardWidth() + - kSearchHistoryPopupOuterMargin * 2); - const int popupHeight = qMax(height(), sizeHint().height()); - const QPoint anchorGlobal = - m_anchor->mapToGlobal(QPoint(m_anchor->width() / 2, - m_anchor->height() + - kSearchHistoryPopupVerticalOffset)); - QPoint popupPos = host->mapFromGlobal( - QPoint(anchorGlobal.x() - popupWidth / 2, anchorGlobal.y())); - - const QRect hostRect = host->rect(); - popupPos.setX(qBound(6, popupPos.x(), qMax(6, hostRect.width() - popupWidth - 6))); - popupPos.setY(qBound(0, popupPos.y() + m_visibilityOffsetY, - qMax(0, hostRect.height() - popupHeight))); - - move(popupPos); -} - -void SearchHistoryPopup::resetScrollPosition() -{ - if (!m_scrollArea) - { - return; - } - - if (auto *verticalBar = m_scrollArea->verticalScrollBar()) - { - verticalBar->setValue(verticalBar->minimum()); - } - - if (auto *horizontalBar = m_scrollArea->horizontalScrollBar()) - { - horizontalBar->setValue(horizontalBar->minimum()); - } -} - -void SearchHistoryPopup::stopHeightAnimation() -{ - if (m_heightAnimation && - m_heightAnimation->state() == QAbstractAnimation::Running) - { - m_heightAnimation->stop(); - } -} - -void SearchHistoryPopup::stopSortTransitionAnimation() -{ - if (m_sortTransitionAnimation && - m_sortTransitionAnimation->state() == QAbstractAnimation::Running) - { - m_sortTransitionAnimation->stop(); - } - - m_sortTransitionRebuilt = false; - if (m_flowOpacityEffect) - { - m_flowOpacityEffect->setOpacity(1.0); - } -} - -void SearchHistoryPopup::stopVisibilityAnimation() -{ - if (m_visibilityAnimation && - m_visibilityAnimation->state() == QAbstractAnimation::Running) - { - m_visibilityAnimation->stop(); - } -} - -void SearchHistoryPopup::syncReducedAnimationMode() -{ - const bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (!reduceAnimations) - { - return; - } - - const bool wasHeightAnimating = - m_heightAnimation && - m_heightAnimation->state() == QAbstractAnimation::Running; - const bool wasSortAnimating = - m_sortTransitionAnimation && - m_sortTransitionAnimation->state() == QAbstractAnimation::Running; - const bool needsSortRebuild = wasSortAnimating && !m_sortTransitionRebuilt; - const bool wasVisibilityAnimating = - m_visibilityAnimation && - m_visibilityAnimation->state() == QAbstractAnimation::Running; - const bool wasVisibilityHiding = m_visibilityHiding; - const bool preserveScrollPosition = isVisible(); - - stopHeightAnimation(); - stopSortTransitionAnimation(); - stopVisibilityAnimation(); - - if (needsSortRebuild) - { - rebuildChips(preserveScrollPosition); - } - else if (wasHeightAnimating) - { - m_animateNextRelayout = false; - relayoutPopup(); - } - - if (wasVisibilityAnimating || isVisible()) - { - if (wasVisibilityHiding) - { - m_visibilityHiding = false; - m_visibilityOffsetY = 0; - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - QFrame::hide(); - m_anchor = nullptr; - return; - } - - m_visibilityHiding = false; - applyVisibilityState(1.0, 0); - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(1.0); - m_cardOpacityEffect->setEnabled(false); - } - raise(); - } -} - -void SearchHistoryPopup::applyVisibilityState(qreal opacity, int offsetY) -{ - m_visibilityOffsetY = offsetY; - if (m_cardOpacityEffect) - { - m_cardOpacityEffect->setOpacity(qBound(0.0, opacity, 1.0)); - } - - if (isVisible()) - { - repositionToAnchor(); - } -} - -void SearchHistoryPopup::applyAnimatedHeights(int scrollHeight, int bodyHeight, - int popupHeight) -{ - const int safeScrollHeight = qMax(0, scrollHeight); - const int safeBodyHeight = qMax(0, bodyHeight); - const int safePopupHeight = - qMax(safeBodyHeight + kSearchHistoryPopupOuterMargin * 2, popupHeight); - - if (m_scrollArea) - { - m_scrollArea->setFixedHeight(safeScrollHeight); - } - - if (m_body) - { - m_body->setFixedHeight(safeBodyHeight); - } - - if (m_card) - { - m_card->setFixedHeight(safeBodyHeight); - } - - resize(width(), safePopupHeight); - if (isVisible()) - { - repositionToAnchor(); - } -} - -void SearchHistoryPopup::updateLayoutMetrics(int cardWidth) -{ - if (!m_flowHost || !m_flowLayout || !m_scrollArea) - { - return; - } - - m_flowLayout->invalidate(); - - if (m_header && m_header->layout()) - { - m_header->layout()->activate(); - m_header->adjustSize(); - } - - const int effectiveCardWidth = qMax(kSearchHistoryPopupMinWidth, cardWidth); - const bool headerVisible = m_header && !m_entries.isEmpty(); - const int controlWidth = headerVisible ? m_header->sizeHint().width() : 0; - const int controlHeight = headerVisible ? m_header->sizeHint().height() : 0; - const bool dividerVisible = headerVisible && m_headerDivider; - const int dividerWidth = dividerVisible ? m_headerDivider->width() : 0; - const int dividerHeight = dividerVisible ? m_headerDivider->height() : 0; - - const int firstLineTrailingSpacing = - headerVisible ? controlWidth + dividerWidth + kSearchHistoryPopupBodyGap * 2 : 0; - int flowWidth = qMax(220, effectiveCardWidth - kSearchHistoryPopupBodyPadding - - kSearchHistoryPopupContentRightInset); - m_flowLayout->setFirstLineTrailingSpacing(firstLineTrailingSpacing); - - const int maxHeight = m_expanded ? kSearchHistoryPopupExpandedBodyMaxHeight - : kSearchHistoryPopupCollapsedBodyMaxHeight; - int contentHeight = m_flowLayout->heightForWidth(flowWidth); - bool needsScrollBar = (contentHeight + 2) > maxHeight; - if (needsScrollBar) - { - const int adjustedFlowWidth = - qMax(220, flowWidth - kSearchHistoryPopupEdgeScrollBarWidth - 2); - if (adjustedFlowWidth != flowWidth) - { - flowWidth = adjustedFlowWidth; - contentHeight = m_flowLayout->heightForWidth(flowWidth); - } - } - - const int paddedContentHeight = qMax(0, contentHeight) + 2; - const int scrollHeight = qMin(paddedContentHeight, maxHeight); - const int scrollAreaWidth = - qMax(220, effectiveCardWidth - kSearchHistoryPopupBodyPadding); - const int bodyHeight = - kSearchHistoryPopupControlsTop + scrollHeight + kSearchHistoryPopupBodyBottomPadding; - - m_flowHost->setFixedWidth(flowWidth); - m_flowHost->setFixedHeight(paddedContentHeight); - if (m_flowHost->layout()) - { - m_flowHost->layout()->activate(); - m_flowHost->layout()->setGeometry(m_flowHost->rect()); - } - m_scrollArea->setFixedWidth(scrollAreaWidth); - m_scrollArea->setFixedHeight(scrollHeight); - m_scrollArea->setVerticalScrollBarPolicy( - paddedContentHeight > maxHeight ? Qt::ScrollBarAsNeeded : Qt::ScrollBarAlwaysOff); - if (m_body) - { - m_body->setFixedSize(effectiveCardWidth, bodyHeight); - } - if (m_card) - { - m_card->setFixedSize(effectiveCardWidth, bodyHeight); - } - - if (headerVisible) - { - const int headerX = - effectiveCardWidth - kSearchHistoryPopupBodyPadding - controlWidth; - const int headerY = kSearchHistoryPopupControlsTop; - m_header->setGeometry(headerX, headerY, controlWidth, controlHeight); - m_header->raise(); - } - - if (dividerVisible) - { - const int dividerX = effectiveCardWidth - kSearchHistoryPopupBodyPadding - - controlWidth - kSearchHistoryPopupBodyGap - dividerWidth; - const int dividerY = kSearchHistoryPopupControlsTop + - qMax(0, (controlHeight - dividerHeight) / 2); - m_headerDivider->move(dividerX, dividerY); - m_headerDivider->raise(); - } -} - -void SearchHistoryPopup::refreshControls() -{ - if (m_recentButton) - { - m_recentButton->setChecked(m_sortMode == SortMode::Recent); - m_recentButton->setToolTip(tr("Sort by recent searches")); - } - - if (m_hotButton) - { - m_hotButton->setChecked(m_sortMode == SortMode::Count); - m_hotButton->setToolTip(tr("Sort by most searched")); - } - - if (m_expandButton) - { - const bool canExpand = m_entries.size() > kSearchHistoryPopupVisibleCount; - m_expandButton->setVisible(canExpand); - m_expandButton->setChecked(m_expanded); - m_expandButton->setToolTip(m_expanded ? tr("Show Less") : tr("Show More")); - } - - if (m_clearButton) - { - m_clearButton->setVisible(!m_entries.isEmpty()); - m_clearButton->setToolTip(m_clearArmed ? tr("Confirm clear all history") - : tr("Clear search history")); - m_clearButton->setProperty("armed", m_clearArmed); - repolishWidget(m_clearButton); - } - - if (m_headerDivider) - { - m_headerDivider->setVisible(!m_entries.isEmpty()); - } - - if (m_header) - { - m_header->setVisible(!m_entries.isEmpty()); - if (m_header->layout()) - { - m_header->layout()->activate(); - m_header->adjustSize(); - } - } - - updateControlIcons(); -} - -void SearchHistoryPopup::updateControlIcons() -{ - if (m_recentButton) - { - m_recentButton->setIcon(ThemeManager::getAdaptiveIcon( - QStringLiteral(":/svg/dark/search-history-recent.svg"), - controlIconColor(m_sortMode == SortMode::Recent))); - } - - if (m_hotButton) - { - m_hotButton->setIcon(ThemeManager::getAdaptiveIcon( - QStringLiteral(":/svg/dark/search-history-hot.svg"), - controlIconColor(m_sortMode == SortMode::Count))); - } - - if (m_clearButton) - { - m_clearButton->setIcon(ThemeManager::getAdaptiveIcon( - m_clearArmed ? QStringLiteral(":/svg/dark/check.svg") - : QStringLiteral(":/svg/dark/delete.svg"), - clearIconColor(m_clearArmed))); - } - - if (m_expandButton) - { - const bool expanded = m_expandButton->isChecked(); - QPixmap pixmap = ThemeManager::getAdaptiveIcon( - QStringLiteral(":/svg/dark/chevron-down.svg"), - controlIconColor(expanded)) - .pixmap(13, 13); - - if (expanded) - { - QTransform transform; - transform.rotate(180); - pixmap = pixmap.transformed(transform, Qt::SmoothTransformation); - } - - m_expandButton->setIcon(QIcon(pixmap)); - } -} - -void SearchHistoryPopup::setClearArmed(bool armed) -{ - if (m_clearArmed == armed) - { - refreshControls(); - return; - } - - m_clearArmed = armed; - refreshControls(); -} - -void SearchHistoryPopup::setExpanded(bool expanded) -{ - if (m_expanded == expanded) - { - return; - } - - stopHeightAnimation(); - - const bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - m_animateNextRelayout = - isVisible() && !reduceAnimations && m_scrollArea && m_body && m_card; - if (m_animateNextRelayout) - { - m_animationStartScrollHeight = m_scrollArea->height(); - m_animationStartBodyHeight = m_body->height(); - m_animationStartPopupHeight = height(); - if (m_heightAnimation) - { - m_heightAnimation->setDuration(expanded ? kSearchHistoryPopupExpandDurationMs - : kSearchHistoryPopupCollapseDurationMs); - m_heightAnimation->setEasingCurve(AnimationTokens::Curve::enter()); - } - } - - setClearArmed(false); - m_expanded = expanded; - rebuildChips(); -} - -void SearchHistoryPopup::setSortMode(SortMode mode) -{ - if (m_sortMode == mode) - { - refreshControls(); - return; - } - - stopSortTransitionAnimation(); - setClearArmed(false); - m_sortMode = mode; - refreshControls(); - - const bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - const bool canAnimate = - isVisible() && !reduceAnimations && hasContent() && m_sortTransitionAnimation; - - if (!canAnimate) - { - rebuildChips(); - return; - } - - m_sortTransitionRebuilt = false; - m_sortTransitionAnimation->setStartValue(0.0); - m_sortTransitionAnimation->setEndValue(1.0); - m_sortTransitionAnimation->start(); -} - -QList -SearchHistoryPopup::sortedEntries() const -{ - auto entries = m_entries; - std::sort(entries.begin(), entries.end(), - [this](const SearchHistoryManager::SearchHistoryEntry &lhs, - const SearchHistoryManager::SearchHistoryEntry &rhs) { - if (m_sortMode == SortMode::Count) - { - if (lhs.searchCount != rhs.searchCount) - { - return lhs.searchCount > rhs.searchCount; - } - if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs) - { - return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs; - } - return lhs.term.localeAwareCompare(rhs.term) < 0; - } - - if (lhs.lastSearchedAtMs != rhs.lastSearchedAtMs) - { - return lhs.lastSearchedAtMs > rhs.lastSearchedAtMs; - } - if (lhs.searchCount != rhs.searchCount) - { - return lhs.searchCount > rhs.searchCount; - } - return lhs.term.localeAwareCompare(rhs.term) < 0; - }); - return entries; -} - -QString SearchHistoryPopup::heatLevelForCount(int count) -{ - if (count >= 10) - { - return QStringLiteral("top"); - } - if (count >= 6) - { - return QStringLiteral("high"); - } - if (count >= 3) - { - return QStringLiteral("mid"); - } - return QStringLiteral("low"); -} diff --git a/src/qEmbyApp/components/searchhistorypopup.h b/src/qEmbyApp/components/searchhistorypopup.h deleted file mode 100644 index 10f8584..0000000 --- a/src/qEmbyApp/components/searchhistorypopup.h +++ /dev/null @@ -1,95 +0,0 @@ -#ifndef SEARCHHISTORYPOPUP_H -#define SEARCHHISTORYPOPUP_H - -#include "../managers/searchhistorymanager.h" -#include -#include - -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 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 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 m_entries; - SortMode m_sortMode = SortMode::Recent; - bool m_expanded = false; - bool m_clearArmed = false; -}; - -#endif diff --git a/src/qEmbyApp/components/seektimebubble.cpp b/src/qEmbyApp/components/seektimebubble.cpp deleted file mode 100644 index 6c563a4..0000000 --- a/src/qEmbyApp/components/seektimebubble.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "seektimebubble.h" - -#include -#include -#include - -#include "../utils/fadehelper.h" - -SeekTimeBubble::SeekTimeBubble(QWidget *parent) - : QWidget(parent) -{ - setAttribute(Qt::WA_TransparentForMouseEvents); - setVisible(false); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(10, 4, 10, 4); - layout->setSpacing(4); - - m_thumbSlot = new QLabel(this); - m_thumbSlot->setObjectName("seekBubbleThumb"); - m_thumbSlot->setVisible(false); - layout->addWidget(m_thumbSlot, 0, Qt::AlignHCenter); - - m_timeLabel = new QLabel(this); - m_timeLabel->setObjectName("seekBubbleTime"); - m_timeLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(m_timeLabel, 0, Qt::AlignHCenter); -} - -void SeekTimeBubble::setTimeText(const QString &text) -{ - if (m_timeLabel->text() == text) - { - return; - } - m_timeLabel->setText(text); - adjustSize(); -} - -void SeekTimeBubble::showAt(const QPoint &anchor) -{ - adjustSize(); - - QPoint topLeft(anchor.x() - width() / 2, anchor.y() - height()); - if (parentWidget()) - { - const int maxX = parentWidget()->width() - width(); - topLeft.setX(qBound(0, topLeft.x(), qMax(0, maxX))); - topLeft.setY(qMax(0, topLeft.y())); - } - move(topLeft); - - if (!m_shown) - { - m_shown = true; - raise(); - FadeHelper::fadeIn(this, AnimationTokens::DurationMs::kQuick); - } -} - -void SeekTimeBubble::hideBubble() -{ - if (!m_shown) - { - return; - } - m_shown = false; - FadeHelper::fadeOut(this, AnimationTokens::DurationMs::kMicro, - AnimationTokens::Curve::exit(), [this]() { hide(); }); -} - -void SeekTimeBubble::paintEvent(QPaintEvent *event) -{ - Q_UNUSED(event); - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - painter.setPen(QPen(QColor(148, 163, 184, 60), 1)); - painter.setBrush(QColor(15, 23, 42, 235)); - painter.drawRoundedRect(QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5), 6, 6); -} diff --git a/src/qEmbyApp/components/seektimebubble.h b/src/qEmbyApp/components/seektimebubble.h deleted file mode 100644 index 9b13903..0000000 --- a/src/qEmbyApp/components/seektimebubble.h +++ /dev/null @@ -1,37 +0,0 @@ -#ifndef SEEKTIMEBUBBLE_H -#define SEEKTIMEBUBBLE_H - -#include - -class QLabel; - -// Small floating bubble shown above the player seek bar while hovering, -// displaying the timestamp under the cursor. Fades in kQuick / out kMicro -// (via FadeHelper, so reduce-motion snaps). The layout reserves a hidden -// thumbnail slot above the time label so a trickplay preview image can be -// added later without re-layouting. -// -// The bubble is an always-dark player surface, styled by objectName -// "seekBubbleTime" in the QSS plus a self-painted panel. -class SeekTimeBubble : public QWidget -{ - Q_OBJECT -public: - explicit SeekTimeBubble(QWidget *parent); - - void setTimeText(const QString &text); - // Anchor = bottom-center of the bubble, in parent coordinates. Clamped to - // stay inside the parent. Fades in on the first call after a hide. - void showAt(const QPoint &anchor); - void hideBubble(); - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - QLabel *m_thumbSlot; - QLabel *m_timeLabel; - bool m_shown = false; -}; - -#endif // SEEKTIMEBUBBLE_H diff --git a/src/qEmbyApp/components/serverwheelview.cpp b/src/qEmbyApp/components/serverwheelview.cpp deleted file mode 100644 index 445f148..0000000 --- a/src/qEmbyApp/components/serverwheelview.cpp +++ /dev/null @@ -1,347 +0,0 @@ -#include "serverwheelview.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" - -ServerWheelView::ServerWheelView(QWidget* parent) : QWidget(parent) { - setAttribute(Qt::WA_StyledBackground, true); - setStyleSheet("ServerWheelView { background: transparent; }"); - - m_scrollAnim = new QVariantAnimation(this); - - m_scrollAnim->setEasingCurve(AnimationTokens::Curve::enter()); - m_scrollAnim->setDuration(AnimationTokens::DurationMs::kSlow); - 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(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(event); - if (me->button() == Qt::LeftButton) handleMousePress(me->globalPosition().toPoint()); - } - else if (event->type() == QEvent::MouseMove) { - auto* me = static_cast(event); - handleMouseMove(me->globalPosition().toPoint()); - if (m_dragStarted) return true; - } - else if (event->type() == QEvent::MouseButtonRelease) { - auto* me = static_cast(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()) { - child->installEventFilter(this); - } -} - -void ServerWheelView::scrollTo(qreal target) { - m_scrollAnim->stop(); - if (MotionPrefs::reduceMotion()) { - m_scrollOffset = target; - updateLayout(); - return; - } - 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 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(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(card->property("cachedActionPtr").toULongLong()); - } else { - for (auto* btn : card->findChildren()) { - if (btn->objectName() == "action-edit-btn" && btn->parentWidget()) { - actionContainer = btn->parentWidget(); - card->setProperty("cachedActionPtr", reinterpret_cast(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()) { - - if (qobject_cast(child) || qobject_cast(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(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(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(); - } -} diff --git a/src/qEmbyApp/components/serverwheelview.h b/src/qEmbyApp/components/serverwheelview.h deleted file mode 100644 index 94b058a..0000000 --- a/src/qEmbyApp/components/serverwheelview.h +++ /dev/null @@ -1,62 +0,0 @@ -#ifndef SERVERWHEELVIEW_H -#define SERVERWHEELVIEW_H - -#include -#include -#include - -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 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 diff --git a/src/qEmbyApp/components/settingscard.cpp b/src/qEmbyApp/components/settingscard.cpp deleted file mode 100644 index b09d3cd..0000000 --- a/src/qEmbyApp/components/settingscard.cpp +++ /dev/null @@ -1,209 +0,0 @@ -#include "settingscard.h" -#include "elidedlabel.h" -#include "modernswitch.h" -#include "moderntaginput.h" -#include "../managers/thememanager.h" -#include -#include -#include -#include -#include -#include - -#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(m_controlWidget)) { - combo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - } else if (auto *btn = qobject_cast(m_controlWidget)) { - btn->setObjectName("SettingsCardButton"); - btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - } else if (auto *lineEdit = qobject_cast(m_controlWidget)) { - lineEdit->setMinimumWidth(200); - lineEdit->setFixedHeight(32); - lineEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - } else if (auto *spinBox = qobject_cast(m_controlWidget)) { - spinBox->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - } else if (qobject_cast(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(m_controlWidget)) { - - switchControl->setChecked(store->get(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(m_controlWidget)) { - - QString currentVal = store->get(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::of(&QComboBox::currentIndexChanged), - this, [this, store, comboControl](int index) { - store->set(m_configKey, comboControl->itemData(index)); - }); - } - - - - else if (auto *lineEdit = qobject_cast(m_controlWidget)) { - - lineEdit->setText(store->get(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(m_controlWidget)) { - spinBox->setValue(store->get(m_configKey, m_defaultValue.toInt())); - - connect(spinBox, QOverload::of(&QSpinBox::valueChanged), this, - [this, store](int value) { store->set(m_configKey, value); }); - } - - - - else if (auto *tagInput = qobject_cast(m_controlWidget)) { - - tagInput->setValue(store->get(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(m_controlWidget)) { - switchControl->setChecked(newValue.toBool()); - } else if (auto *comboControl = qobject_cast(m_controlWidget)) { - int idx = comboControl->findData(newValue); - if (idx >= 0) { - comboControl->setCurrentIndex(idx); - } - } else if (auto *lineEdit = qobject_cast(m_controlWidget)) { - lineEdit->setText(newValue.toString()); - } else if (auto *spinBox = qobject_cast(m_controlWidget)) { - spinBox->setValue(newValue.toInt()); - } else if (auto *tagInput = qobject_cast(m_controlWidget)) { - tagInput->setValue(newValue.toString()); - } - - m_controlWidget->blockSignals(false); -} diff --git a/src/qEmbyApp/components/settingscard.h b/src/qEmbyApp/components/settingscard.h deleted file mode 100644 index be56321..0000000 --- a/src/qEmbyApp/components/settingscard.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifndef SETTINGSCARD_H -#define SETTINGSCARD_H - -#include -#include -#include -#include -#include -#include -#include -#include - -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 diff --git a/src/qEmbyApp/components/settingssubpanel.cpp b/src/qEmbyApp/components/settingssubpanel.cpp deleted file mode 100644 index 21868e7..0000000 --- a/src/qEmbyApp/components/settingssubpanel.cpp +++ /dev/null @@ -1,429 +0,0 @@ -#include "settingssubpanel.h" -#include "../managers/thememanager.h" -#include "../tokens/AnimationTokens.h" -#include -#include -#include -#include -#include - -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(AnimationTokens::DurationMs::kNormal); - m_animation->setEasingCurve(AnimationTokens::Curve::enter()); - - - 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(p)) { - scrollArea->ensureWidgetVisible(this, 0, 60); - break; - } - p = p->parentWidget(); - } - }; - - int targetH = contentHeight(); - bool reduceAnimations = ConfigStore::instance()->get(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(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()); -} diff --git a/src/qEmbyApp/components/settingssubpanel.h b/src/qEmbyApp/components/settingssubpanel.h deleted file mode 100644 index 303fa81..0000000 --- a/src/qEmbyApp/components/settingssubpanel.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifndef SETTINGSSUBPANEL_H -#define SETTINGSSUBPANEL_H - -#include -#include -#include -#include - - -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 diff --git a/src/qEmbyApp/components/shadowoverlay.cpp b/src/qEmbyApp/components/shadowoverlay.cpp deleted file mode 100644 index 6f468fb..0000000 --- a/src/qEmbyApp/components/shadowoverlay.cpp +++ /dev/null @@ -1,90 +0,0 @@ -#include "shadowoverlay.h" - -#include - -ShadowOverlay::ShadowOverlay(QWidget *parent) : QWidget(parent) -{ - setAttribute(Qt::WA_TransparentForMouseEvents, true); - setAttribute(Qt::WA_NoSystemBackground, true); - setFocusPolicy(Qt::NoFocus); -} - -void ShadowOverlay::setShadow(int blurRadius, int cornerRadius, const QColor &color) -{ - m_blurRadius = blurRadius; - m_cornerRadius = cornerRadius; - m_color = color; - m_patch = NinePatchShadow{}; // force rebuild on next paint - m_builtDpr = 0.0; - update(); -} - -void ShadowOverlay::setBodyCutout(bool on) -{ - if (m_bodyCutout == on) - { - return; - } - m_bodyCutout = on; - update(); -} - -void ShadowOverlay::ensurePixmap(qreal dpr) -{ - if (!m_patch.isNull() && qFuzzyCompare(m_builtDpr, dpr)) - { - return; - } - m_patch = ShadowPixmap::make(m_blurRadius, m_cornerRadius, m_color, dpr); - m_builtDpr = dpr; -} - -void ShadowOverlay::paintEvent(QPaintEvent *) -{ - const qreal dpr = devicePixelRatioF(); - ensurePixmap(dpr); - if (m_patch.isNull()) - { - return; - } - - const int b = m_patch.border; - const int t = m_patch.total; - const qreal s = m_patch.dpr; - const QRect r = rect(); - - // Degenerate target (smaller than the two fixed borders): nothing sensible - // to slice, skip rather than draw garbage. - if (r.width() < 2 * b || r.height() < 2 * b) - { - return; - } - - // 3x3 grid: corners fixed, edges/centre stretched. Target columns/rows are - // logical; source columns/rows are physical (pixmap carries no dpr). - const int xt[4] = {r.left(), r.left() + b, r.right() + 1 - b, r.right() + 1}; - const int yt[4] = {r.top(), r.top() + b, r.bottom() + 1 - b, r.bottom() + 1}; - const qreal xs[4] = {0.0, b * s, (t - b) * s, t * s}; - const qreal ys[4] = {0.0, b * s, (t - b) * s, t * s}; - - QPainter p(this); - if (m_bodyCutout) - { - // Paint only the ring outside the target's footprint. - const int reachPx = reach(); - const QRect body = r.adjusted(reachPx, reachPx, -reachPx, -reachPx); - if (body.isValid()) - { - p.setClipRegion(QRegion(r).subtracted(QRegion(body))); - } - } - for (int i = 0; i < 3; ++i) - { - for (int j = 0; j < 3; ++j) - { - const QRectF target(xt[i], yt[j], xt[i + 1] - xt[i], yt[j + 1] - yt[j]); - const QRectF source(xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j]); - p.drawPixmap(target, m_patch.pixmap, source); - } - } -} diff --git a/src/qEmbyApp/components/shadowoverlay.h b/src/qEmbyApp/components/shadowoverlay.h deleted file mode 100644 index b1a129c..0000000 --- a/src/qEmbyApp/components/shadowoverlay.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef SHADOWOVERLAY_H -#define SHADOWOVERLAY_H - -#include -#include - -#include "../utils/shadowpixmap.h" - -// A mouse-transparent sibling widget that paints a pre-baked drop shadow as a -// 9-slice stretch. Use it in place of QGraphicsDropShadowEffect on widgets that -// animate: position the overlay just under the target (geometry expanded by -// reach() on every side) and it follows with no per-frame blur cost. -// -// The baked pixmap is built lazily on first paint and rebuilt only when the -// device pixel ratio changes, so HiDPI / monitor moves stay crisp. -class ShadowOverlay : public QWidget -{ - Q_OBJECT -public: - explicit ShadowOverlay(QWidget *parent = nullptr); - - // Configure the shadow. blurRadius/cornerRadius are logical pixels; color - // carries the tint and its alpha sets strength. Invalidates any baked pixmap. - void setShadow(int blurRadius, int cornerRadius, const QColor &color); - - // How far the shadow extends beyond the target on each side. Callers expand - // the target geometry by this amount to get the overlay geometry. - int reach() const { return m_blurRadius + 1; } - - // When enabled, the target's footprint (the overlay rect inset by reach()) is - // clipped out, so the shadow is painted only as a ring around the target. - // Required for semi-transparent targets, where the shadow would otherwise - // bleed through the body and darken it. - void setBodyCutout(bool on); - -protected: - void paintEvent(QPaintEvent *event) override; - -private: - void ensurePixmap(qreal dpr); - - int m_blurRadius = 0; - int m_cornerRadius = 0; - QColor m_color; - bool m_bodyCutout = false; - - NinePatchShadow m_patch; - qreal m_builtDpr = 0.0; -}; - -#endif // SHADOWOVERLAY_H diff --git a/src/qEmbyApp/components/shimmerwidget.cpp b/src/qEmbyApp/components/shimmerwidget.cpp deleted file mode 100644 index c4a79d4..0000000 --- a/src/qEmbyApp/components/shimmerwidget.cpp +++ /dev/null @@ -1,161 +0,0 @@ -#include "shimmerwidget.h" -#include "../managers/thememanager.h" -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" -#include -#include -#include - -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(AnimationTokens::DurationMs::kShimmer); - m_animation->setLoopCount(-1); - m_animation->setEasingCurve(AnimationTokens::Curve::loop()); -} - -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) { - return; - } - m_shimmerPhase = 0.0; - if (MotionPrefs::reduceMotion()) { - // Reduce-motion: show a static skeleton (base shapes) with no moving sweep. - update(); - return; - } - 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(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(); -} diff --git a/src/qEmbyApp/components/shimmerwidget.h b/src/qEmbyApp/components/shimmerwidget.h deleted file mode 100644 index 6f43c0a..0000000 --- a/src/qEmbyApp/components/shimmerwidget.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef SHIMMERWIDGET_H -#define SHIMMERWIDGET_H - -#include -#include - -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 diff --git a/src/qEmbyApp/components/splitplayerbutton.cpp b/src/qEmbyApp/components/splitplayerbutton.cpp deleted file mode 100644 index 177af0f..0000000 --- a/src/qEmbyApp/components/splitplayerbutton.cpp +++ /dev/null @@ -1,278 +0,0 @@ -#include "splitplayerbutton.h" -#include "../managers/externalplayerdetector.h" -#include "../utils/popupfade.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - - - -SplitPlayerButton::SplitPlayerButton(QWidget *parent) - : QWidget(parent) -{ - setMouseTracking(true); - setCursor(Qt::PointingHandCursor); - setFixedHeight(36); - - m_menu = new QMenu(this); - m_menu->setObjectName("immersive-stream-menu"); - - m_menu->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint); - m_menu->setAttribute(Qt::WA_TranslucentBackground, true); - m_menu->setAutoFillBackground(false); - PopupFade::attach(m_menu); -} - -void SplitPlayerButton::setPlayers(const QList &players, - const QString &activePlayerPath) -{ - m_menu->clear(); - - if (players.isEmpty()) { - m_activeIcon = QIcon(); - m_activeText.clear(); - m_activePath.clear(); - m_hasMultiple = false; - hide(); - return; - } - - QFileIconProvider iconProvider; - m_activePath = activePlayerPath; - m_hasMultiple = (players.size() > 1); - - - int activeIdx = 0; - for (int i = 0; i < players.size(); ++i) { - if (players[i].path == activePlayerPath) { - activeIdx = i; - break; - } - } - m_activeIcon = iconProvider.icon(QFileInfo(players[activeIdx].path)); - m_activeText = players[activeIdx].name; - - - for (const auto &player : players) { - QIcon playerIcon = iconProvider.icon(QFileInfo(player.path)); - bool isActive = (player.path == activePlayerPath); - QString title = isActive ? QString("★ %1").arg(player.name) : player.name; - - - auto *widgetAction = new QWidgetAction(m_menu); - - auto *container = new QWidget(); - container->setObjectName("stream-menu-item"); - auto *layout = new QHBoxLayout(container); - layout->setContentsMargins(14, 6, 28, 6); - layout->setSpacing(10); - - - auto *iconLabel = new QLabel(container); - iconLabel->setFixedSize(20, 20); - iconLabel->setPixmap(playerIcon.pixmap(20, 20)); - iconLabel->setStyleSheet("background: transparent;"); - - - auto *textContainer = new QWidget(container); - auto *textLayout = new QVBoxLayout(textContainer); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(2); - - auto *line1 = new QLabel(title, textContainer); - line1->setObjectName("stream-menu-line1"); - - auto *line2 = new QLabel(player.path, textContainer); - line2->setObjectName("stream-menu-line2"); - - textLayout->addWidget(line1); - textLayout->addWidget(line2); - - layout->addWidget(iconLabel); - layout->addWidget(textContainer, 1); - - widgetAction->setDefaultWidget(container); - widgetAction->setCheckable(true); - widgetAction->setChecked(isActive); - m_menu->addAction(widgetAction); - - QString path = player.path; - connect(widgetAction, &QAction::triggered, this, [this, path]() { - emit playerSelected(path); - }); - } - - setToolTip(tr("Play with %1").arg(m_activeText)); - show(); - update(); -} - -void SplitPlayerButton::setIconOnly(bool iconOnly) { - if (m_iconOnly == iconOnly) return; - m_iconOnly = iconOnly; - updateGeometry(); - update(); -} - -void SplitPlayerButton::clear() { - m_menu->clear(); - m_activeIcon = QIcon(); - m_activeText.clear(); - m_activePath.clear(); - m_hasMultiple = false; - update(); -} - -int SplitPlayerButton::arrowAreaWidth() const { - return m_hasMultiple ? 28 : 0; -} - -SplitPlayerButton::Zone SplitPlayerButton::zoneAt(const QPoint &pos) const { - if (!rect().contains(pos)) return None; - if (m_hasMultiple && pos.x() >= width() - arrowAreaWidth()) - return ArrowZone; - return MainZone; -} - -QSize SplitPlayerButton::sizeHint() const { - if (m_iconOnly) { - - int w = 8 + 20 + 8; - if (m_hasMultiple) w += arrowAreaWidth(); - return QSize(w, 36); - } - int textWidth = fontMetrics().horizontalAdvance(m_activeText); - int w = 8 + 20 + 6 + textWidth + 8; - if (m_hasMultiple) w += arrowAreaWidth(); - return QSize(qMax(w, 60), 36); -} - - - -void SplitPlayerButton::paintEvent(QPaintEvent *) { - QPainter p(this); - p.setRenderHint(QPainter::Antialiasing); - p.setRenderHint(QPainter::SmoothPixmapTransform); - - int arrowW = arrowAreaWidth(); - QRectF mainRect(0, 0, width() - arrowW, height()); - QRectF arrowRect(width() - arrowW, 0, arrowW, height()); - QRectF fullRect(0, 0, width(), height()); - - - QPainterPath bgPath; - bgPath.addRoundedRect(fullRect.adjusted(0.5, 0.5, -0.5, -0.5), - m_borderRadius, m_borderRadius); - - QColor mainBg = Qt::transparent; - QColor arrowBg = Qt::transparent; - - if (m_pressedZone == MainZone) mainBg = m_pressedColor; - else if (m_hoverZone == MainZone) mainBg = m_hoverColor; - - if (m_pressedZone == ArrowZone) arrowBg = m_pressedColor; - else if (m_hoverZone == ArrowZone) arrowBg = m_hoverColor; - - if (m_hasMultiple) { - p.save(); - p.setClipPath(bgPath); - if (mainBg != Qt::transparent) - p.fillRect(mainRect, mainBg); - if (arrowBg != Qt::transparent) - p.fillRect(arrowRect, arrowBg); - p.restore(); - } else { - if (mainBg != Qt::transparent) { - p.save(); - p.setClipPath(bgPath); - p.fillRect(fullRect, mainBg); - p.restore(); - } - } - - - int iconSize = 20; - int iconX = 8; - int iconY = (height() - iconSize) / 2; - m_activeIcon.paint(&p, iconX, iconY, iconSize, iconSize); - - - if (!m_iconOnly) { - int textX = iconX + iconSize + 6; - int textW = width() - textX - arrowW - 4; - if (textW > 0) { - p.setPen(m_textColor); - QFont f = font(); - f.setPointSizeF(9.5); - p.setFont(f); - QString elidedText = p.fontMetrics().elidedText( - m_activeText, Qt::ElideRight, textW); - p.drawText(QRectF(textX, 0, textW, height()), - Qt::AlignVCenter | Qt::AlignLeft, elidedText); - } - } - - - if (m_hasMultiple) { - p.setPen(m_arrowColor); - QFont arrowFont = font(); - arrowFont.setPointSizeF(9); - p.setFont(arrowFont); - p.drawText(arrowRect, Qt::AlignCenter, "⯆"); - } -} - - - -void SplitPlayerButton::mousePressEvent(QMouseEvent *e) { - if (e->button() == Qt::LeftButton) - m_pressedZone = zoneAt(e->pos()); - update(); -} - -void SplitPlayerButton::mouseReleaseEvent(QMouseEvent *e) { - if (e->button() == Qt::LeftButton) { - Zone released = zoneAt(e->pos()); - if (released == m_pressedZone) { - if (released == MainZone && !m_activePath.isEmpty()) { - emit playRequested(m_activePath); - } else if (released == ArrowZone) { - showPlayerMenu(); - } - } - } - m_pressedZone = None; - update(); -} - -void SplitPlayerButton::mouseMoveEvent(QMouseEvent *e) { - Zone newZone = zoneAt(e->pos()); - if (newZone != m_hoverZone) { - m_hoverZone = newZone; - update(); - } -} - -void SplitPlayerButton::enterEvent(QEnterEvent *) { -} - -void SplitPlayerButton::leaveEvent(QEvent *) { - m_hoverZone = None; - m_pressedZone = None; - update(); -} - -void SplitPlayerButton::showPlayerMenu() { - - m_menu->adjustSize(); - QPoint pos = mapToGlobal(QPoint(width() / 2, height() + 4)); - pos.setX(pos.x() - m_menu->sizeHint().width() / 2); - m_menu->exec(pos); -} diff --git a/src/qEmbyApp/components/splitplayerbutton.h b/src/qEmbyApp/components/splitplayerbutton.h deleted file mode 100644 index 121d1a6..0000000 --- a/src/qEmbyApp/components/splitplayerbutton.h +++ /dev/null @@ -1,93 +0,0 @@ -#ifndef SPLITPLAYERBUTTON_H -#define SPLITPLAYERBUTTON_H - -#include -#include -#include - -struct DetectedPlayer; - - - -class SplitPlayerButton : public QWidget { - Q_OBJECT - - - Q_PROPERTY(QColor hoverColor READ hoverColor WRITE setHoverColor) - Q_PROPERTY(QColor pressedColor READ pressedColor WRITE setPressedColor) - Q_PROPERTY(QColor separatorColor READ separatorColor WRITE setSeparatorColor) - Q_PROPERTY(QColor arrowColor READ arrowColor WRITE setArrowColor) - Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor) - Q_PROPERTY(int borderRadius READ borderRadius WRITE setBorderRadius) - -public: - explicit SplitPlayerButton(QWidget *parent = nullptr); - - - void setPlayers(const QList &players, const QString &activePlayerPath); - void clear(); - - - void setIconOnly(bool iconOnly); - bool isIconOnly() const { return m_iconOnly; } - - - QColor hoverColor() const { return m_hoverColor; } - QColor pressedColor() const { return m_pressedColor; } - QColor separatorColor() const { return m_separatorColor; } - QColor arrowColor() const { return m_arrowColor; } - QColor textColor() const { return m_textColor; } - int borderRadius() const { return m_borderRadius; } - - void setHoverColor (const QColor &c) { m_hoverColor = c; update(); } - void setPressedColor (const QColor &c) { m_pressedColor = c; update(); } - void setSeparatorColor(const QColor &c) { m_separatorColor = c; update(); } - void setArrowColor (const QColor &c) { m_arrowColor = c; update(); } - void setTextColor (const QColor &c) { m_textColor = c; update(); } - void setBorderRadius (int r) { m_borderRadius = r; update(); } - -signals: - - void playRequested(const QString &playerPath); - - - void playerSelected(const QString &playerPath); - -protected: - void paintEvent(QPaintEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void enterEvent(QEnterEvent *event) override; - void leaveEvent(QEvent *event) override; - QSize sizeHint() const override; - -private: - enum Zone { None, MainZone, ArrowZone }; - - Zone zoneAt(const QPoint &pos) const; - int arrowAreaWidth() const; - void showPlayerMenu(); - - - QIcon m_activeIcon; - QString m_activeText; - QString m_activePath; - bool m_hasMultiple = false; - - QMenu *m_menu; - - Zone m_hoverZone = None; - Zone m_pressedZone = None; - - - QColor m_hoverColor {255, 255, 255, 30}; - QColor m_pressedColor {255, 255, 255, 20}; - QColor m_separatorColor {255, 255, 255, 50}; - QColor m_arrowColor {255, 255, 255, 180}; - QColor m_textColor {255, 255, 255, 180}; - int m_borderRadius = 6; - bool m_iconOnly = false; -}; - -#endif diff --git a/src/qEmbyApp/components/themetransitionwidget.cpp b/src/qEmbyApp/components/themetransitionwidget.cpp deleted file mode 100644 index 0f77667..0000000 --- a/src/qEmbyApp/components/themetransitionwidget.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "themetransitionwidget.h" -#include -#include - -ThemeTransitionWidget::ThemeTransitionWidget(const QPixmap& oldSnapshot, const QPoint& center, QWidget* parent) - : QWidget(parent), m_oldSnapshot(oldSnapshot), m_center(center), m_radius(0) -{ - - setAttribute(Qt::WA_TransparentForMouseEvents); - - setAttribute(Qt::WA_TranslucentBackground); - setAttribute(Qt::WA_NoSystemBackground); -} - -int ThemeTransitionWidget::radius() const { return m_radius; } - -void ThemeTransitionWidget::setRadius(int r) { - m_radius = r; - update(); -} - -void ThemeTransitionWidget::paintEvent(QPaintEvent* event) { - Q_UNUSED(event); - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - - - QPainterPath path; - path.addRect(rect()); - path.addEllipse(m_center, m_radius, m_radius); - path.setFillRule(Qt::OddEvenFill); - - - painter.setClipPath(path); - - painter.drawPixmap(0, 0, m_oldSnapshot); -} diff --git a/src/qEmbyApp/components/themetransitionwidget.h b/src/qEmbyApp/components/themetransitionwidget.h deleted file mode 100644 index 1e6e0bb..0000000 --- a/src/qEmbyApp/components/themetransitionwidget.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifndef THEMETRANSITIONWIDGET_H -#define THEMETRANSITIONWIDGET_H - -#include -#include -#include - -class ThemeTransitionWidget : public QWidget { - Q_OBJECT - - Q_PROPERTY(int radius READ radius WRITE setRadius) - -public: - explicit ThemeTransitionWidget(const QPixmap& oldSnapshot, const QPoint& center, QWidget* parent = nullptr); - - int radius() const; - void setRadius(int r); - -protected: - void paintEvent(QPaintEvent* event) override; - -private: - QPixmap m_oldSnapshot; - QPoint m_center; - int m_radius; -}; - -#endif diff --git a/src/qEmbyApp/components/transcodingcodeclistwidget.cpp b/src/qEmbyApp/components/transcodingcodeclistwidget.cpp deleted file mode 100644 index 0abd3a9..0000000 --- a/src/qEmbyApp/components/transcodingcodeclistwidget.cpp +++ /dev/null @@ -1,305 +0,0 @@ -#include "transcodingcodeclistwidget.h" - -#include "transcodingcodecrowwidget.h" - -#include -#include -#include -#include -#include -#include "../tokens/AnimationTokens.h" -#include "../utils/motionprefs.h" - -namespace { - -void stopPosAnimations(TranscodingCodecRowWidget* row) { - const auto children = row->children(); - for (auto* child : children) { - if (auto* animation = qobject_cast(child)) { - if (animation->propertyName() == "pos") { - animation->stop(); - animation->deleteLater(); - } - } - } -} - -} - -TranscodingCodecListWidget::TranscodingCodecListWidget(QWidget* parent) : QWidget(parent) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - m_autoScrollTimer = new QTimer(this); - m_autoScrollTimer->setInterval(16); - connect(m_autoScrollTimer, &QTimer::timeout, this, [this]() { - if (!m_dragRow) { - 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_dragRow, m_lastDragGlobalPos); - } - }); -} - -void TranscodingCodecListWidget::setCodecRows( - const QList& rows) { - for (auto* row : m_rows) { - if (row && !rows.contains(row)) { - row->hide(); - row->deleteLater(); - } - } - - m_rows = rows; - for (auto* row : m_rows) { - if (!row) { - continue; - } - - row->setParent(this); - row->show(); - connect(row, &TranscodingCodecRowWidget::dragStarted, this, - &TranscodingCodecListWidget::onRowDragStart, Qt::UniqueConnection); - connect(row, &TranscodingCodecRowWidget::dragMoved, this, - &TranscodingCodecListWidget::onRowDragMove, Qt::UniqueConnection); - connect(row, &TranscodingCodecRowWidget::dragFinished, this, - &TranscodingCodecListWidget::onRowDragFinish, Qt::UniqueConnection); - } - - relayout(false); -} - -QStringList TranscodingCodecListWidget::codecOrder() const { - QStringList codecIds; - codecIds.reserve(m_rows.size()); - for (auto* row : m_rows) { - if (row) { - codecIds.append(row->codecId()); - } - } - return codecIds; -} - -void TranscodingCodecListWidget::onRowDragStart(TranscodingCodecRowWidget* row, - const QPoint& globalPos) { - if (!row) { - return; - } - - m_dragRow = row; - m_dragOriginalIndex = m_rows.indexOf(row); - if (m_dragOriginalIndex < 0) { - m_dragRow = nullptr; - return; - } - m_dragCurrentIndex = m_dragOriginalIndex; - m_lastDragGlobalPos = globalPos; - m_dragHotSpotY = row->mapFromGlobal(globalPos).y(); - - for (auto* currentRow : m_rows) { - if (currentRow) { - stopPosAnimations(currentRow); - } - } - - row->setDragging(true); - row->raise(); - m_autoScrollTimer->start(); -} - -void TranscodingCodecListWidget::onRowDragMove(TranscodingCodecRowWidget* row, - const QPoint& globalPos) { - if (!row || row != m_dragRow) { - return; - } - - m_lastDragGlobalPos = globalPos; - updateDragPosition(row, globalPos); -} - -void TranscodingCodecListWidget::onRowDragFinish(TranscodingCodecRowWidget* row) { - if (!row || row != m_dragRow) { - return; - } - - row->setDragging(false); - m_autoScrollTimer->stop(); - - const int finalIndex = qMax(0, m_dragCurrentIndex); - const bool changed = (m_dragOriginalIndex != m_dragCurrentIndex); - - m_dragRow = nullptr; - m_dragOriginalIndex = -1; - m_dragCurrentIndex = -1; - m_dragHotSpotY = 0; - - stopPosAnimations(row); - if (MotionPrefs::reduceMotion()) { - row->move(QPoint(0, rowTopForIndex(finalIndex))); - } else { - auto* animation = new QPropertyAnimation(row, "pos", row); - animation->setDuration(AnimationTokens::DurationMs::kFast); - animation->setEasingCurve(AnimationTokens::Curve::enter()); - animation->setEndValue(QPoint(0, rowTopForIndex(finalIndex))); - animation->start(QAbstractAnimation::DeleteWhenStopped); - } - - if (changed) { - emit orderChanged(codecOrder()); - } -} - -void TranscodingCodecListWidget::resizeEvent(QResizeEvent* event) { - QWidget::resizeEvent(event); - relayout(false); -} - -int TranscodingCodecListWidget::rowHeightFor(TranscodingCodecRowWidget* row) const { - return row ? row->sizeHint().height() : 0; -} - -int TranscodingCodecListWidget::rowTopForIndex(int index) const { - int top = 0; - for (int i = 0; i < index && i < m_rows.size(); ++i) { - top += rowHeightFor(m_rows.at(i)) + RowSpacing; - } - return top; -} - -int TranscodingCodecListWidget::indexForCenterY(int centerY) const { - QList orderedRows = m_rows; - orderedRows.removeOne(m_dragRow); - - int top = 0; - for (int index = 0; index < orderedRows.size(); ++index) { - const int rowHeight = rowHeightFor(orderedRows.at(index)); - if (centerY < top + rowHeight / 2) { - return index; - } - top += rowHeight + RowSpacing; - } - - return orderedRows.size(); -} - -QScrollArea* TranscodingCodecListWidget::parentScrollArea() const { - QWidget* current = parentWidget(); - while (current) { - if (auto* scrollArea = qobject_cast(current)) { - return scrollArea; - } - current = current->parentWidget(); - } - - return nullptr; -} - -int TranscodingCodecListWidget::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 TranscodingCodecListWidget::updateDragPosition(TranscodingCodecRowWidget* row, - const QPoint& globalPos) { - if (!row) { - return; - } - - const QPoint localPos = mapFromGlobal(globalPos); - const int rowY = localPos.y() - m_dragHotSpotY; - row->move(0, rowY); - - const int centerY = rowY + row->height() / 2; - const int targetIndex = indexForCenterY(centerY); - if (targetIndex == m_dragCurrentIndex) { - return; - } - - m_rows.removeOne(row); - m_rows.insert(qBound(0, targetIndex, m_rows.size()), row); - m_dragCurrentIndex = m_rows.indexOf(row); - relayout(true); -} - -void TranscodingCodecListWidget::relayout(bool animate) { - int top = 0; - const int contentWidth = qMax(0, width()); - - for (int index = 0; index < m_rows.size(); ++index) { - auto* row = m_rows.at(index); - if (!row) { - continue; - } - - const int rowHeight = rowHeightFor(row); - row->setFixedSize(contentWidth, rowHeight); - - if (row == m_dragRow) { - top += rowHeight + RowSpacing; - continue; - } - - const QPoint targetPos(0, top); - if (animate && !MotionPrefs::reduceMotion() && row->pos() != targetPos) { - stopPosAnimations(row); - - auto* animation = new QPropertyAnimation(row, "pos", row); - animation->setDuration(AnimationTokens::DurationMs::kNormal); - animation->setEasingCurve(AnimationTokens::Curve::enter()); - animation->setEndValue(targetPos); - animation->start(QAbstractAnimation::DeleteWhenStopped); - } else { - stopPosAnimations(row); - row->move(targetPos); - } - - top += rowHeight + RowSpacing; - } - - if (top > 0) { - top -= RowSpacing; - } - setFixedHeight(qMax(top, 0)); -} diff --git a/src/qEmbyApp/components/transcodingcodeclistwidget.h b/src/qEmbyApp/components/transcodingcodeclistwidget.h deleted file mode 100644 index 5f00d92..0000000 --- a/src/qEmbyApp/components/transcodingcodeclistwidget.h +++ /dev/null @@ -1,54 +0,0 @@ -#ifndef TRANSCODINGCODECLISTWIDGET_H -#define TRANSCODINGCODECLISTWIDGET_H - -#include -#include -#include -#include - -class QResizeEvent; -class QScrollArea; -class QTimer; -class TranscodingCodecRowWidget; - -class TranscodingCodecListWidget : public QWidget { - Q_OBJECT - -public: - explicit TranscodingCodecListWidget(QWidget* parent = nullptr); - - void setCodecRows(const QList& rows); - QStringList codecOrder() const; - -signals: - void orderChanged(const QStringList& codecIds); - -public slots: - void onRowDragStart(TranscodingCodecRowWidget* row, const QPoint& globalPos); - void onRowDragMove(TranscodingCodecRowWidget* row, const QPoint& globalPos); - void onRowDragFinish(TranscodingCodecRowWidget* row); - -protected: - void resizeEvent(QResizeEvent* event) override; - -private: - static constexpr int RowSpacing = 10; - - int rowHeightFor(TranscodingCodecRowWidget* row) const; - int rowTopForIndex(int index) const; - int indexForCenterY(int centerY) const; - QScrollArea* parentScrollArea() const; - int autoScrollDelta(const QPoint& globalPos) const; - void updateDragPosition(TranscodingCodecRowWidget* row, const QPoint& globalPos); - void relayout(bool animate = false); - - QList m_rows; - TranscodingCodecRowWidget* m_dragRow = nullptr; - int m_dragOriginalIndex = -1; - int m_dragCurrentIndex = -1; - int m_dragHotSpotY = 0; - QPoint m_lastDragGlobalPos; - QTimer* m_autoScrollTimer = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/components/transcodingcodecrowwidget.cpp b/src/qEmbyApp/components/transcodingcodecrowwidget.cpp deleted file mode 100644 index bf90ee8..0000000 --- a/src/qEmbyApp/components/transcodingcodecrowwidget.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "transcodingcodecrowwidget.h" - -#include "elidedlabel.h" -#include "../managers/thememanager.h" -#include "modernswitch.h" - -#include -#include -#include -#include -#include -#include -#include - -TranscodingCodecRowWidget::TranscodingCodecRowWidget(const QString& codecId, - const QString& title, - const QString& description, - const QString& infoToolTip, - const QString& dragToolTip, - QWidget* parent) - : QFrame(parent), m_codecId(codecId) { - setObjectName("TranscodingCodecItem"); - setAttribute(Qt::WA_StyledBackground, true); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - auto* rowLayout = new QHBoxLayout(this); - rowLayout->setContentsMargins(14, 12, 14, 12); - rowLayout->setSpacing(12); - - auto* textLayout = new QVBoxLayout(); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(3); - - m_titleLabel = new ElidedLabel(this); - m_titleLabel->setObjectName("TranscodingCodecName"); - m_titleLabel->setFullText(title); - textLayout->addWidget(m_titleLabel); - - m_descriptionLabel = new ElidedLabel(this); - m_descriptionLabel->setObjectName("TranscodingCodecDescription"); - m_descriptionLabel->setFullText(description); - m_descriptionLabel->setVisible(!description.trimmed().isEmpty()); - textLayout->addWidget(m_descriptionLabel); - - rowLayout->addLayout(textLayout, 1); - - auto* controlsLayout = new QHBoxLayout(); - controlsLayout->setContentsMargins(0, 0, 0, 0); - controlsLayout->setSpacing(8); - - m_infoButton = new QPushButton(this); - m_infoButton->setObjectName("TranscodingMiniButton"); - m_infoButton->setCursor(Qt::PointingHandCursor); - m_infoButton->setFixedSize(30, 30); - m_infoButton->setIconSize(QSize(16, 16)); - m_infoButton->setIcon(ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/player/info.svg"))); - m_infoButton->setToolTip(infoToolTip); - m_infoButton->setFocusPolicy(Qt::NoFocus); - controlsLayout->addWidget(m_infoButton, 0, Qt::AlignVCenter); - - m_dragHandle = new QPushButton(this); - m_dragHandle->setObjectName("TranscodingDragHandle"); - m_dragHandle->setCursor(Qt::OpenHandCursor); - m_dragHandle->setFixedSize(30, 30); - m_dragHandle->setToolTip(dragToolTip); - m_dragHandle->setFocusPolicy(Qt::NoFocus); - m_dragHandle->installEventFilter(this); - controlsLayout->addWidget(m_dragHandle, 0, Qt::AlignVCenter); - - m_switch = new ModernSwitch(this); - controlsLayout->addWidget(m_switch, 0, Qt::AlignVCenter); - - rowLayout->addLayout(controlsLayout, 0); - - connect(m_infoButton, &QPushButton::clicked, this, - [this]() { emit infoRequested(m_codecId); }); - connect(m_switch, &ModernSwitch::toggled, this, [this](bool enabled) { - emit enabledChanged(m_codecId, enabled); - }); - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() { - if (m_infoButton) { - m_infoButton->setIcon( - ThemeManager::getAdaptiveIcon(QStringLiteral(":/svg/player/info.svg"))); - } - }); -} - -bool TranscodingCodecRowWidget::isCodecEnabled() const { - return m_switch && m_switch->isChecked(); -} - -void TranscodingCodecRowWidget::setCodecEnabled(bool enabled) { - if (m_switch) { - m_switch->setChecked(enabled); - } -} - -void TranscodingCodecRowWidget::setDragging(bool dragging) { - setProperty("dragging", dragging); - style()->unpolish(this); - style()->polish(this); - update(); - - if (m_dragHandle) { - m_dragHandle->setCursor(dragging ? Qt::ClosedHandCursor : Qt::OpenHandCursor); - } -} - -QSize TranscodingCodecRowWidget::sizeHint() const { - return QSize(420, 68); -} - -bool TranscodingCodecRowWidget::eventFilter(QObject* watched, QEvent* event) { - if (watched != m_dragHandle) { - return QFrame::eventFilter(watched, event); - } - - switch (event->type()) { - case QEvent::MouseButtonPress: { - auto* mouseEvent = static_cast(event); - if (mouseEvent->button() != Qt::LeftButton) { - break; - } - - m_dragCandidate = true; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - m_dragStartPos = mouseEvent->globalPosition().toPoint(); -#else - m_dragStartPos = mouseEvent->globalPos(); -#endif - m_dragHandle->setCursor(Qt::ClosedHandCursor); - return true; - } - case QEvent::MouseMove: { - if (!m_dragCandidate) { - break; - } - - auto* mouseEvent = static_cast(event); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - const QPoint globalPos = mouseEvent->globalPosition().toPoint(); -#else - const QPoint globalPos = mouseEvent->globalPos(); -#endif - - if (!m_dragActive && - (globalPos - m_dragStartPos).manhattanLength() >= - QApplication::startDragDistance()) { - m_dragActive = true; - emit dragStarted(this, globalPos); - } - - if (m_dragActive) { - emit dragMoved(this, globalPos); - } - return true; - } - case QEvent::MouseButtonRelease: { - auto* mouseEvent = static_cast(event); - if (mouseEvent->button() != Qt::LeftButton) { - break; - } - - const bool hadActiveDrag = m_dragActive; - m_dragCandidate = false; - m_dragActive = false; - m_dragHandle->setCursor(Qt::OpenHandCursor); - - if (hadActiveDrag) { - emit dragFinished(this); - } - return true; - } - default: - break; - } - - return QFrame::eventFilter(watched, event); -} diff --git a/src/qEmbyApp/components/transcodingcodecrowwidget.h b/src/qEmbyApp/components/transcodingcodecrowwidget.h deleted file mode 100644 index ae28935..0000000 --- a/src/qEmbyApp/components/transcodingcodecrowwidget.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef TRANSCODINGCODECROWWIDGET_H -#define TRANSCODINGCODECROWWIDGET_H - -#include -#include -#include - -class ElidedLabel; -class ModernSwitch; -class QPushButton; - -class TranscodingCodecRowWidget : public QFrame { - Q_OBJECT - Q_PROPERTY(QPoint pos READ pos WRITE move) - -public: - explicit TranscodingCodecRowWidget(const QString& codecId, - const QString& title, - const QString& description, - const QString& infoToolTip, - const QString& dragToolTip, - QWidget* parent = nullptr); - - QString codecId() const { return m_codecId; } - bool isCodecEnabled() const; - void setCodecEnabled(bool enabled); - void setDragging(bool dragging); - QSize sizeHint() const override; - -signals: - void infoRequested(const QString& codecId); - void enabledChanged(const QString& codecId, bool enabled); - void dragStarted(TranscodingCodecRowWidget* row, const QPoint& globalPos); - void dragMoved(TranscodingCodecRowWidget* row, const QPoint& globalPos); - void dragFinished(TranscodingCodecRowWidget* row); - -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - -private: - QString m_codecId; - ElidedLabel* m_titleLabel = nullptr; - ElidedLabel* m_descriptionLabel = nullptr; - QPushButton* m_infoButton = nullptr; - QPushButton* m_dragHandle = nullptr; - ModernSwitch* m_switch = nullptr; - QPoint m_dragStartPos; - bool m_dragCandidate = false; - bool m_dragActive = false; -}; - -#endif diff --git a/src/qEmbyApp/main.cpp b/src/qEmbyApp/main.cpp index fcfa573..ff7612d 100644 --- a/src/qEmbyApp/main.cpp +++ b/src/qEmbyApp/main.cpp @@ -1,86 +1,138 @@ -#include "mainwindow.h" +#include "quick/appshellhost.h" +#include "quick/mpvitem.h" #include #include #include +#include #include +#include +#include +#include #include #include +#include #include + #include "api/proxymanager.h" #include "components/mpvcontroller.h" #include "managers/languagemanager.h" #include "managers/logmanager.h" +#include + namespace { -QSurfaceFormat createDefaultSurfaceFormat() { - QSurfaceFormat format; - format.setRenderableType(QSurfaceFormat::OpenGL); - format.setVersion(3, 3); - format.setProfile(QSurfaceFormat::CoreProfile); - format.setDepthBufferSize(24); - format.setStencilBufferSize(8); - format.setSwapInterval(1); - format.setSwapBehavior(QSurfaceFormat::DoubleBuffer); - return format; +QSurfaceFormat createDefaultSurfaceFormat() +{ + QSurfaceFormat format; + format.setRenderableType(QSurfaceFormat::OpenGL); + format.setVersion(3, 3); + format.setProfile(QSurfaceFormat::CoreProfile); + format.setDepthBufferSize(24); + format.setStencilBufferSize(8); + format.setSwapInterval(1); + format.setSwapBehavior(QSurfaceFormat::DoubleBuffer); + return format; } } // namespace -int main(int argc, char *argv[]) { +int main(int argc, char *argv[]) +{ #if defined(Q_OS_LINUX) - const QByteArray qpaPlatform = qgetenv("QT_QPA_PLATFORM"); - const QByteArray forceXcb = qgetenv("QEMBY_FORCE_XCB").toLower(); - if (qpaPlatform.isEmpty() && - (forceXcb == "1" || forceXcb == "true" || forceXcb == "yes")) { - qputenv("QT_QPA_PLATFORM", "xcb"); - } + const QByteArray qpaPlatform = qgetenv("QT_QPA_PLATFORM"); + const QByteArray forceXcb = qgetenv("QEMBY_FORCE_XCB").toLower(); + if (qpaPlatform.isEmpty() && + (forceXcb == "1" || forceXcb == "true" || forceXcb == "yes")) { + qputenv("QT_QPA_PLATFORM", "xcb"); + } #endif - QSurfaceFormat::setDefaultFormat(createDefaultSurfaceFormat()); + QSurfaceFormat::setDefaultFormat(createDefaultSurfaceFormat()); - QGuiApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); - QApplication a(argc, argv); - a.setApplicationName(APP_NAME); - a.setApplicationVersion(APP_VERSION); - a.setOrganizationName("AlanHJ"); - a.setOrganizationDomain("github.com/AlanHJ/qEmby"); + // MpvItem uses QQuickFramebufferObject and requires the OpenGL scene graph + // backend; lock it before constructing QApplication. + QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL); + + QGuiApplication::setAttribute(Qt::AA_DontCreateNativeWidgetSiblings); + // QApplication (not QGuiApplication): remaining Widget dialogs and tray + // still need it. Independent playback is now a secondary QML window. + QApplication a(argc, argv); + a.setApplicationName(APP_NAME); + a.setApplicationVersion(APP_VERSION); + a.setOrganizationName("AlanHJ"); + a.setOrganizationDomain("github.com/AlanHJ/qEmby"); #if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - QGuiApplication::setDesktopFileName(QStringLiteral("qemby")); - const QIcon appIcon(QStringLiteral(":/svg/qemby_logo.svg")); - a.setWindowIcon(appIcon); + QGuiApplication::setDesktopFileName(QStringLiteral("qemby")); + const QIcon appIcon(QStringLiteral(":/svg/qemby_logo.svg")); + a.setWindowIcon(appIcon); #endif - LogManager::instance()->init(); - LanguageManager::instance()->init(); + LogManager::instance()->init(); + LanguageManager::instance()->init(); - - - ProxyManager::installApplicationFactory(); + ProxyManager::installApplicationFactory(); - MainWindow w; -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - w.setWindowIcon(appIcon); -#endif - w.show(); + // Spike harness: QEMBY_MPV_SPIKE= loads only MpvItem verification. + const QByteArray spikeMedia = qgetenv("QEMBY_MPV_SPIKE"); + if (!spikeMedia.isEmpty()) { + QQmlApplicationEngine engine; + engine.loadFromModule("qEmby", "MpvSpikeHarness"); + if (engine.rootObjects().isEmpty()) { + qCritical() << "[spike] failed to load MpvSpikeHarness"; + return 1; + } + auto *win = qobject_cast(engine.rootObjects().constFirst()); + if (auto *page = win ? win->findChild(QStringLiteral("spikePage")) + : nullptr) { + const QString media = QString::fromLocal8Bit(spikeMedia); + page->setProperty("mediaUrl", media); + QTimer::singleShot(300, page, [page, media] { + auto *mpv = page->findChild(); + if (!mpv) { + qWarning() << "[spike] MpvItem not found"; + return; + } + mpv->loadMedia(media); + mpv->play(); - - - - - - - - - - - - - - MpvController::warmupOnce(); + const int seekTo = qEnvironmentVariableIntValue("QEMBY_MPV_SPIKE_SEEK"); + if (seekTo > 0) + QTimer::singleShot(2000, mpv, [mpv, seekTo] { mpv->seek(seekTo); }); + }); + } + const int grabDelay = qEnvironmentVariableIntValue("QEMBY_MPV_SPIKE_GRAB_MS"); + const QByteArray grabPath = qgetenv("QEMBY_MPV_SPIKE_GRAB"); + if (win && !grabPath.isEmpty()) { + QTimer::singleShot(grabDelay > 0 ? grabDelay : 3000, win, [win, grabPath] { + const QImage shot = win->grabWindow(); + if (shot.isNull()) + qWarning() << "[spike] grabWindow returned null"; + else if (!shot.save(QString::fromLocal8Bit(grabPath))) + qWarning() << "[spike] failed to save" << grabPath; + else + qInfo() << "[spike] saved frame to" << grabPath; + }); + } + const int quitMs = qEnvironmentVariableIntValue("QEMBY_MPV_SPIKE_QUIT_MS"); + if (quitMs > 0) + QTimer::singleShot(quitMs, &a, &QApplication::quit); + return a.exec(); + } - int ret = a.exec(); - - return ret; + // Full QML application shell only (Widgets MainWindow removed). + auto *core = new QEmbyCore(&a); + auto *shell = new AppShellHost(core, &a); + if (!shell->load()) { + qCritical() << "[qml-shell] failed to load AppShell"; + return 1; + } + MpvController::warmupOnce(); + const int rc = a.exec(); + // Destroy the QML engine only after the event loop has fully unwound + // (not from aboutToQuit inside Window.closeEvent). ViewModels still live + // as children of `shell` until `a` is destroyed below. + shell->shutdownUi(); + return rc; } diff --git a/src/qEmbyApp/mainwindow.cpp b/src/qEmbyApp/mainwindow.cpp deleted file mode 100644 index 10b3117..0000000 --- a/src/qEmbyApp/mainwindow.cpp +++ /dev/null @@ -1,1210 +0,0 @@ -#include "mainwindow.h" -#include "tokens/AnimationTokens.h" -#include -#include "views/public/loginview.h" -#include "views/media/playerview.h" -#include "views/user/homeview.h" -#include "components/searchcompleterpopup.h" -#include "components/searchhistorypopup.h" -#include "managers/thememanager.h" -#include "managers/searchhistorymanager.h" -#include "managers/traymanager.h" -#include "config/configstore.h" -#include "config/config_keys.h" -#include "utils/contextmenuutils.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "components/themetransitionwidget.h" -#include "components/moderntoast.h" -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -static inline void emulateLeaveEvent(QWidget *widget) { - Q_ASSERT(widget); - if (!widget) return; - QTimer::singleShot(0, widget, [widget]() { -#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)) - const QScreen *screen = widget->screen(); -#else - const QScreen *screen = widget->windowHandle()->screen(); -#endif - const QPoint globalPos = QCursor::pos(screen); - if (!QRect(widget->mapToGlobal(QPoint{0, 0}), widget->size()).contains(globalPos)) { - QCoreApplication::postEvent(widget, new QEvent(QEvent::Leave)); - if (widget->testAttribute(Qt::WA_Hover)) { - const QPoint localPos = widget->mapFromGlobal(globalPos); - const QPoint scenePos = widget->window()->mapFromGlobal(globalPos); - static constexpr const auto oldPos = QPoint{}; - const Qt::KeyboardModifiers modifiers = QGuiApplication::keyboardModifiers(); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) - const auto event = new QHoverEvent(QEvent::HoverLeave, scenePos, globalPos, oldPos, modifiers); - Q_UNUSED(localPos); -#elif (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) - const auto event = new QHoverEvent(QEvent::HoverLeave, localPos, globalPos, oldPos, modifiers); - Q_UNUSED(scenePos); -#else - const auto event = new QHoverEvent(QEvent::HoverLeave, localPos, oldPos, modifiers); - Q_UNUSED(scenePos); -#endif - QCoreApplication::postEvent(widget, event); - } - } - }); -} - -MainWindow::MainWindow(QWidget *parent) - : QMainWindow(parent) -{ - m_core = new QEmbyCore(this); - - - m_trayManager = new TrayManager(this); - connect(m_trayManager, &TrayManager::showMainRequested, this, [this]() { - showNormal(); - activateWindow(); - - - - if (m_wasPausedByTray) { - m_wasPausedByTray = false; - QTimer::singleShot(500, this, [this]() { - if (auto *player = m_homeView->activePlayerView()) { - player->resumePlayback(); - } - }); - } - }); - connect(m_trayManager, &TrayManager::quitRequested, this, - &MainWindow::requestApplicationQuit); - - - - m_backClickTimer.invalidate(); - -#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 - - auto agent = new QWK::WidgetWindowAgent(this); - agent->setup(this); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - agent->setWindowAttribute("no-system-buttons", false); -#endif - - QPalette pal = palette(); - pal.setColor(QPalette::Window, QColor(241, 245, 249)); - setPalette(pal); - setAutoFillBackground(true); - - - auto titleLabel = new QLabel(qApp->applicationName()); - titleLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); - titleLabel->setObjectName(QStringLiteral("win-title-label")); - titleLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - titleLabel->setContentsMargins(0, 5, 0, 0); - - auto iconButton = new QWK::WindowButton(); - iconButton->setObjectName(QStringLiteral("icon-button")); - iconButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - iconButton->setIconNormal(qApp->windowIcon()); - iconButton->setIconSize(QSize(16, 16)); - - auto themeButton = new QWK::WindowButton(); - themeButton->setCheckable(true); - themeButton->setObjectName(QStringLiteral("theme-button")); - themeButton->setProperty("system-button", true); - themeButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - themeButton->setIconNormal(QIcon(":/svg/moon.svg")); - themeButton->setIconSize(QSize(15, 15)); - themeButton->setChecked(true); - - auto minButton = new QWK::WindowButton(); - minButton->setObjectName(QStringLiteral("min-button")); - minButton->setProperty("system-button", true); - minButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - - auto maxButton = new QWK::WindowButton(); - maxButton->setCheckable(true); - maxButton->setObjectName(QStringLiteral("max-button")); - maxButton->setProperty("system-button", true); - maxButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - - auto closeButton = new QWK::WindowButton(); - closeButton->setObjectName(QStringLiteral("close-button")); - closeButton->setProperty("system-button", true); - closeButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - - auto backButton = new QWK::WindowButton(); - backButton->setObjectName(QStringLiteral("back-button")); - backButton->setProperty("system-button", true); - backButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - backButton->setShortcut(QKeySequence::Back); - backButton->hide(); - - auto homeButton = new QWK::WindowButton(); - homeButton->setObjectName(QStringLiteral("home-button")); - homeButton->setProperty("system-button", true); - homeButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - homeButton->hide(); - - auto favButton = new QWK::WindowButton(); - favButton->setObjectName(QStringLiteral("fav-button")); - favButton->setProperty("system-button", true); - favButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - favButton->hide(); - - - - - connect(backButton, &QWK::WindowButton::clicked, this, [this]() { - if (m_viewStack->currentWidget() == m_homeView) { - if (m_homeView->canNavigateBack()) { - - m_homeView->navigateBack(); - m_backClickTimer.invalidate(); - } else if (m_homeView->canGoHome()) { - - m_homeView->goHome(); - m_backClickTimer.invalidate(); - } else { - - if (m_backClickTimer.isValid() && m_backClickTimer.elapsed() < 2000) { - - m_backClickTimer.invalidate(); - navigateToLogin(); - } else { - - m_backClickTimer.start(); - ModernToast::showMessage(tr("Press Back again to logout")); - } - } - } - }); - - connect(homeButton, &QWK::WindowButton::clicked, this, [this]() { - if (m_viewStack->currentWidget() == m_homeView) { - - if (!m_homeView->canGoHome()) { - ModernToast::showMessage(tr("Refreshing Home..."), 1000); - } - m_homeView->goHome(); - } - }); - - connect(favButton, &QWK::WindowButton::clicked, this, [this]() { - if (m_viewStack->currentWidget() == m_homeView) { - - if (!m_homeView->canGoFav()) { - ModernToast::showMessage(tr("Refreshing Favorites..."), 1000); - } - m_homeView->goFav(); - } - }); - - auto windowBar = new QWK::WindowBar(); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - windowBar->layout()->setContentsMargins(80, 0, 0, 0); -#endif - windowBar->setIconButton(iconButton); - windowBar->setPinButton(themeButton); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - windowBar->setMinButton(minButton); - windowBar->setMaxButton(maxButton); - windowBar->setCloseButton(closeButton); -#else - minButton->setParent(windowBar); - maxButton->setParent(windowBar); - closeButton->setParent(windowBar); - minButton->hide(); - maxButton->hide(); - closeButton->hide(); -#endif - windowBar->setTitleLabel(titleLabel); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - windowBar->setBackButton(backButton); - windowBar->setHomeButton(homeButton); - windowBar->setFavButton(favButton); -#endif - titleLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - windowBar->setHostWidget(this); - - auto *centerContainer = new QWidget(windowBar); - auto *centerLayout = new QHBoxLayout(centerContainer); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - centerLayout->setContentsMargins(0, 1, 0, 1); -#else - centerLayout->setContentsMargins(0, 0, 0, 0); -#endif - centerLayout->setSpacing(0); - - - auto *globalSearchBox = new QLineEdit(centerContainer); - m_globalSearchBox = globalSearchBox; - globalSearchBox->setObjectName("titlebar-search"); - globalSearchBox->setPlaceholderText(tr("Search...")); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - globalSearchBox->setFixedSize(380, 30); -#else - globalSearchBox->setFixedSize(380, 32); -#endif - globalSearchBox->setClearButtonEnabled(true); - - auto *searchAction = new QAction(QIcon(":/svg/light/search.svg"), tr("Search"), this); - globalSearchBox->addAction(searchAction, QLineEdit::LeadingPosition); - globalSearchBox->hide(); - - auto *macCenteredTitleLabel = new QLabel(centerContainer); - macCenteredTitleLabel->setObjectName("mac-title-label"); - macCenteredTitleLabel->setAlignment(Qt::AlignCenter); - macCenteredTitleLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); - macCenteredTitleLabel->hide(); - - connect(globalSearchBox, &QLineEdit::returnPressed, this, [this]() { - if (!m_globalSearchBox) { - qWarning() << "Global search submit ignored: search box was destroyed"; - return; - } - submitGlobalSearch(m_globalSearchBox->text()); - }); - -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - auto *macTitlebarNavSpacer = new QWidget(centerContainer); - macTitlebarNavSpacer->setObjectName("mac-titlebar-nav-spacer"); - macTitlebarNavSpacer->setFixedWidth(150); - macTitlebarNavSpacer->hide(); - - auto *macTitlebarNav = new QWidget(centerContainer); - macTitlebarNav->setObjectName("mac-titlebar-nav"); - auto *macTitlebarNavLayout = new QHBoxLayout(macTitlebarNav); - macTitlebarNavLayout->setContentsMargins(0, 0, 0, 0); - macTitlebarNavLayout->setSpacing(0); - macTitlebarNavLayout->addWidget(backButton); - macTitlebarNavLayout->addWidget(homeButton); - macTitlebarNavLayout->addWidget(favButton); - macTitlebarNav->setFixedWidth(150); - macTitlebarNav->hide(); - - centerLayout->addWidget(macTitlebarNavSpacer); -#endif - centerLayout->addStretch(); - centerLayout->addWidget(globalSearchBox, 0, Qt::AlignVCenter); - centerLayout->addWidget(macCenteredTitleLabel, 0, Qt::AlignVCenter); - centerLayout->addStretch(); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - centerLayout->addWidget(macTitlebarNav); -#endif - centerContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - windowBar->setCenterWidget(centerContainer); - - agent->setTitleBar(windowBar); - - agent->setHitTestVisible(themeButton, true); - agent->setHitTestVisible(backButton, true); - agent->setHitTestVisible(homeButton, true); - agent->setHitTestVisible(favButton, true); - agent->setHitTestVisible(globalSearchBox, true); - agent->setSystemButton(QWK::WindowAgentBase::WindowIcon, iconButton); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - agent->setSystemButton(QWK::WindowAgentBase::Minimize, minButton); - agent->setSystemButton(QWK::WindowAgentBase::Maximize, maxButton); - agent->setSystemButton(QWK::WindowAgentBase::Close, closeButton); -#endif -#if defined(Q_OS_MAC) - agent->setSystemButtonAreaCallback([](const QSize &size) { - return QRect(QPoint(0, 0), QSize(80, size.height())); - }); -#endif - - auto *mainContainer = new QWidget(this); - mainContainer->setObjectName(QStringLiteral("main-window-content")); - auto *mainLayout = new QVBoxLayout(mainContainer); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - mainLayout->addWidget(windowBar, 0); - - m_viewStack = new QStackedWidget(mainContainer); - m_viewStack->setObjectName("main-view-stack"); - m_viewStack->setPalette(pal); - m_viewStack->setAutoFillBackground(true); - mainLayout->addWidget(m_viewStack, 1); - setCentralWidget(mainContainer); - - m_loginView = new LoginView(m_core, this); - m_homeView = new HomeView(m_core, this); - setupGlobalSearchHistory(); - - m_viewStack->addWidget(m_loginView); - m_viewStack->addWidget(m_homeView); - - - connect(m_viewStack, &QStackedWidget::currentChanged, this, [this](int index) { - QWidget *currentView = m_viewStack->widget(index); - if (!currentView) return; - - bool showSearch = currentView->property("showGlobalSearch").toBool(); - if (m_globalSearchBox) { - m_globalSearchBox->setVisible(showSearch); - } else { - qWarning() << "Global search box missing during view change" - << "index:" << index - << "view:" << currentView; - } - if (!showSearch) { - hideGlobalSearchTransientUi(); - } - - auto back = findChild("back-button"); - auto icon = findChild("icon-button"); - auto home = findChild("home-button"); - auto fav = findChild("fav-button"); - auto title = findChild("win-title-label"); - auto macTitle = findChild("mac-title-label"); - auto macNav = findChild("mac-titlebar-nav"); - auto macNavSpacer = findChild("mac-titlebar-nav-spacer"); - - - if (currentView == m_homeView) { - if (back) { - back->setVisible(true); - - back->setEnabled(true); - } - if (icon) icon->setVisible(false); - if (home) - { - home->setVisible(true); - - home->setEnabled(true); - } - if (fav) - { - fav->setVisible(true); - - fav->setEnabled(true); - } - } else { - - bool showBack = currentView->property("showGlobalBack").toBool(); - if (back) { - back->setVisible(showBack); - back->setEnabled(true); - } - if (icon) icon->setVisible(!showBack); - bool showHome = currentView->property("showGlobalHome").toBool(); - if (home) - { - home->setVisible(showHome); - home->setEnabled(true); - } - bool showFav = currentView->property("showGlobalFav").toBool(); - if (fav) - { - fav->setVisible(showFav); - fav->setEnabled(true); - } - } - - QVariant customTitle = currentView->property("viewTitle"); - if (customTitle.isValid()) { - if (title) { - title->setText(customTitle.toString()); - } - if (macTitle) { - macTitle->setText(customTitle.toString()); - } - } - -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - const bool useCenteredLoginTitle = (currentView == m_loginView); - const bool showMacTitlebarNav = (currentView == m_homeView); - if (icon) { - icon->setVisible(false); - } - if (title) { - title->setVisible(false); - } - if (macTitle) { - macTitle->setVisible(useCenteredLoginTitle); - } - if (macNav) { - macNav->setVisible(showMacTitlebarNav); - } - if (macNavSpacer) { - macNavSpacer->setVisible(showMacTitlebarNav); - } -#else - if (macTitle) { - macTitle->hide(); - } -#endif - }); - - - connect(m_homeView, &HomeView::immersiveStateChanged, this, [windowBar, agent](bool isImmersive) { - windowBar->setVisible(!isImmersive); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - if (!isImmersive) { - agent->setWindowAttribute("no-system-buttons", false); - } -#else - Q_UNUSED(agent); -#endif - }); - - connect(m_homeView, &HomeView::playerChromeVisibilityChanged, this, - [this, agent](bool visible) { -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - if (m_homeView && m_homeView->activePlayerView()) { - agent->setWindowAttribute("no-system-buttons", !visible); - } -#else - Q_UNUSED(visible); - Q_UNUSED(agent); -#endif - }); - - - connect(m_homeView, &HomeView::canNavigateBackChanged, this, [this](bool canBack) { - - - - - - }); - - connect(m_homeView, &HomeView::homeContentSwitched, this, [this] () { - - - - - - - - }); - - connect(m_loginView, &LoginView::loginCompleted, this, &MainWindow::navigateToHome); - connect(m_homeView, &HomeView::logoutRequested, this, &MainWindow::navigateToLogin); - - - connect(windowBar, &QWK::WindowBar::pinRequested, this, [this, themeButton](bool checked) { - themeButton->setChecked(checked); - - hideGlobalSearchTransientUi(); - - - QString newMode = checked ? "dark" : "light"; - - themeButton->blockSignals(true); - if (checked) { - themeButton->setIconNormal(QIcon(":/svg/moon.svg")); - } else { - themeButton->setIconNormal(QIcon(":/svg/sun.svg")); - } - themeButton->blockSignals(false); - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (reduceAnimations) { - m_themeAnimating = true; - ConfigStore::instance()->set(ConfigKeys::ThemeMode, newMode); - m_themeAnimating = false; - return; - } - - - QPixmap snapshot = this->grab(); - - - QPoint center = themeButton->mapTo(this, themeButton->rect().center()); - - - auto transition = new ThemeTransitionWidget(snapshot, center, this); - transition->setGeometry(this->rect()); - transition->raise(); - transition->show(); - - - int maxRadius = qMax( - qMax(QLineF(center, QPoint(0, 0)).length(), QLineF(center, QPoint(width(), 0)).length()), - qMax(QLineF(center, QPoint(0, height())).length(), QLineF(center, QPoint(width(), height())).length()) - ) + 10; - - - m_themeAnimating = true; - ConfigStore::instance()->set(ConfigKeys::ThemeMode, newMode); - m_themeAnimating = false; - - - auto anim = new QPropertyAnimation(transition, "radius", transition); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - anim->setStartValue(0); - anim->setEndValue(maxRadius); - - anim->setEasingCurve(AnimationTokens::Curve::enter()); - - - connect(anim, &QPropertyAnimation::finished, transition, &QObject::deleteLater); - anim->start(QAbstractAnimation::DeleteWhenStopped); - }); - - connect(windowBar, &QWK::WindowBar::minimizeRequested, this, [this]() { - hideGlobalSearchTransientUi(); - showMinimized(); - }); - connect(windowBar, &QWK::WindowBar::maximizeRequested, this, [this, maxButton](bool max) { - if (max) { - showMaximized(); - } else { - showNormal(); - - QRect screenGeo = screen()->availableGeometry(); - int w = 1280, h = 800; - setGeometry( - screenGeo.x() + (screenGeo.width() - w) / 2, - screenGeo.y() + (screenGeo.height() - h) / 2, - w, h); - } - emulateLeaveEvent(maxButton); - }); - - - connect(windowBar, &QWK::WindowBar::closeRequested, this, &QWidget::close); - - - QString savedThemeMode = ConfigStore::instance()->get(ConfigKeys::ThemeMode, "system"); - ThemeManager::instance()->applyThemeMode(savedThemeMode); - - bool isDark = ThemeManager::instance()->isDarkMode(); - themeButton->setChecked(isDark); - themeButton->setIconNormal(isDark ? QIcon(":/svg/moon.svg") : QIcon(":/svg/sun.svg")); - - - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [this, themeButton](const QString &key, const QVariant &newValue) { - - if (key == ConfigKeys::FontSize) { - - ThemeManager::instance()->setTheme(ThemeManager::instance()->currentTheme()); - return; - } - - - if (key != ConfigKeys::ThemeMode) return; - QString mode = newValue.toString(); - hideGlobalSearchTransientUi(); - - - if (m_themeAnimating) { - ThemeManager::instance()->applyThemeMode(mode); - bool dark = ThemeManager::instance()->isDarkMode(); - themeButton->blockSignals(true); - themeButton->setChecked(dark); - themeButton->setIconNormal(dark ? QIcon(":/svg/moon.svg") : QIcon(":/svg/sun.svg")); - themeButton->blockSignals(false); - return; - } - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (!reduceAnimations) { - - QPixmap snapshot = this->grab(); - QPoint center = this->rect().center(); - - - ThemeManager::instance()->applyThemeMode(mode); - bool dark = ThemeManager::instance()->isDarkMode(); - themeButton->blockSignals(true); - themeButton->setChecked(dark); - themeButton->setIconNormal(dark ? QIcon(":/svg/moon.svg") : QIcon(":/svg/sun.svg")); - themeButton->blockSignals(false); - - - auto transition = new ThemeTransitionWidget(snapshot, center, this); - transition->setGeometry(this->rect()); - transition->raise(); - transition->show(); - - int maxRadius = qMax( - qMax(QLineF(center, QPoint(0, 0)).length(), QLineF(center, QPoint(width(), 0)).length()), - qMax(QLineF(center, QPoint(0, height())).length(), QLineF(center, QPoint(width(), height())).length()) - ) + 10; - - - auto anim = new QPropertyAnimation(transition, "radius", transition); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - anim->setStartValue(0); - anim->setEndValue(maxRadius); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - connect(anim, &QPropertyAnimation::finished, transition, &QObject::deleteLater); - anim->start(QAbstractAnimation::DeleteWhenStopped); - } else { - - ThemeManager::instance()->applyThemeMode(mode); - bool dark = ThemeManager::instance()->isDarkMode(); - themeButton->blockSignals(true); - themeButton->setChecked(dark); - themeButton->setIconNormal(dark ? QIcon(":/svg/moon.svg") : QIcon(":/svg/sun.svg")); - themeButton->blockSignals(false); - } - }); - - - minButton->hide(); - maxButton->hide(); - m_viewStack->setCurrentWidget(m_loginView); -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - iconButton->hide(); - titleLabel->hide(); - macCenteredTitleLabel->setText(m_loginView->property("viewTitle").toString()); - macCenteredTitleLabel->show(); - if (macTitlebarNav) { - macTitlebarNav->hide(); - } - if (macTitlebarNavSpacer) { - macTitlebarNavSpacer->hide(); - } -#endif - setMinimumSize(900, 600); - setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); - const QString startupWindowState = ConfigStore::instance()->get( - ConfigKeys::StartupWindowState, "normal"); - if (startupWindowState == "maximized") { - setWindowState(windowState() | Qt::WindowMaximized); - } else { - resize(m_defaultWidth, m_defaultHeight); - } - - - - - qApp->installEventFilter(this); - connect(qApp, &QGuiApplication::applicationStateChanged, this, - [this](Qt::ApplicationState state) { - if (state != Qt::ApplicationActive) { - hideGlobalSearchTransientUi(); - } - }); -} - -MainWindow::~MainWindow() {} - -void MainWindow::setupGlobalSearchHistory() -{ - if (!m_globalSearchBox) { - return; - } - - m_globalSearchModel = new QStringListModel(this); - m_globalSearchCompleter = new QCompleter(m_globalSearchModel, this); - m_globalSearchCompleter->setCaseSensitivity(Qt::CaseInsensitive); - m_globalSearchCompleter->setFilterMode(Qt::MatchContains); - m_globalSearchCompleter->setCompletionMode(QCompleter::PopupCompletion); - m_globalSearchCompleter->setMaxVisibleItems(8); - m_globalSearchCompleter->setPopup(new SearchCompleterPopup()); - if (auto *popup = qobject_cast( - m_globalSearchCompleter->popup())) { - popup->setMaxVisibleRows(m_globalSearchCompleter->maxVisibleItems()); - } - m_globalSearchBox->setCompleter(m_globalSearchCompleter); - - m_globalSearchHistoryPopup = new SearchHistoryPopup(this); - connect(m_globalSearchHistoryPopup, &SearchHistoryPopup::termActivated, this, - [this](const QString &term) { - if (!m_globalSearchBox) { - qWarning() << "History term ignored: search box was destroyed"; - return; - } - m_globalSearchBox->setText(term); - submitGlobalSearch(term); - }); - connect(m_globalSearchHistoryPopup, &SearchHistoryPopup::clearHistoryRequested, this, - [this]() { - SearchHistoryManager::instance()->clearHistory(currentSearchServerId()); - }); - connect(m_globalSearchHistoryPopup, &SearchHistoryPopup::removeHistoryTermRequested, - this, [this](const QString &term) { - SearchHistoryManager::instance()->removeHistoryTerm( - currentSearchServerId(), term); - }); - - connect(m_globalSearchBox.data(), &QLineEdit::textEdited, this, - [this](const QString &text) { - updateGlobalSearchCompleter(text); - hideGlobalSearchTransientUi(); - }); - - connect(SearchHistoryManager::instance(), &SearchHistoryManager::historyChanged, - this, [this](const QString &serverId) { - if (serverId == currentSearchServerId()) { - updateGlobalSearchCompleter(m_globalSearchBox - ? m_globalSearchBox->text() - : QString()); - } - }); - connect(SearchHistoryManager::instance(), &SearchHistoryManager::enabledChanged, - this, [this](bool enabled) { - Q_UNUSED(enabled); - updateGlobalSearchCompleter(m_globalSearchBox - ? m_globalSearchBox->text() - : QString()); - hideGlobalSearchTransientUi(); - }); - connect(SearchHistoryManager::instance(), - &SearchHistoryManager::autocompleteEnabledChanged, this, - [this](bool ) { - updateGlobalSearchCompleter(m_globalSearchBox - ? m_globalSearchBox->text() - : QString()); - }); - - if (m_core && m_core->serverManager()) { - connect(m_core->serverManager(), &ServerManager::activeServerChanged, this, - [this](const ServerProfile &profile) { - Q_UNUSED(profile); - updateGlobalSearchCompleter(m_globalSearchBox - ? m_globalSearchBox->text() - : QString()); - hideGlobalSearchTransientUi(); - }); - } - - updateGlobalSearchCompleter(); -} - -void MainWindow::hideGlobalSearchTransientUi() -{ - if (m_globalSearchHistoryPopup) { - const bool immediateHide = - QGuiApplication::applicationState() != Qt::ApplicationActive || - !isVisible() || isMinimized() || !isActiveWindow(); - m_globalSearchHistoryPopup->dismiss(immediateHide); - } - if (m_globalSearchCompleter && m_globalSearchCompleter->popup()) { - m_globalSearchCompleter->popup()->hide(); - } -} - -void MainWindow::updateGlobalSearchCompleter(const QString &text) -{ - if (!m_globalSearchModel || !m_globalSearchCompleter || !m_globalSearchBox) { - return; - } - - const QStringList suggestions = - SearchHistoryManager::instance()->completionSuggestions( - currentSearchServerId(), text, 8); - m_globalSearchModel->setStringList(suggestions); - - if (!SearchHistoryManager::instance()->isAutocompleteEnabled() || - text.trimmed().isEmpty() || - suggestions.isEmpty() || !m_globalSearchBox->hasFocus()) { - if (m_globalSearchCompleter->popup()) { - m_globalSearchCompleter->popup()->hide(); - } - return; - } - - if (auto *popup = qobject_cast( - m_globalSearchCompleter->popup())) { - popup->setHighlightText(text); - popup->syncWidthToAnchor(m_globalSearchBox.data()); - } - - m_globalSearchCompleter->setCompletionPrefix(text); - if (auto *popup = qobject_cast( - m_globalSearchCompleter->popup())) { - m_globalSearchCompleter->complete( - popup->popupRectForAnchor(m_globalSearchBox.data())); - return; - } - - m_globalSearchCompleter->complete(); -} - -void MainWindow::showGlobalSearchHistoryPopup(const QString &filterText) -{ - if (!m_globalSearchBox || !m_globalSearchHistoryPopup || - !SearchHistoryManager::instance()->isEnabled()) { - return; - } - - if (!isVisible() || isMinimized() || !m_globalSearchBox->isVisible() || - !m_globalSearchBox->hasFocus() || - QGuiApplication::applicationState() != Qt::ApplicationActive || - !isActiveWindow()) { - hideGlobalSearchTransientUi(); - return; - } - - const QString serverId = currentSearchServerId(); - const auto historyEntries = - SearchHistoryManager::instance()->historyEntries(serverId, filterText); - - m_globalSearchHistoryPopup->setEntries(historyEntries); - if (!m_globalSearchHistoryPopup->hasContent()) { - hideGlobalSearchTransientUi(); - return; - } - - m_globalSearchHistoryPopup->dismiss(true); - if (m_globalSearchCompleter && m_globalSearchCompleter->popup()) { - m_globalSearchCompleter->popup()->hide(); - } - m_globalSearchHistoryPopup->showBelow(m_globalSearchBox.data()); -} - -void MainWindow::submitGlobalSearch(const QString &query) -{ - if (!m_homeView || m_viewStack->currentWidget() != m_homeView) { - return; - } - - const QString trimmedQuery = query.trimmed(); - if (trimmedQuery.isEmpty()) { - return; - } - - hideGlobalSearchTransientUi(); - - m_homeView->triggerSearch(trimmedQuery); - if (m_globalSearchBox) { - m_globalSearchBox->clear(); - } - hideGlobalSearchTransientUi(); -} - -QString MainWindow::currentSearchServerId() const -{ - if (!m_core || !m_core->serverManager()) { - return {}; - } - return m_core->serverManager()->activeProfile().id; -} - -void MainWindow::navigateToHome() -{ - if (m_viewTransitionInProgress) { - qWarning() << "navigateToHome ignored: view transition already in progress"; - return; - } - if (m_viewStack && m_viewStack->currentWidget() == m_homeView) { - return; - } - m_viewTransitionInProgress = true; - m_homeView->beginAuthenticatedSession(); - - - m_homeView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (reduceAnimations) { - setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); - setMinimumSize(900, 600); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - if (auto* minBtn = findChild("min-button")) minBtn->show(); - if (auto* maxBtn = findChild("max-button")) maxBtn->show(); -#endif - - m_loginView->setGraphicsEffect(nullptr); - m_homeView->setGraphicsEffect(nullptr); - m_viewStack->setCurrentWidget(m_homeView); - m_viewTransitionInProgress = false; - return; - } - - setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); - setMinimumSize(900, 600); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - if (auto* minBtn = findChild("min-button")) minBtn->show(); - if (auto* maxBtn = findChild("max-button")) maxBtn->show(); -#endif - - m_loginView->setGraphicsEffect(nullptr); - auto *homeOpacity = new QGraphicsOpacityEffect(m_homeView); - homeOpacity->setOpacity(0.0); - m_homeView->setGraphicsEffect(homeOpacity); - m_viewStack->setCurrentWidget(m_homeView); - - auto *fadeIn = new QPropertyAnimation(homeOpacity, "opacity", this); - fadeIn->setDuration(AnimationTokens::DurationMs::kNormal); - fadeIn->setStartValue(0.0); - fadeIn->setEndValue(1.0); - fadeIn->setEasingCurve(AnimationTokens::Curve::fade()); - - connect(fadeIn, &QPropertyAnimation::finished, this, [this]() { - m_homeView->setGraphicsEffect(nullptr); - m_viewTransitionInProgress = false; - }); - fadeIn->start(QAbstractAnimation::DeleteWhenStopped); -} - -void MainWindow::navigateToLogin() { - if (m_viewTransitionInProgress) { - qWarning() << "navigateToLogin ignored: view transition already in progress"; - return; - } - if (m_viewStack && m_viewStack->currentWidget() == m_loginView) { - return; - } - m_viewTransitionInProgress = true; - m_homeView->clearAuthenticatedSession(); - - - - m_homeView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (reduceAnimations) { -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - if (auto* m = findChild("min-button")) m->hide(); - if (auto* x = findChild("max-button")) x->hide(); -#endif - - setMinimumSize(900, 600); - setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); - - m_homeView->setGraphicsEffect(nullptr); - m_loginView->setGraphicsEffect(nullptr); - m_viewStack->setCurrentWidget(m_loginView); - - m_core->authService()->logout(); - m_viewTransitionInProgress = false; - return; - } - - setMinimumSize(900, 600); - setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); -#if !defined(Q_OS_MACOS) && !defined(Q_OS_MAC) - if (auto* m = findChild("min-button")) m->hide(); - if (auto* x = findChild("max-button")) x->hide(); -#endif - - m_homeView->setGraphicsEffect(nullptr); - auto *loginOpacity = new QGraphicsOpacityEffect(m_loginView); - loginOpacity->setOpacity(0.0); - m_loginView->setGraphicsEffect(loginOpacity); - m_viewStack->setCurrentWidget(m_loginView); - - auto *fadeIn = new QPropertyAnimation(loginOpacity, "opacity", this); - fadeIn->setDuration(AnimationTokens::DurationMs::kNormal); - fadeIn->setStartValue(0.0); - fadeIn->setEndValue(1.0); - fadeIn->setEasingCurve(AnimationTokens::Curve::fade()); - - connect(fadeIn, &QPropertyAnimation::finished, this, [this]() { - m_loginView->setGraphicsEffect(nullptr); - m_core->authService()->logout(); - m_viewTransitionInProgress = false; - }); - fadeIn->start(QAbstractAnimation::DeleteWhenStopped); -} - -void MainWindow::requestApplicationQuit() -{ - if (m_quitPending) { - return; - } - - m_quitPending = true; - m_realQuit = true; - const bool stoppedPlayback = stopActivePlaybackForQuit(); - if (stoppedPlayback) { - QTimer::singleShot(500, this, &MainWindow::finishApplicationQuit); - return; - } - - finishApplicationQuit(); -} - -bool MainWindow::stopActivePlaybackForQuit() -{ - if (!m_homeView) { - return false; - } - - auto *player = m_homeView->activePlayerView(); - if (!player) { - return false; - } - - player->stopAndReport(); - return true; -} - -void MainWindow::finishApplicationQuit() -{ - if (m_core && m_core->authService()) { - m_core->authService()->logout(); - } - qApp->quit(); -} - -bool MainWindow::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == qApp && event->type() == QEvent::ApplicationStateChange) { - if (QGuiApplication::applicationState() != Qt::ApplicationActive) { - hideGlobalSearchTransientUi(); - } - } - - if (watched == this) { - if (event->type() == QEvent::WindowDeactivate || - event->type() == QEvent::Move || - event->type() == QEvent::Resize || - event->type() == QEvent::Hide || - event->type() == QEvent::Close || - event->type() == QEvent::WindowStateChange) { - hideGlobalSearchTransientUi(); - } - } - - if (watched == m_globalSearchHistoryPopup && m_globalSearchHistoryPopup) { - if (event->type() == QEvent::WindowDeactivate || - event->type() == QEvent::Hide || - event->type() == QEvent::Close) { - hideGlobalSearchTransientUi(); - } - } - - if (m_globalSearchBox && watched == m_globalSearchBox.data()) { - if (event->type() == QEvent::FocusIn) { - auto *focusEvent = static_cast(event); - if (focusEvent->reason() != Qt::MouseFocusReason && - m_globalSearchBox->text().trimmed().isEmpty()) { - QTimer::singleShot(0, this, [this]() { - showGlobalSearchHistoryPopup(); - }); - } - } else if (event->type() == QEvent::MouseButtonPress) { - if (m_globalSearchBox->text().trimmed().isEmpty()) { - QTimer::singleShot(0, this, [this]() { - showGlobalSearchHistoryPopup(); - }); - } - } else if (event->type() == QEvent::KeyPress) { - auto *keyEvent = static_cast(event); - if (keyEvent->key() == Qt::Key_Down) { - showGlobalSearchHistoryPopup(m_globalSearchBox->text()); - return true; - } - } else if (event->type() == QEvent::Hide) { - hideGlobalSearchTransientUi(); - } - } - - if ((event->type() == QEvent::MouseButtonPress || - event->type() == QEvent::MouseButtonDblClick) && - m_globalSearchHistoryPopup && m_globalSearchHistoryPopup->isVisible() && - m_globalSearchBox) { - auto *mouseEvent = static_cast(event); - const QPoint globalPos = mouseEvent->globalPosition().toPoint(); - const QRect popupRect(m_globalSearchHistoryPopup->mapToGlobal(QPoint(0, 0)), - m_globalSearchHistoryPopup->size()); - const QRect searchRect(m_globalSearchBox->mapToGlobal(QPoint(0, 0)), - m_globalSearchBox->size()); - if (!popupRect.contains(globalPos) && !searchRect.contains(globalPos)) { - hideGlobalSearchTransientUi(); - } - } - - - if (event->type() == QEvent::MouseButtonRelease) { - auto *mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::BackButton || mouseEvent->button() == Qt::XButton1) { - - - if (auto *modal = QApplication::activeModalWidget()) { - if (auto *dlg = qobject_cast(modal)) { - dlg->reject(); - } - return true; - } - auto back = findChild("back-button"); - - if (back && back->isVisible()) { - back->click(); - return true; - } - } - } else if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick) { - auto *mouseEvent = static_cast(event); - if (mouseEvent->button() == Qt::BackButton || mouseEvent->button() == Qt::XButton1) { - auto back = findChild("back-button"); - if (back && back->isVisible()) { - return true; - } - } - } - - if (ContextMenuUtils::showStyledTextContextMenu(watched, event)) { - return true; - } - if (ContextMenuUtils::showStyledLabelContextMenu(watched, event)) { - return true; - } - - return QMainWindow::eventFilter(watched, event); -} - -void MainWindow::closeEvent(QCloseEvent *event) -{ - hideGlobalSearchTransientUi(); - - - bool useTray = ConfigStore::instance()->get(ConfigKeys::CloseToTray, false); - - - if (!m_realQuit && useTray && QSystemTrayIcon::isSystemTrayAvailable()) { - - if (auto *player = m_homeView->activePlayerView()) { - if (player->isMediaPlaying()) { - player->pausePlayback(); - m_wasPausedByTray = true; - } - } - - hide(); - event->ignore(); - } else { - if (!m_quitPending) { - requestApplicationQuit(); - event->ignore(); - return; - } - event->accept(); - } -} diff --git a/src/qEmbyApp/mainwindow.h b/src/qEmbyApp/mainwindow.h deleted file mode 100644 index 32ddfe6..0000000 --- a/src/qEmbyApp/mainwindow.h +++ /dev/null @@ -1,68 +0,0 @@ -#ifndef MAINWINDOW_H -#define MAINWINDOW_H - -#include -#include -#include -#include - -class QEmbyCore; -class LoginView; -class HomeView; -class QStackedWidget; -class QLineEdit; -class QCompleter; -class QStringListModel; -class TrayManager; -class SearchHistoryPopup; - -class MainWindow : public QMainWindow -{ - Q_OBJECT - -public: - MainWindow(QWidget *parent = nullptr); - ~MainWindow(); - -public Q_SLOTS: - void navigateToHome(); - void navigateToLogin(); - -protected: - bool eventFilter(QObject *watched, QEvent *event) override; - void closeEvent(QCloseEvent *event) override; - -private: - void requestApplicationQuit(); - bool stopActivePlaybackForQuit(); - void finishApplicationQuit(); - void setupGlobalSearchHistory(); - void hideGlobalSearchTransientUi(); - void updateGlobalSearchCompleter(const QString &text = QString()); - void showGlobalSearchHistoryPopup(const QString &filterText = QString()); - void submitGlobalSearch(const QString &query); - QString currentSearchServerId() const; - - QEmbyCore *m_core; - QStackedWidget *m_viewStack; - LoginView *m_loginView; - HomeView *m_homeView; - QPointer m_globalSearchBox; - QCompleter *m_globalSearchCompleter = nullptr; - QStringListModel *m_globalSearchModel = nullptr; - SearchHistoryPopup *m_globalSearchHistoryPopup = nullptr; - TrayManager *m_trayManager = nullptr; - - - QElapsedTimer m_backClickTimer; - - quint32 m_defaultWidth{1280}; - quint32 m_defaultHeight{800}; - bool m_realQuit{false}; - bool m_themeAnimating{false}; - bool m_wasPausedByTray{false}; - bool m_viewTransitionInProgress{false}; - bool m_quitPending{false}; -}; - -#endif diff --git a/src/qEmbyApp/managers/playbackmanager.cpp b/src/qEmbyApp/managers/playbackmanager.cpp index 65da70f..3686db9 100644 --- a/src/qEmbyApp/managers/playbackmanager.cpp +++ b/src/qEmbyApp/managers/playbackmanager.cpp @@ -6,7 +6,7 @@ #include #include #include -#include "../components/playerwindow.h" +#include "../quick/independentplayerhost.h" #include "../components/moderntoast.h" #include "../utils/logredactionutils.h" #include "../utils/playerpreferenceutils.h" @@ -39,16 +39,21 @@ void PlaybackManager::init(QEmbyCore* core) m_core = core; } +void PlaybackManager::setEmbeddedHostAvailable(bool available) +{ + m_embeddedHostAvailable = available; +} + void PlaybackManager::startPlayback(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData) { if (!m_core) return; - - if (m_independentWindow) { - m_independentWindow->close(); - + // Drop any existing independent window before starting a new session + // (embedded, external, or a replacement independent play). + if (m_independentHost) { + m_independentHost->close(); } qDebug() << "[PlaybackManager] ===== startPlayback ====="; @@ -88,6 +93,13 @@ void PlaybackManager::startPlayback(const QString& mediaId, const QString& title + // No embedded host: fall back to QML IndependentPlayerHost. + if (!m_embeddedHostAvailable) { + qDebug() << "[PlaybackManager] → Mode: Independent window (no embedded host)"; + launchIndependentWindow(mediaId, title, streamUrl, startPositionTicks, extraData); + return; + } + qDebug() << "[PlaybackManager] → Mode: Embedded player"; Q_EMIT requestEmbeddedPlay(mediaId, title, streamUrl, startPositionTicks, extraData); } @@ -98,22 +110,25 @@ void PlaybackManager::startInternalPlayback(const QString& mediaId, const QStrin { if (!m_core) return; - - if (m_independentWindow) { - m_independentWindow->close(); + if (m_independentHost) { + m_independentHost->close(); } qDebug() << "[PlaybackManager] ===== startInternalPlayback ====="; qDebug() << "[PlaybackManager] mediaId:" << mediaId << "title:" << title; qDebug() << "[PlaybackManager] streamUrl:" << LogRedactionUtils::url(streamUrl); - bool independent = ConfigStore::instance()->get(ConfigKeys::PlayerIndependentWindow, false); if (independent) { qDebug() << "[PlaybackManager] → Mode: Independent window (internal)"; launchIndependentWindow(mediaId, title, streamUrl, startPositionTicks, extraData); return; } + if (!m_embeddedHostAvailable) { + qDebug() << "[PlaybackManager] → Mode: Independent window (internal, no embedded host)"; + launchIndependentWindow(mediaId, title, streamUrl, startPositionTicks, extraData); + return; + } qDebug() << "[PlaybackManager] → Mode: Embedded player (internal)"; Q_EMIT requestEmbeddedPlay(mediaId, title, streamUrl, startPositionTicks, extraData); } @@ -127,6 +142,35 @@ void PlaybackManager::stopExternalPlayer() cleanupExternalPlayerState(); } +bool PlaybackManager::stopActivePlaybackForQuit() +{ + bool stopped = false; + + // Independent QML player: close() deactivates PlayerViewModel (STOP report) + // then destroys the secondary engine. Host emits closed → playbackFinished. + if (m_independentHost) { + m_independentHost->close(); + stopped = true; + } + + // External player process: kill it and flush the final stop report. This + // mirrors the process-exit handler's reportPlaybackStopped, but synchronously + // on the quit path where we cannot wait for the async exit signal. + if (m_extProcess && m_extProcess->state() != QProcess::NotRunning) { + long long finalTicks = m_ipc ? m_ipc->currentPositionTicks() : 0; + if (m_core && m_core->mediaService() && !m_currentMediaId.isEmpty() && + !m_currentPlaySessionId.isEmpty()) { + m_core->mediaService()->reportPlaybackStopped( + m_currentMediaId, m_currentMediaSourceId, finalTicks, + m_currentPlaySessionId); + } + stopExternalPlayer(); + stopped = true; + } + + return stopped; +} + void PlaybackManager::cleanupExternalPlayerState() { if (m_progressTimer) { @@ -640,31 +684,16 @@ void PlaybackManager::launchIndependentWindow(const QString& mediaId, const QStr qDebug() << "[PlaybackManager] startPositionTicks:" << startPositionTicks << "(" << ticksToTimeString(startPositionTicks) << ")"; - - if (m_independentWindow) { - qDebug() << "[PlaybackManager] Reusing existing independent window"; - m_independentWindow->playMedia(mediaId, title, streamUrl, startPositionTicks, extraData); - m_independentWindow->raise(); - m_independentWindow->activateWindow(); - return; + if (!m_independentHost) { + qDebug() << "[PlaybackManager] Creating IndependentPlayerHost"; + m_independentHost = new IndependentPlayerHost(m_core, this); + connect(m_independentHost, &IndependentPlayerHost::closed, this, [this]() { + qDebug() << "[PlaybackManager] Independent player window closed"; + Q_EMIT playbackFinished(); + }); + } else { + qDebug() << "[PlaybackManager] Reusing IndependentPlayerHost"; } - qDebug() << "[PlaybackManager] Creating new independent window"; - - auto* playerWindow = new PlayerWindow(m_core); - playerWindow->setAttribute(Qt::WA_DeleteOnClose, true); - playerWindow->setMinimumSize(960, 540); - playerWindow->resize(1280, 720); - - - connect(playerWindow, &QObject::destroyed, this, [this]() { - m_independentWindow = nullptr; - qDebug() << "[PlaybackManager] Independent window destroyed"; - Q_EMIT playbackFinished(); - }); - - m_independentWindow = playerWindow; - - playerWindow->show(); - playerWindow->playMedia(mediaId, title, streamUrl, startPositionTicks, extraData); + m_independentHost->play(mediaId, title, streamUrl, startPositionTicks, extraData); } diff --git a/src/qEmbyApp/managers/playbackmanager.h b/src/qEmbyApp/managers/playbackmanager.h index 2d32149..262b9c7 100644 --- a/src/qEmbyApp/managers/playbackmanager.h +++ b/src/qEmbyApp/managers/playbackmanager.h @@ -9,48 +9,47 @@ class QEmbyCore; class QProcess; -class PlayerWindow; +class IndependentPlayerHost; class QTimer; struct MediaSourceInfo; - - - class PlaybackManager : public QObject { Q_OBJECT public: static PlaybackManager* instance(); - void init(QEmbyCore* core); - + // Whether an in-shell embedded player host exists to receive + // requestEmbeddedPlay (AppShellHost + PlayerRouteController). When false, + // the embedded branch falls back to IndependentPlayerHost (QML window). + void setEmbeddedHostAvailable(bool available); + void startPlayback(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData = QVariant()); - void startExternalPlayback(const QString& playerPath, const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData = QVariant()); - void startInternalPlayback(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData = QVariant()); - void stopExternalPlayer(); + // Stop independent QML player window and/or external process before quit. + // Returns true if something was stopped (caller may defer quit briefly). + bool stopActivePlaybackForQuit(); + signals: - void requestEmbeddedPlay(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData); - void playbackFinished(); private: @@ -66,11 +65,8 @@ private: const QString& streamUrl, long long startPositionTicks, const QVariant& extraData); - - static PlayerType identifyPlayerType(const QString& playerPath); - static QStringList buildPlayerArgs(PlayerType type, long long startPositionTicks, int audioStreamIndex, @@ -78,34 +74,27 @@ private: bool subtitleDisabled, const MediaSourceInfo* sourceInfo); - static QString ticksToTimeString(long long ticks); - - static double ticksToSeconds(long long ticks); - static int computeRelativeStreamIndex(const MediaSourceInfo& source, const QString& streamType, int globalIndex); - static QString generateIpcEndpoint(PlayerType type); - void cleanupExternalPlayerState(); - QEmbyCore* m_core = nullptr; - QPointer m_extProcess; - QPointer m_independentWindow; + bool m_embeddedHostAvailable = true; + QPointer m_extProcess; + QPointer m_independentHost; - - ExternalPlayerIPC* m_ipc = nullptr; - QTimer* m_progressTimer = nullptr; - QString m_currentMediaId; - QString m_currentMediaSourceId; - QString m_currentPlaySessionId; + ExternalPlayerIPC* m_ipc = nullptr; + QTimer* m_progressTimer = nullptr; + QString m_currentMediaId; + QString m_currentMediaSourceId; + QString m_currentPlaySessionId; }; #endif diff --git a/src/qEmbyApp/qml/AppShell.qml b/src/qEmbyApp/qml/AppShell.qml new file mode 100644 index 0000000..03b0bff --- /dev/null +++ b/src/qEmbyApp/qml/AppShell.qml @@ -0,0 +1,143 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls.Basic +import QtQuick.Layouts +import QWindowKit +import qEmby + +// 全 QML 外壳的顶层窗口。QQmlApplicationEngine 的根对象。 +// 视觉基调:静谧底色 + 极淡氛围光 + 壳层微交互(标题栏 / 侧栏 / 路由)。 +ApplicationWindow { + id: shell + + width: 1280 + height: 800 + minimumWidth: 960 + minimumHeight: 600 + visible: true + color: Theme.bgBase + title: Qt.application.name + + Behavior on color { + ColorAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + + Component.onCompleted: windowAgent.setup(shell) + + // 关闭窗口时交由 C++ 生命周期控制器裁决(复刻 MainWindow::closeEvent): + // 开启「关闭到托盘」时隐藏到托盘、吞掉关闭;否则启动优雅退出流程(退出前 + // 停播 → 冲刷停播上报 → 登出 → qApp->quit),同样先吞掉这次关闭,待异步 + // 序列结束再真正退出。 + onClosing: (close) => { close.accepted = shellLifecycle.handleWindowClose() } + + WindowAgent { + id: windowAgent + } + + // ── 氛围底:双光斑 + 主题色平滑过渡 ────────────────────────────── + Rectangle { + anchors.fill: parent + color: Theme.bgBase + z: -1 + + Behavior on color { + ColorAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + + // 右上主光斑(强调色极低透明) + Rectangle { + id: ambientOrb + width: Math.max(parent.width * 0.52, 640) + height: width + radius: width / 2 + x: parent.width - width * 0.58 + y: -height * 0.64 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.045 : 0.065) + opacity: 0.92 + + Behavior on color { + ColorAnimation { duration: Theme.animationSlow; easing.type: Easing.OutCubic } + } + + // 氛围呼吸:低频率;减少动画时关闭,避免后台常驻帧 + SequentialAnimation on opacity { + running: !Theme.reduceMotion + loops: Animation.Infinite + NumberAnimation { + to: 1.0 + duration: 5600 + easing.type: Easing.InOutSine + } + NumberAnimation { + to: 0.82 + duration: 5600 + easing.type: Easing.InOutSine + } + } + } + + // 左下辅光斑,制造纵深,不抢内容 + Rectangle { + width: Math.max(parent.width * 0.34, 380) + height: width + radius: width / 2 + x: -width * 0.45 + y: parent.height - height * 0.35 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.028 : 0.04) + opacity: 0.7 + + Behavior on color { + ColorAnimation { duration: Theme.animationSlow; easing.type: Easing.OutCubic } + } + } + } + + // ===== 完整标题栏 ===== + TitleBar { + id: titleBar + anchors { top: parent.top; left: parent.left; right: parent.right } + shell: shell + windowAgent: windowAgent + } + + // ===== 内容区:Router + 侧边栏 ===== + // 固定态时 SideBar 内缩 Router(dockLeftInset/dockRightInset);浮动态 + // SideBar 覆盖在 Router 之上(声明在后 → 绘制在上),透明区放行到 Router。 + Router { + id: router + anchors { + // An invisible anchored TitleBar still occupies its 40px geometry, + // so in immersive mode anchor to the top edge instead to let the + // video fill the window. + top: titleBar.visible ? titleBar.bottom : parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + leftMargin: sideBar.dockLeftInset + rightMargin: sideBar.dockRightInset + } + Behavior on anchors.leftMargin { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + Behavior on anchors.rightMargin { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + active: shell.active + } + + SideBar { + id: sideBar + anchors { + top: titleBar.visible ? titleBar.bottom : parent.top + bottom: parent.bottom + left: parent.left + right: parent.right + } + } +} diff --git a/src/qEmbyApp/qml/CategoryPage.qml b/src/qEmbyApp/qml/CategoryPage.qml index 7f01743..ceb861f 100644 --- a/src/qEmbyApp/qml/CategoryPage.qml +++ b/src/qEmbyApp/qml/CategoryPage.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts import qEmby 1.0 +import "components" Item { id: page @@ -18,33 +19,21 @@ Item { Rectangle { anchors.fill: parent color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } ColumnLayout { anchors.fill: parent spacing: 0 - RowLayout { + PageHeader { Layout.fillWidth: true - Layout.leftMargin: Theme.spacingXl - Layout.rightMargin: Theme.spacingXl - Layout.topMargin: Theme.spacingLg - Layout.bottomMargin: Theme.spacingMd - spacing: Theme.spacingMd - - Text { - Layout.fillWidth: true - Layout.maximumWidth: 480 - text: categoryPage.title - color: Theme.textPrimary - font.pixelSize: Theme.fontTitle - font.weight: Font.Bold - elide: Text.ElideRight - } - - Item { - Layout.fillWidth: true - } + title: categoryPage.title ThemedButton { visible: categoryPage.refreshVisible @@ -55,7 +44,6 @@ Item { ThemedComboBox { id: sortBox - model: page.sortLabels currentIndex: categoryPage.sortIndex enabled: categoryPage.sortEnabled && !categoryPage.loading @@ -81,14 +69,8 @@ Item { color: Theme.textMuted font.pixelSize: Theme.fontBody horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter } - - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.borderSoft } Item { diff --git a/src/qEmbyApp/qml/DashboardPage.qml b/src/qEmbyApp/qml/DashboardPage.qml index 3e456f0..ee2fd79 100644 --- a/src/qEmbyApp/qml/DashboardPage.qml +++ b/src/qEmbyApp/qml/DashboardPage.qml @@ -56,6 +56,13 @@ Item { Rectangle { anchors.fill: parent color: Theme.bgBase + + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } Flickable { @@ -66,6 +73,17 @@ Item { contentHeight: contentColumn.implicitHeight + page.pageMargin * 2 clip: true boundsBehavior: Flickable.StopAtBounds + // 更顺的滚动手感 + flickDeceleration: 2400 + maximumFlickVelocity: 3200 + opacity: dashboardPage.loading && !dashboardPage.hasContent ? 0 : 1 + + Behavior on opacity { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } Column { id: contentColumn @@ -73,27 +91,70 @@ Item { x: page.pageMargin y: page.pageMargin width: page.contentWidth - spacing: Theme.spacingXl + spacing: Theme.spacingXl + 4 Repeater { model: dashboardPage.sectionOrder delegate: Loader { + id: sectionLoader required property string modelData + required property int index width: contentColumn.width + // 不可见区块不实例化;异步创建减轻首屏主线程峰值 active: page.sectionVisible(modelData) - visible: active + asynchronous: true + visible: active && status === Loader.Ready sourceComponent: page.sectionComponent(modelData) + opacity: Theme.reduceMotion ? 1 : 0 + transform: Translate { + id: sectionShift + y: Theme.reduceMotion ? 0 : 12 + } + + onActiveChanged: { + if (!active) { + opacity = Theme.reduceMotion ? 1 : 0 + sectionShift.y = Theme.reduceMotion ? 0 : 12 + } else if (status === Loader.Ready) { + sectionAppear.restart() + } + } + onStatusChanged: { + if (status === Loader.Ready && active) + sectionAppear.restart() + } + + SequentialAnimation { + id: sectionAppear + // reduceMotion:立即可见,不播动画 + PauseAnimation { + duration: Theme.reduceMotion ? 0 + : Math.min(sectionLoader.index, 3) * 28 + } + ParallelAnimation { + NumberAnimation { + target: sectionLoader + property: "opacity" + to: 1 + duration: Theme.reduceMotion ? 0 : Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + target: sectionShift + property: "y" + to: 0 + duration: Theme.reduceMotion ? 0 : Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } } - } - - } - - ScrollBar.vertical: ThemedScrollBar { } + ScrollBar.vertical: ThemedScrollBar {} } LoadingState { @@ -231,13 +292,26 @@ Item { width: contentColumn.width spacing: Theme.spacingMd - Text { + Row { + spacing: Theme.spacingSm width: parent.width - text: qsTr("All Libraries") - color: Theme.textPrimary - font.pixelSize: Theme.fontHeading - font.weight: Font.DemiBold - elide: Text.ElideRight + + Rectangle { + width: 3 + height: 16 + radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: Theme.accent + opacity: 0.9 + } + Text { + width: parent.width - 14 + text: qsTr("All Libraries") + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + } } MediaGrid { diff --git a/src/qEmbyApp/qml/DetailPage.qml b/src/qEmbyApp/qml/DetailPage.qml index 6302347..9b0f44a 100644 --- a/src/qEmbyApp/qml/DetailPage.qml +++ b/src/qEmbyApp/qml/DetailPage.qml @@ -28,6 +28,12 @@ FocusScope { Rectangle { anchors.fill: parent color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } Flickable { @@ -38,6 +44,8 @@ FocusScope { contentHeight: contentColumn.implicitHeight boundsBehavior: Flickable.StopAtBounds clip: true + flickDeceleration: 2400 + maximumFlickVelocity: 3200 ScrollBar.vertical: ThemedScrollBar {} @@ -51,29 +59,62 @@ FocusScope { id: hero Layout.fillWidth: true - Layout.preferredHeight: Math.max(430, heroContent.implicitHeight + Theme.spacingXl * 3) + Layout.preferredHeight: Math.max(460, heroContent.implicitHeight + Theme.spacingXl * 3) + clip: true + // 视差背景:滚动时缓慢上移 Image { - anchors.fill: parent + id: backdrop + width: parent.width + height: parent.height + 80 + y: Math.min(0, -pageScroll.contentY * 0.28) source: detailPage.backdropSource fillMode: Image.PreserveAspectCrop asynchronous: true - opacity: status === Image.Ready ? 0.48 : 0 + opacity: status === Image.Ready ? 0.5 : 0 + scale: status === Image.Ready ? 1.04 : 1.12 Behavior on opacity { - NumberAnimation { duration: Theme.animationNormal } + NumberAnimation { + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: 900 + easing.type: Easing.OutCubic + } } } Rectangle { anchors.fill: parent gradient: Gradient { - GradientStop { position: 0; color: Theme.alpha(Theme.bgBase, 0.18) } - GradientStop { position: 0.72; color: Theme.alpha(Theme.bgBase, 0.9) } + GradientStop { position: 0; color: Theme.alpha(Theme.bgBase, 0.12) } + GradientStop { position: 0.55; color: Theme.alpha(Theme.bgBase, 0.72) } GradientStop { position: 1; color: Theme.bgBase } } } + // 底部强调色雾 + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.45 + gradient: Gradient { + GradientStop { + position: 0 + color: "transparent" + } + GradientStop { + position: 1 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.06 : 0.04) + } + } + } + RowLayout { id: heroContent @@ -82,22 +123,121 @@ FocusScope { anchors.bottom: parent.bottom anchors.margins: Theme.spacingXl spacing: Theme.spacingXl + opacity: 0 + // 入场:自下而上 + transform: Translate { + id: heroShift + y: 18 + } - Rectangle { - Layout.preferredWidth: 220 - Layout.preferredHeight: 330 + property string lastEntranceTitle: "" + + function playEntrance() { + heroEntrance.stop() + heroContent.opacity = 0 + heroShift.y = 18 + heroEntrance.start() + } + + Component.onCompleted: playEntrance() + Connections { + target: detailPage + // title 挂在 stateChanged 上;仅标题变化时重播入场,避免收藏等操作抖动 + function onStateChanged() { + const t = detailPage.title + if (t.length > 0 && t !== heroContent.lastEntranceTitle) { + heroContent.lastEntranceTitle = t + heroContent.playEntrance() + } + } + } + + ParallelAnimation { + id: heroEntrance + NumberAnimation { + target: heroContent + property: "opacity" + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: heroShift + property: "y" + to: 0 + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + + // 海报 + 外圈柔光 + Item { + Layout.preferredWidth: 228 + Layout.preferredHeight: 338 Layout.alignment: Qt.AlignBottom - radius: Theme.radiusLg - color: Theme.bgRaised - border.width: 1 - border.color: Theme.borderSoft - clip: true - Image { + Rectangle { + anchors.centerIn: parent + width: parent.width + 10 + height: parent.height + 10 + radius: Theme.radiusXl + color: "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.2) + opacity: 0.85 + } + + Rectangle { + id: posterFrame anchors.fill: parent - source: detailPage.posterSource - fillMode: Image.PreserveAspectCrop - asynchronous: true + anchors.margins: 4 + radius: Theme.radiusLg + color: Theme.bgRaised + border.width: 1 + border.color: Theme.borderSoft + clip: true + scale: 1 + + Image { + id: posterImage + anchors.fill: parent + source: detailPage.posterSource + fillMode: Image.PreserveAspectCrop + asynchronous: true + opacity: status === Image.Ready ? 1 : 0 + scale: status === Image.Ready ? 1 : 1.06 + + Behavior on opacity { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + } + + // 底部轻微渐变,与背景衔接 + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 48 + gradient: Gradient { + GradientStop { + position: 0 + color: "transparent" + } + GradientStop { + position: 1 + color: Theme.alpha(Theme.bgBase, 0.35) + } + } + } } } @@ -119,21 +259,24 @@ FocusScope { Text { visible: text.length > 0 text: detailPage.metaText - color: Theme.textBody + color: Theme.textBody font.pixelSize: Theme.fontBody Layout.fillWidth: true + opacity: 0.92 } Text { visible: text.length > 0 text: detailPage.tagline - color: Theme.textBody + color: Theme.textBody font.pixelSize: Theme.fontHeading font.italic: true wrapMode: Text.WordWrap Layout.fillWidth: true + opacity: 0.88 } + // 类型 chips Flow { visible: detailPage.genres.length > 0 Layout.fillWidth: true @@ -142,13 +285,53 @@ FocusScope { Repeater { model: detailPage.genres - ThemedButton { + Rectangle { + id: genreChip required property int index required property string modelData - text: modelData - implicitWidth: contentItem.implicitWidth + Theme.spacingLg * 2 - onClicked: detailPage.activateGenre(index) + implicitWidth: genreLabel.implicitWidth + Theme.spacingMd * 2 + implicitHeight: 30 + radius: height / 2 + color: genreHover.hovered + ? Theme.alpha(Theme.accent, Theme.dark ? 0.22 : 0.14) + : Theme.alpha(Theme.bgRaised, Theme.dark ? 0.75 : 0.9) + border.width: 1 + border.color: genreHover.hovered + ? Theme.alpha(Theme.accent, 0.55) + : Theme.borderSoft + scale: genreTap.pressed ? 0.96 : genreHover.hovered ? 1.04 : 1 + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Text { + id: genreLabel + anchors.centerIn: parent + text: genreChip.modelData + color: genreHover.hovered ? Theme.accent : Theme.textBody + font.pixelSize: Theme.fontSmall + font.weight: Font.Medium + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + + HoverHandler { id: genreHover } + TapHandler { + id: genreTap + onTapped: detailPage.activateGenre(genreChip.index) + } } } } @@ -159,6 +342,7 @@ FocusScope { ThemedButton { visible: detailPage.playable enabled: !detailPage.actionBusy + prominent: true text: detailPage.resumable ? qsTr("Resume") : qsTr("Play") onClicked: detailPage.requestPlay(detailPage.resumable) } @@ -194,7 +378,7 @@ FocusScope { Text { text: qsTr("Version") - color: Theme.textBody + color: Theme.textBody font.pixelSize: Theme.fontBody } @@ -213,11 +397,12 @@ FocusScope { text: detailPage.overview color: Theme.textPrimary font.pixelSize: Theme.fontBody - lineHeight: 1.25 + lineHeight: 1.35 wrapMode: Text.WordWrap maximumLineCount: 8 elide: Text.ElideRight Layout.fillWidth: true + opacity: 0.94 } } diff --git a/src/qEmbyApp/qml/EmptyState.qml b/src/qEmbyApp/qml/EmptyState.qml index 84f29d3..59cfee4 100644 --- a/src/qEmbyApp/qml/EmptyState.qml +++ b/src/qEmbyApp/qml/EmptyState.qml @@ -2,33 +2,63 @@ import QtQuick import QtQuick.Layouts import qEmby -// 共享空状态占位:与 QSS 的 QLabel#ManageEmptyLabel 同一视觉语义 -// (textMuted + 斜体),可选 adaptiveicon 图标与副标题。 -// 用法: EmptyState { icon: "/svg/dark/now-playing.svg"; text: qsTr("...") } +// 共享空状态占位 ColumnLayout { id: root - // /svg/dark/xxx.svg 路径,留空则不显示图标(经 image://adaptiveicon 随主题换色) property string icon: "" property alias text: titleText.text property string hint: "" - property int iconSize: 28 + property int iconSize: 32 spacing: Theme.spacingSm Layout.fillWidth: true Layout.topMargin: Theme.spacingSm Layout.bottomMargin: Theme.spacingSm + opacity: 0 + scale: 0.97 - Image { + Component.onCompleted: appear.start() + onVisibleChanged: if (visible) appear.restart() + + ParallelAnimation { + id: appear + NumberAnimation { + target: root + property: "opacity" + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: root + property: "scale" + to: 1 + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + + Rectangle { visible: root.icon.length > 0 - opacity: 0.55 - sourceSize: Qt.size(root.iconSize * 2, root.iconSize * 2) - source: root.icon.length > 0 - ? "image://adaptiveicon/" + root.icon + "?d=" + (Theme.dark ? 1 : 0) - : "" - Layout.preferredWidth: root.iconSize - Layout.preferredHeight: root.iconSize + Layout.preferredWidth: root.iconSize + 20 + Layout.preferredHeight: root.iconSize + 20 Layout.alignment: Qt.AlignHCenter + radius: width / 2 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.1 : 0.08) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.18) + + Image { + anchors.centerIn: parent + opacity: 0.7 + sourceSize: Qt.size(root.iconSize * 2, root.iconSize * 2) + width: root.iconSize + height: root.iconSize + source: root.icon.length > 0 + ? "image://adaptiveicon/" + root.icon + "?d=" + (Theme.dark ? 1 : 0) + : "" + } } Text { diff --git a/src/qEmbyApp/qml/FavoritesPage.qml b/src/qEmbyApp/qml/FavoritesPage.qml index b96d144..f89487e 100644 --- a/src/qEmbyApp/qml/FavoritesPage.qml +++ b/src/qEmbyApp/qml/FavoritesPage.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts import qEmby 1.0 +import "components" Item { id: page @@ -28,47 +29,28 @@ Item { Rectangle { anchors.fill: parent color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } ColumnLayout { anchors.fill: parent spacing: 0 - RowLayout { + PageHeader { Layout.fillWidth: true - Layout.leftMargin: Theme.spacingXl - Layout.rightMargin: Theme.spacingXl - Layout.topMargin: Theme.spacingLg - Layout.bottomMargin: Theme.spacingMd - spacing: Theme.spacingMd - - Text { - text: qsTr("Favorites") - color: Theme.textPrimary - font.pixelSize: Theme.fontTitle - font.weight: Font.Bold - Layout.fillWidth: true - } - - Text { - visible: favoritesPage.refreshing - text: qsTr("Refreshing…") - color: Theme.textMuted - font.pixelSize: Theme.fontSmall - } + title: qsTr("Favorites") + subtitle: favoritesPage.refreshing ? qsTr("Refreshing…") : "" ThemedButton { text: qsTr("Refresh") enabled: !favoritesPage.loading && !favoritesPage.refreshing onClicked: favoritesPage.refresh() } - - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.borderSoft } Item { @@ -85,6 +67,8 @@ Item { contentWidth: width contentHeight: sections.implicitHeight + Theme.spacingXl * 2 boundsBehavior: Flickable.StopAtBounds + flickDeceleration: 2400 + maximumFlickVelocity: 3200 ColumnLayout { id: sections diff --git a/src/qEmbyApp/qml/IndependentPlayerWindow.qml b/src/qEmbyApp/qml/IndependentPlayerWindow.qml new file mode 100644 index 0000000..57b3467 --- /dev/null +++ b/src/qEmbyApp/qml/IndependentPlayerWindow.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Window +import qEmby 1.0 + +// Standalone player window (replaces Widgets PlayerWindow + PlayerView). +// Context property `player` is a dedicated PlayerViewModel owned by +// IndependentPlayerHost — not the in-shell route VM. +// Closing / Back is owned by IndependentPlayerHost (C++); QML only +// deactivates the session so STOP is reported before teardown. +ApplicationWindow { + id: win + + width: 1280 + height: 720 + minimumWidth: 960 + minimumHeight: 540 + visible: true + color: "#000000" + title: (player && player.title) ? player.title : qsTr("Player") + + onClosing: function(close) { + if (player && player.active) + player.active = false + } + + PlayerPage { + id: playerPage + anchors.fill: parent + active: true + } + + Connections { + target: player + function onStateChanged() { + win.title = player.title && player.title.length > 0 + ? player.title : qsTr("Player") + } + } +} diff --git a/src/qEmbyApp/qml/LibraryPage.qml b/src/qEmbyApp/qml/LibraryPage.qml index 1a25dd4..a2fd190 100644 --- a/src/qEmbyApp/qml/LibraryPage.qml +++ b/src/qEmbyApp/qml/LibraryPage.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts import qEmby 1.0 +import "components" Item { id: page @@ -45,123 +46,108 @@ Item { Rectangle { anchors.fill: parent color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } ColumnLayout { anchors.fill: parent spacing: 0 - ColumnLayout { + PageHeader { Layout.fillWidth: true - Layout.leftMargin: Theme.spacingXl - Layout.rightMargin: Theme.spacingXl - Layout.topMargin: Theme.spacingLg - Layout.bottomMargin: Theme.spacingMd - spacing: Theme.spacingMd - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingMd - - Text { - Layout.fillWidth: true - Layout.maximumWidth: 460 - text: libraryPage.title - color: Theme.textPrimary - font.pixelSize: Theme.fontTitle - font.weight: Font.Bold - elide: Text.ElideRight - } - - ThemedButton { - visible: libraryPage.favoriteVisible - text: libraryPage.favorite ? qsTr("♥ Favorite") : qsTr("♡ Favorite") - onClicked: libraryPage.requestCurrentFavorite() - } - - Item { - Layout.fillWidth: true - } - - ThemedButton { - enabled: !libraryPage.loading - text: qsTr("Refresh") - onClicked: libraryPage.refresh() - } - - ThemedComboBox { - model: page.sortLabels - currentIndex: libraryPage.sortIndex - enabled: !libraryPage.loading - onActivated: function(index) { - libraryPage.sortIndex = index; - } - } - - ThemedButton { - enabled: !libraryPage.loading - text: libraryPage.descending ? qsTr("Descending") : qsTr("Ascending") - onClicked: libraryPage.descending = !libraryPage.descending - } - - ThemedComboBox { - model: [qsTr("Poster"), qsTr("Tile"), qsTr("List")] - currentIndex: libraryPage.viewStyle - onActivated: function(index) { - libraryPage.viewStyle = index; - } - } - - Text { - text: libraryPage.statsText - color: Theme.textMuted - font.pixelSize: Theme.fontBody - horizontalAlignment: Text.AlignRight - } + title: libraryPage.title + bottomPadding: libraryPage.tabsVisible ? Theme.spacingXs : Theme.spacingMd + ThemedButton { + visible: libraryPage.favoriteVisible + text: libraryPage.favorite ? qsTr("♥ Favorite") : qsTr("♡ Favorite") + onClicked: libraryPage.requestCurrentFavorite() } - Flickable { - Layout.fillWidth: true - Layout.preferredHeight: libraryPage.tabsVisible ? 38 : 0 - visible: libraryPage.tabsVisible - contentWidth: tabRow.implicitWidth - contentHeight: height - clip: true - boundsBehavior: Flickable.StopAtBounds - - Row { - id: tabRow - - height: parent.height - spacing: Theme.spacingSm - - Repeater { - model: page.tabLabels - - ThemedButton { - required property int index - required property string modelData - - height: 34 - text: modelData - checkable: true - checked: libraryPage.tabIndex === index - onClicked: libraryPage.tabIndex = index - } - - } - - } - + ThemedButton { + enabled: !libraryPage.loading + text: qsTr("Refresh") + onClicked: libraryPage.refresh() } + ThemedComboBox { + model: page.sortLabels + currentIndex: libraryPage.sortIndex + enabled: !libraryPage.loading + onActivated: function(index) { + libraryPage.sortIndex = index; + } + } + + ThemedButton { + enabled: !libraryPage.loading + text: libraryPage.descending ? qsTr("Descending") : qsTr("Ascending") + onClicked: libraryPage.descending = !libraryPage.descending + } + + ThemedComboBox { + model: [qsTr("Poster"), qsTr("Tile"), qsTr("List")] + currentIndex: libraryPage.viewStyle + onActivated: function(index) { + libraryPage.viewStyle = index; + } + } + + Text { + text: libraryPage.statsText + color: Theme.textMuted + font.pixelSize: Theme.fontBody + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + } } - Rectangle { + // Tab 条(在顶栏分隔线之下) + Item { Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.borderSoft + Layout.preferredHeight: libraryPage.tabsVisible ? 44 : 0 + visible: libraryPage.tabsVisible + clip: true + + Flickable { + anchors.fill: parent + anchors.leftMargin: Theme.spacingXl + anchors.rightMargin: Theme.spacingXl + contentWidth: libraryTabs.implicitWidth + contentHeight: height + boundsBehavior: Flickable.StopAtBounds + clip: true + + SegmentedTabs { + id: libraryTabs + anchors.verticalCenter: parent.verticalCenter + model: page.tabLabels + currentIndex: libraryPage.tabIndex + onCurrentIndexChanged: { + if (libraryPage.tabIndex !== currentIndex) + libraryPage.tabIndex = currentIndex + } + } + } + + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 1 + color: Theme.alpha(Theme.borderSoft, 0.85) + } + } + + Connections { + target: libraryPage + function onTabIndexChanged() { + if (libraryTabs.currentIndex !== libraryPage.tabIndex) + libraryTabs.currentIndex = libraryPage.tabIndex + } } Item { diff --git a/src/qEmbyApp/qml/LoginPage.qml b/src/qEmbyApp/qml/LoginPage.qml index 8120741..3f020b3 100644 --- a/src/qEmbyApp/qml/LoginPage.qml +++ b/src/qEmbyApp/qml/LoginPage.qml @@ -7,6 +7,8 @@ import "components" Item { id: page + Component.onCompleted: entrance.start() + Connections { target: login function onFormChanged() { @@ -19,29 +21,70 @@ Item { } } - Rectangle { anchors.fill: parent; color: Theme.bgBase } + Rectangle { + anchors.fill: parent + color: Theme.bgBase + + Rectangle { + width: 420 + height: 420 + radius: 210 + x: -150 + y: parent.height - 270 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.035 : 0.06) + } + } ColumnLayout { + id: loginContent anchors.centerIn: parent - width: Math.min(560, Math.max(320, page.width - 48)) - spacing: Theme.spacingLg + anchors.verticalCenterOffset: 0 + width: Math.min(540, Math.max(320, page.width - 48)) + spacing: Theme.spacingMd + opacity: 0 + scale: 0.97 + + Image { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 54 + Layout.preferredHeight: 54 + source: "qrc:/svg/qemby_logo.svg" + fillMode: Image.PreserveAspectFit + } Text { Layout.alignment: Qt.AlignHCenter text: login.editorVisible ? qsTr("Connect to Server") : qsTr("Select Server") color: Theme.textPrimary - font.pixelSize: Theme.fontTitle - font.weight: Font.Bold + font.pixelSize: Theme.fontDisplay + font.weight: Font.DemiBold + } + + Text { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: Theme.spacingSm + text: login.editorVisible + ? qsTr("Enter your server details to continue") + : qsTr("Choose where you want to watch") + color: Theme.textMuted + font.pixelSize: Theme.fontBody } Rectangle { Layout.fillWidth: true implicitHeight: login.editorVisible ? editor.implicitHeight + Theme.spacingLg * 2 : serverList.implicitHeight + Theme.spacingLg * 2 - radius: Theme.radiusLg - color: Theme.bgSurface + radius: 14 + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.96 : 0.98) border.width: 1 - border.color: Theme.borderSoft + border.color: Theme.alpha(Theme.border, 0.72) + + Behavior on color { + ColorAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast } + } ListView { id: serverList @@ -71,6 +114,13 @@ Item { color: cardMouse.containsMouse ? Theme.alpha(Theme.accent, 0.12) : Theme.bgRaised border.width: 1 border.color: cardMouse.containsMouse ? Theme.accent : Theme.borderSoft + scale: cardMouse.pressed ? 0.97 : cardMouse.containsMouse ? 1.018 : 1 + + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + Behavior on border.color { ColorAnimation { duration: Theme.animationFast } } + Behavior on scale { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } MouseArea { id: cardMouse @@ -191,12 +241,47 @@ Item { RowLayout { Layout.fillWidth: true; spacing: Theme.spacingSm ThemedButton { Layout.fillWidth: true; text: qsTr("Cancel"); enabled: login.hasServers && !login.busy; onClicked: login.cancelEdit() } - ThemedButton { Layout.fillWidth: true; text: login.busy ? qsTr("Connecting…") : (login.editing ? qsTr("Save & Login") : qsTr("Login")); enabled: !login.busy; onClicked: login.submit() } + ThemedButton { + Layout.fillWidth: true + prominent: true + text: login.busy ? qsTr("Connecting…") + : (login.editing ? qsTr("Save & Login") : qsTr("Login")) + enabled: !login.busy + onClicked: login.submit() + } } } } } + ParallelAnimation { + id: entrance + NumberAnimation { + target: loginContent + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: loginContent + property: "scale" + from: 0.97 + to: 1 + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + NumberAnimation { + target: loginContent + property: "anchors.verticalCenterOffset" + from: 14 + to: 0 + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + RowLayout { anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom anchors.margins: Theme.spacingMd @@ -208,9 +293,31 @@ Item { Rectangle { anchors.fill: parent visible: login.busy - color: Theme.alpha(Theme.bgBase, 0.72) + opacity: login.busy ? 1 : 0 + color: Theme.alpha(Theme.bgBase, 0.68) z: 20 + Behavior on opacity { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } MouseArea { anchors.fill: parent } - BusyIndicator { anchors.centerIn: parent; running: parent.visible } + BusyIndicator { + anchors.centerIn: parent + running: parent.visible + scale: parent.visible ? 1 : 0.9 + Behavior on scale { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutBack + } + } + } + } + + ConfirmDialog { + target: login + parent: Overlay.overlay } } diff --git a/src/qEmbyApp/qml/ManageDashboardPage.qml b/src/qEmbyApp/qml/ManageDashboardPage.qml index 0b77666..9ad8157 100644 --- a/src/qEmbyApp/qml/ManageDashboardPage.qml +++ b/src/qEmbyApp/qml/ManageDashboardPage.qml @@ -456,4 +456,9 @@ Flickable { } } } + + ConfirmDialog { + target: dashboard + parent: Overlay.overlay + } } diff --git a/src/qEmbyApp/qml/ManagePage.qml b/src/qEmbyApp/qml/ManagePage.qml new file mode 100644 index 0000000..7855a9a --- /dev/null +++ b/src/qEmbyApp/qml/ManagePage.qml @@ -0,0 +1,203 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby + +// Shared-engine host for the seven Management sub-pages. In the Widgets shell +// this role was played by ManageView (a QListWidget nav + SlidingStackedWidget +// of Page* wrappers); here Router.qml owns the page lifetime and this file +// reproduces the left nav + lazy content switch. Sub-pages bind to the admin +// context properties registered by AppShellHost (dashboard / librariesPage / +// collectionsPage / transcodingPage / usersPage / tasksPage / logsPage). +// +// Section activation is fanned out through `manageController.setActiveSection()` +// (a C++ Q_INVOKABLE) rather than each page toggling its own VM, because some +// admin VMs expose setActive as a plain method, not a QML-callable slot. The +// controller also drives WebSocket connect/disconnect off the active section. +// An optional `route.params.section` selects the initial row. +Item { + id: managePage + + // Injected by Router.injectRoute(). + property var route: ({}) + property bool active: false + + // Nav order mirrors ManageView::setupUi (Dashboard / Libraries / Lists / + // Transcoding / Users / Tasks / Logs). `section` values match + // NavigationController's kManageSections set. + readonly property var sections: ["dashboard", "libraries", "collections", + "transcoding", "users", "tasks", "logs"] + property int currentIndex: 0 + + function sectionToIndex(section) { + const idx = sections.indexOf(section); + return idx >= 0 ? idx : 0; + } + + function applyRoute(r) { + const params = r && r.params ? r.params : ({}); + const section = params.section ? String(params.section) : ""; + if (section.length > 0) + currentIndex = sectionToIndex(section); + } + + // Push the visible section to the controller whenever it, or the page's + // active state, changes. Deactivating clears every VM (empty section) and + // drops the management WebSocket. + function syncActiveSection() { + if (typeof manageController === "undefined" || !manageController) + return; + manageController.setActiveSection(active ? sections[currentIndex] : ""); + } + + onRouteChanged: applyRoute(route) + onCurrentIndexChanged: syncActiveSection() + onActiveChanged: syncActiveSection() + Component.onCompleted: { + applyRoute(route); + syncActiveSection(); + } + Component.onDestruction: { + if (typeof manageController !== "undefined" && manageController) + manageController.setActiveSection(""); + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ─── Left navigation panel ─────────────────────────────────────── + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 260 + color: "transparent" + + ColumnLayout { + anchors.fill: parent + anchors.margins: 32 + spacing: 24 + + Text { + text: qsTr("Management") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: 700 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: [ + { label: qsTr("Dashboard"), icon: "/svg/dark/manage-dashboard.svg" }, + { label: qsTr("Libraries"), icon: "/svg/dark/manage-libraries.svg" }, + { label: qsTr("Lists"), icon: "/svg/dark/manage-collections.svg" }, + { label: qsTr("Transcoding"), icon: "/svg/dark/video-output.svg" }, + { label: qsTr("Users"), icon: "/svg/dark/manage-users.svg" }, + { label: qsTr("Tasks"), icon: "/svg/dark/manage-tasks.svg" }, + { label: qsTr("Logs"), icon: "/svg/dark/manage-logs.svg" } + ] + + delegate: Rectangle { + id: navRow + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 44 + radius: Theme.radiusMd + readonly property bool selected: managePage.currentIndex === index + color: selected ? Theme.alpha(Theme.accent, 0.16) + : (navHover.hovered ? Theme.alpha(Theme.bgSurface, 0.6) + : "transparent") + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 12 + + Image { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + source: "image://adaptiveicon/" + modelData.icon + + "?d=" + (Theme.dark ? 1 : 0) + sourceSize.width: 20 + sourceSize.height: 20 + fillMode: Image.PreserveAspectFit + } + + Text { + Layout.fillWidth: true + text: modelData.label + color: navRow.selected ? Theme.accent : Theme.textBody + font.pixelSize: Theme.fontBody + font.weight: navRow.selected ? 600 : 400 + verticalAlignment: Text.AlignVCenter + } + } + + HoverHandler { id: navHover } + TapHandler { + onTapped: managePage.currentIndex = index + } + } + } + } + + Item { Layout.fillHeight: true } + } + } + + // ─── Content stack (lazy per section) ──────────────────────────── + StackLayout { + id: contentStack + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: managePage.currentIndex + + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: dashboardComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: librariesComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: collectionsComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: transcodingComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: usersComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: tasksComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: logsComponent + } + } + } + + Component { id: dashboardComponent; ManageDashboardPage { } } + Component { id: librariesComponent; AdminLibrariesPage { } } + Component { id: collectionsComponent; AdminCollectionsPage { } } + Component { id: transcodingComponent; AdminTranscodingPage { } } + Component { id: usersComponent; AdminUsersPage { } } + Component { id: tasksComponent; AdminTasksPage { } } + Component { id: logsComponent; AdminLogsPage { } } +} diff --git a/src/qEmbyApp/qml/MpvSpikeHarness.qml b/src/qEmbyApp/qml/MpvSpikeHarness.qml new file mode 100644 index 0000000..45c4947 --- /dev/null +++ b/src/qEmbyApp/qml/MpvSpikeHarness.qml @@ -0,0 +1,17 @@ +// 步骤 1 关卡用的独立窗口宿主,仅供 qEmbyMpvSpike 使用。 +import QtQuick +import QtQuick.Window + +Window { + id: win + width: 800 + height: 600 + visible: true + color: "#101418" + title: qsTr("MpvItem spike") + + MpvSpikePage { + objectName: "spikePage" + anchors.fill: parent + } +} diff --git a/src/qEmbyApp/qml/MpvSpikePage.qml b/src/qEmbyApp/qml/MpvSpikePage.qml new file mode 100644 index 0000000..ea27b1c --- /dev/null +++ b/src/qEmbyApp/qml/MpvSpikePage.qml @@ -0,0 +1,82 @@ +// 临时验证页:确认 MpvItem 能在 QML 场景图里出画面并响应 seek。 +// 步骤 1 的关卡通过后即可删除(正式页面是 PlayerPage.qml)。 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import qEmby + +Item { + id: root + + // 由宿主注入的测试媒体 URL。 + property string mediaUrl: "" + + // 供独立 spike 宿主调用(加载后立即播放)。 + function loadAndPlay(url) { + mpv.loadMedia(url) + mpv.play() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "black" + + MpvItem { + id: mpv + anchors.fill: parent + + onErrorOccurred: function (msg) { + statusLabel.text = qsTr("Error: ") + msg + } + } + } + + Text { + id: statusLabel + Layout.fillWidth: true + color: "white" + text: qsTr("position: %1 / %2 paused: %3") + .arg(mpv.position.toFixed(1)) + .arg(mpv.duration.toFixed(1)) + .arg(mpv.paused) + } + + RowLayout { + spacing: 8 + + Button { + text: qsTr("Load") + onClicked: mpv.loadMedia(urlField.text) + } + Button { + text: qsTr("Play") + onClicked: mpv.play() + } + Button { + text: qsTr("Pause") + onClicked: mpv.pause() + } + Button { + text: qsTr("Seek +30s") + onClicked: mpv.seek(mpv.position + 30) + } + Button { + text: qsTr("Stop") + onClicked: mpv.stop() + } + } + + TextField { + id: urlField + Layout.fillWidth: true + text: root.mediaUrl + placeholderText: qsTr("file path or URL") + } + } +} diff --git a/src/qEmbyApp/qml/PlayerPage.qml b/src/qEmbyApp/qml/PlayerPage.qml new file mode 100644 index 0000000..e9fa176 --- /dev/null +++ b/src/qEmbyApp/qml/PlayerPage.qml @@ -0,0 +1,1810 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import QtQuick.Window +import qEmby 1.0 +import "components" as Comp + +// Full QML player route (batches 0–6): +// MpvItem + danmaku surface + HUD + tracks/danmaku menus + switcher + +// long-press indicators + continuous play on EOF. Dialogs stay Widgets. +FocusScope { + id: root + + property var route: ({}) + property bool active: true + property string loadedUrl: "" + property bool resumeApplied: false + property bool chromeVisible: true + property string osdText: "" + property bool volumeOsdVisible: false + property bool statsVisible: false + property var statsLines: [] + property bool switcherVisible: false + property bool longPressing: false + property int longPressDirection: 0 // -1 left / +1 right + property real longPressBaseSpeed: 1.0 + // 缓冲:demuxer-cache-time 为当前位置之后的缓存秒数 + property real cacheAhead: 0 + property bool buffering: false + readonly property real bufferRatio: { + if (mpv.duration <= 0) + return 0 + return Math.max(0, Math.min(1, (mpv.position + cacheAhead) / mpv.duration)) + } + readonly property var speedSteps: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] + readonly property int seekStep: player.seekStepSeconds ? player.seekStepSeconds() : 10 + readonly property bool anySheetOpen: + audioSheet.visible || subSheet.visible || speedSheet.visible || danmakuSheet.visible + readonly property int playbackCursorShape: + (!chromeVisible && !mpv.paused && !longPressing && !anySheetOpen) + ? Qt.BlankCursor : Qt.ArrowCursor + + focus: true + + function closeSheets() { + audioSheet.close() + subSheet.close() + speedSheet.close() + danmakuSheet.close() + } + + function openSheet(sheet) { + closeSheets() + sheet.open() + wakeChrome() + } + + function refreshCacheState() { + if (!mpv || loadedUrl.length === 0) { + cacheAhead = 0 + buffering = false + return + } + var ahead = mpv.getProperty("demuxer-cache-time") + cacheAhead = (ahead !== undefined && ahead !== null && ahead > 0) ? Number(ahead) : 0 + var pfc = mpv.getProperty("paused-for-cache") + buffering = !!pfc + } + + // ── 播放器专用玻璃按钮 ─────────────────────────────────────────── + component ChromeBtn: Item { + id: cbtn + property string label: "" + property bool active: false + property bool prominent: false + property real hPad: 12 + property int fontPx: Theme.fontSmall + signal activated() + + implicitWidth: Math.max(prominent ? 44 : 36, labelText.implicitWidth + hPad * 2) + implicitHeight: prominent ? 44 : 34 + opacity: enabled ? 1 : 0.4 + scale: cMouse.pressed ? 0.92 : cMouse.containsMouse ? 1.05 : 1 + + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: cbtn.prominent + ? (cMouse.pressed ? Theme.alpha(Theme.accent, 0.95) + : cMouse.containsMouse ? Theme.accentHover : Theme.accent) + : cbtn.active + ? Theme.alpha(Theme.accent, 0.28) + : cMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.16) : Qt.rgba(1, 1, 1, 0.08) + border.width: cbtn.active && !cbtn.prominent ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.65) + + Behavior on color { + ColorAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + } + + Text { + id: labelText + anchors.centerIn: parent + text: cbtn.label + color: cbtn.prominent ? "#FFFFFF" + : cbtn.active ? Theme.accent : "#FFFFFF" + font.pixelSize: cbtn.fontPx + font.weight: cbtn.prominent || cbtn.active ? Font.DemiBold : Font.Medium + horizontalAlignment: Text.AlignHCenter + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + + MouseArea { + id: cMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: cbtn.activated() + } + } + + function maybeLoad() { + var url = player.streamUrl + if (!url || url === loadedUrl) + return + loadedUrl = url + resumeApplied = false + mpv.loadMedia(url, player.serverId) + mpv.play() + wakeChrome() + } + + function stopPlaybackSurface() { + // Always drop sheets first — open Popup+Overlay mid-destroy crashes. + closeSheets() + endLongPress() + statsVisible = false + volumeOsdVisible = false + switcherVisible = false + osdText = "" + if (loadedUrl.length === 0) + return + mpv.stop() + loadedUrl = "" + resumeApplied = false + } + + // Safe leave: close overlays, then queue VM back (C++ also queues). + function leavePlayer() { + closeSheets() + hideTimer.stop() + cacheTimer.stop() + statsTimer.stop() + // Unbind before navigation tears the scene down. + player.bindDanmakuSurface(null) + player.bindMpv(null) + stopPlaybackSurface() + player.requestBack() + } + + function wakeChrome() { + chromeVisible = true + if (!mpv.paused && !seekBar.pressed && !volumeBar.pressed + && !longPressing && !anySheetOpen && !switcherVisible) + hideTimer.restart() + else + hideTimer.stop() + } + + onChromeVisibleChanged: { + if (!chromeVisible) + closeSheets() + } + + function flashOsd(text) { + osdText = text + volumeOsdVisible = false + osdTimer.restart() + } + + function flashVolumeOsd() { + volumeOsdVisible = true + osdText = "" + volumeOsdTimer.restart() + wakeChrome() + } + + function togglePlay() { + if (mpv.paused) + mpv.play() + else + mpv.pause() + wakeChrome() + } + + function doSeekRelative(delta) { + var dur = mpv.duration > 0 ? mpv.duration : mpv.position + delta + var target = Math.max(0, Math.min(mpv.position + delta, dur)) + mpv.seek(target) + player.notifySeeked() + flashOsd((delta >= 0 ? "+" : "−") + Math.abs(delta) + "s " + + player.formatTime(target, mpv.duration)) + wakeChrome() + } + + function changeVolume(delta) { + var next = Math.max(0, Math.min(100, mpv.volume + delta)) + mpv.volume = next + if (next > 0 && mpv.muted) + mpv.muted = false + flashVolumeOsd() + } + + function cycleSpeed(dir) { + var steps = root.speedSteps + var idx = 0 + var best = 1e9 + for (var i = 0; i < steps.length; ++i) { + var d = Math.abs(steps[i] - mpv.speed) + if (d < best) { + best = d + idx = i + } + } + idx = Math.max(0, Math.min(steps.length - 1, idx + dir)) + mpv.speed = steps[idx] + flashOsd(qsTr("Speed %1×").arg(steps[idx])) + wakeChrome() + } + + function formatRate(bytesPerSec) { + if (!bytesPerSec || bytesPerSec <= 0) + return "0 B/s" + var units = ["B/s", "KB/s", "MB/s", "GB/s"] + var v = bytesPerSec + var i = 0 + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + ++i + } + return (i === 0 ? Math.round(v) : v.toFixed(1)) + " " + units[i] + } + + function refreshStats() { + if (!statsVisible) + return + var lines = [] + lines.push(qsTr("File") + ": " + (player.title || "—")) + lines.push(qsTr("Position") + ": " + + player.formatTime(mpv.position, mpv.duration) + + " / " + player.formatTime(mpv.duration, mpv.duration)) + lines.push(qsTr("Speed") + ": " + Number(mpv.speed).toFixed(2) + "×") + lines.push(qsTr("Volume") + ": " + + (mpv.muted ? qsTr("Muted") : (Math.round(mpv.volume) + "%"))) + var w = mpv.getProperty("width") + var h = mpv.getProperty("height") + if (w && h) + lines.push(qsTr("Video") + ": " + w + "×" + h) + var hwdec = mpv.getProperty("hwdec-current") + if (!hwdec || hwdec === "no") + hwdec = qsTr("Software (CPU)") + lines.push(qsTr("Decoder") + ": " + hwdec) + var vo = mpv.getProperty("current-vo") + if (vo) + lines.push(qsTr("VO") + ": " + vo) + var cacheDur = mpv.getProperty("demuxer-cache-time") + var cacheLine = formatRate(mpv.networkSpeed) + if (cacheDur && cacheDur > 0) + cacheLine += " · " + Number(cacheDur).toFixed(1) + "s" + if (mpv.relayActive) + cacheLine += " · " + qsTr("relay") + lines.push(qsTr("Cache") + ": " + cacheLine) + if (player.danmakuStatus) + lines.push(qsTr("Danmaku") + ": " + player.danmakuStatus) + root.statsLines = lines + } + + function toggleStats() { + statsVisible = !statsVisible + if (statsVisible) { + refreshStats() + statsTimer.start() + } else { + statsTimer.stop() + } + wakeChrome() + } + + function toggleFullscreen() { + var win = root.Window.window + if (!win) + return + if (win.visibility === Window.FullScreen) + win.showNormal() + else + win.showFullScreen() + wakeChrome() + } + + function toggleSwitcher() { + switcherVisible = !switcherVisible + if (switcherVisible) + player.ensureSwitcherLoaded() + wakeChrome() + } + + function startLongPress(direction) { + if (!player.longPressEnabled || longPressing) + return + longPressing = true + longPressDirection = direction || 1 + longPressBaseSpeed = mpv.speed + player.beginLongPressSpeed(longPressBaseSpeed) + if (player.longPressMode === "seek") { + longPressSeekTimer.start() + flashOsd(longPressDirection < 0 ? qsTr("« Seek") : qsTr("Seek »")) + } else { + mpv.speed = player.longPressSpeed + flashOsd(qsTr("Speed %1×").arg(player.longPressSpeed)) + } + } + + function endLongPress() { + if (!longPressing) + return + longPressing = false + longPressDirection = 0 + longPressSeekTimer.stop() + longPressArmTimer.stop() + if (player.longPressMode !== "seek") + mpv.speed = longPressBaseSpeed + player.endLongPressSpeed() + wakeChrome() + } + + Rectangle { anchors.fill: parent; color: "black" } + + MpvItem { + id: mpv + anchors.fill: parent + Component.onCompleted: player.bindMpv(mpv) + + onDurationChanged: function (duration) { + if (!root.resumeApplied && duration > 0 && player.startPosition > 0) { + root.resumeApplied = true + mpv.seek(player.startPosition) + } else if (!root.resumeApplied && duration > 0) { + root.resumeApplied = true + } + if (root.resumeApplied) + player.notifyLoaded() + } + onPositionChanged: function (position) { player.notifyPositionChanged(position) } + onPlaybackStateChanged: function (isPaused) { + player.notifyPausedChanged(isPaused) + root.wakeChrome() + } + onErrorOccurred: function (msg) { player.notifyError(msg) } + onEndOfFile: function (reason) { player.notifyPlaybackEnded(reason) } + onNetworkSpeedChanged: function () { if (root.statsVisible) root.refreshStats() } + } + + DanmakuPaintedItem { + id: danmaku + anchors.fill: parent + z: 2 + Component.onCompleted: player.bindDanmakuSurface(danmaku) + } + + // 暂停时中心轻量指示(chrome 隐藏时更明显) + Item { + anchors.centerIn: parent + z: 4 + width: 88 + height: 88 + opacity: (!root.buffering && mpv.paused) ? (root.chromeVisible ? 0.22 : 0.42) : 0 + scale: mpv.paused && !root.buffering ? 1 : 0.85 + visible: opacity > 0.01 + Behavior on opacity { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutBack + } + } + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: Qt.rgba(0, 0, 0, 0.35) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.12) + } + Text { + anchors.centerIn: parent + text: "▶" + color: "white" + font.pixelSize: 32 + font.weight: Font.DemiBold + leftPadding: 4 + } + } + + // 缓冲中指示 + Item { + anchors.centerIn: parent + z: 4 + width: 96 + height: 96 + opacity: root.buffering ? 1 : 0 + scale: root.buffering ? 1 : 0.88 + visible: opacity > 0.01 + Behavior on opacity { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: Qt.rgba(0, 0, 0, 0.42) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.4) + } + // 旋转环 + Rectangle { + id: bufferRing + anchors.centerIn: parent + width: 40 + height: 40 + radius: 20 + color: "transparent" + border.width: 3 + border.color: Theme.accent + // 用渐变缺口感:半透明遮罩叠一层 + opacity: 0.9 + RotationAnimator on rotation { + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + running: root.buffering + } + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.bottom + anchors.topMargin: 10 + text: qsTr("Buffering…") + color: Qt.rgba(1, 1, 1, 0.85) + font.pixelSize: Theme.fontSmall + font.weight: Font.Medium + } + } + + MouseArea { + id: wakeArea + anchors.fill: parent + z: 5 + hoverEnabled: true + acceptedButtons: Qt.LeftButton + preventStealing: true + property real pressX: 0 + property real pressY: 0 + + property int edgeDir: 0 + cursorShape: root.playbackCursorShape + + onPositionChanged: function (mouse) { + root.wakeChrome() + if (pressed && player.mouseEdgeLongPress) { + var edge = width * 0.08 + if (mouse.x < edge) { + edgeDir = -1 + longPressArmTimer.restart() + } else if (mouse.x > width - edge) { + edgeDir = 1 + longPressArmTimer.restart() + } + } + } + onPressed: function (mouse) { + pressX = mouse.x + pressY = mouse.y + edgeDir = 0 + if (player.longPressEnabled) + longPressArmTimer.restart() + } + onReleased: { + longPressArmTimer.stop() + if (root.longPressing) + root.endLongPress() + } + onClicked: function (mouse) { + if (root.longPressing) + return + if (Math.abs(mouse.x - pressX) < 6 && Math.abs(mouse.y - pressY) < 6) { + if (player.clickToPause === undefined || player.clickToPause()) + root.togglePlay() + else + root.wakeChrome() + } + } + onDoubleClicked: root.toggleFullscreen() + onPressAndHold: { + var dir = edgeDir !== 0 ? edgeDir : 1 + root.startLongPress(dir) + } + } + + // Long-press edge indicators + Rectangle { + id: leftLongPressIndicator + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 108 + height: 108 + radius: 54 + z: 28 + color: Qt.rgba(0, 0, 0, 0.48) + border.color: Theme.accent + border.width: 2 + opacity: (root.longPressing && root.longPressDirection < 0) ? 1 : 0 + scale: (root.longPressing && root.longPressDirection < 0) ? 1 : 0.8 + visible: opacity > 0.01 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutBack } + } + // 内侧柔光环 + Rectangle { + anchors.centerIn: parent + width: parent.width - 14 + height: width + radius: width / 2 + color: "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.35) + } + Text { + anchors.centerIn: parent + color: "white" + font.pixelSize: Theme.fontSubtitle + font.bold: true + text: player.longPressMode === "seek" + ? "««" + : (Number(player.longPressSpeed).toFixed(1) + "×") + } + } + Rectangle { + id: rightLongPressIndicator + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + width: 108 + height: 108 + radius: 54 + z: 28 + color: Qt.rgba(0, 0, 0, 0.48) + border.color: Theme.accent + border.width: 2 + opacity: (root.longPressing && root.longPressDirection > 0) ? 1 : 0 + scale: (root.longPressing && root.longPressDirection > 0) ? 1 : 0.8 + visible: opacity > 0.01 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutBack } + } + Rectangle { + anchors.centerIn: parent + width: parent.width - 14 + height: width + radius: width / 2 + color: "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.35) + } + Text { + anchors.centerIn: parent + color: "white" + font.pixelSize: Theme.fontSubtitle + font.bold: true + text: player.longPressMode === "seek" + ? "»»" + : (Number(player.longPressSpeed).toFixed(1) + "×") + } + } + + Timer { + id: longPressArmTimer + interval: Math.max(200, player.longPressTriggerMs || 350) + onTriggered: root.startLongPress(wakeArea.edgeDir !== 0 ? wakeArea.edgeDir : 1) + } + Timer { + id: longPressSeekTimer + interval: 120 + repeat: true + onTriggered: root.doSeekRelative(2 * (root.longPressDirection < 0 ? -1 : 1)) + } + Timer { + id: hideTimer + interval: 3200 + onTriggered: { + if (!mpv.paused && !seekBar.pressed && !volumeBar.pressed + && !root.longPressing && !root.switcherVisible + && !root.anySheetOpen) { + root.chromeVisible = false + } + } + } + Timer { id: osdTimer; interval: 1200; onTriggered: root.osdText = "" } + Timer { id: volumeOsdTimer; interval: 1200; onTriggered: root.volumeOsdVisible = false } + Timer { + id: statsTimer + interval: 500 + repeat: true + running: false + onTriggered: root.refreshStats() + } + // 缓冲进度轮询(轻量) + Timer { + id: cacheTimer + interval: 500 + repeat: true + // 仅在需要展示缓冲条/缓冲态时轮询,减无效 getProperty + running: root.active && root.loadedUrl.length > 0 + && (root.chromeVisible || root.buffering) + onTriggered: root.refreshCacheState() + } + + // ── 玻璃弹层(音轨 / 字幕 / 倍速 / 弹幕)共用壳 ────────────────── + component GlassSheet: Popup { + id: sheet + property string title: "" + // 子项写入 body 列(音轨行 / 倍速芯片等) + default property alias sheetBody: bodyColumn.data + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + width: Math.min(340, root.width - 48) + padding: 0 + // 贴底栏上方居中 + x: (root.width - width) / 2 + y: Math.max(24, root.height - bottomChrome.height - implicitHeight - 28) + transformOrigin: Item.Bottom + + enter: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: 0.94 + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } + exit: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } + NumberAnimation { + property: "scale" + from: 1 + to: 0.96 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } + } + } + + onOpened: root.wakeChrome() + onClosed: root.wakeChrome() + + background: Rectangle { + radius: 16 + color: Qt.rgba(0.06, 0.08, 0.12, 0.94) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.12) + // 顶部 accent 线 + Rectangle { + anchors { left: parent.left; right: parent.right; top: parent.top } + height: 1 + color: Theme.alpha(Theme.accent, 0.35) + } + } + + contentItem: ColumnLayout { + spacing: 0 + width: sheet.width + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 12 + Layout.topMargin: 14 + Layout.bottomMargin: 10 + spacing: 10 + Rectangle { + width: 3 + height: 14 + radius: 1.5 + color: Theme.accent + } + Text { + Layout.fillWidth: true + text: sheet.title + color: "white" + font.pixelSize: Theme.fontBodyPlus + font.weight: Font.DemiBold + } + ChromeBtn { + label: "✕" + hPad: 10 + onActivated: sheet.close() + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(1, 1, 1, 0.08) + } + + ColumnLayout { + id: bodyColumn + Layout.fillWidth: true + Layout.margins: 10 + spacing: 4 + } + } + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.28) + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast } + } + } + } + + component SheetRow: Rectangle { + id: srow + property string label: "" + property bool selected: false + property bool enabledRow: true + signal activated() + + Layout.fillWidth: true + Layout.preferredHeight: 40 + radius: 10 + opacity: enabledRow ? 1 : 0.4 + color: selected ? Theme.alpha(Theme.accent, 0.28) + : rowMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.1) + : "transparent" + border.width: selected ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.55) + scale: rowMouse.pressed && enabledRow ? 0.98 : 1 + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + // 选中指示 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: srow.selected ? Theme.accent : Qt.rgba(1, 1, 1, 0.25) + } + Text { + Layout.fillWidth: true + text: srow.label + color: srow.selected ? Theme.accent : "white" + font.pixelSize: Theme.fontBody + font.weight: srow.selected ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + } + } + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + enabled: srow.enabledRow + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: srow.activated() + } + } + + // OSD + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: parent.height * 0.18 + width: osdLabel.implicitWidth + 44 + height: osdLabel.implicitHeight + 22 + radius: 14 + color: Qt.rgba(0, 0, 0, 0.58) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.1) + visible: opacity > 0.01 + opacity: root.osdText.length > 0 ? 1 : 0 + scale: root.osdText.length > 0 ? 1 : 0.92 + z: 30 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutBack } + } + Text { + id: osdLabel + anchors.centerIn: parent + text: root.osdText + color: "white" + font.pixelSize: Theme.fontSubtitle + font.bold: true + } + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 72 + width: 300 + height: 48 + radius: 14 + color: Qt.rgba(0, 0, 0, 0.68) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.08) + visible: opacity > 0.01 + opacity: root.volumeOsdVisible ? 1 : 0 + scale: root.volumeOsdVisible ? 1 : 0.94 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + z: 30 + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + Text { + text: mpv.muted || mpv.volume <= 0 ? "🔇" : (mpv.volume < 50 ? "🔉" : "🔊") + color: "white" + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 5 + radius: 2.5 + color: Qt.rgba(1, 1, 1, 0.16) + Rectangle { + width: parent.width * Math.max(0, Math.min(1, mpv.muted ? 0 : mpv.volume / 100)) + height: parent.height + radius: parent.radius + color: Theme.accent + Behavior on width { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + } + } + Text { + text: mpv.muted ? qsTr("Mute") : (Math.round(mpv.volume) + "%") + color: "white" + font.pixelSize: Theme.fontSmall + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 16 + width: Math.min(parent.width - 32, 520) + height: statsCol.implicitHeight + 24 + radius: 12 + color: Qt.rgba(0, 0, 0, 0.7) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.08) + visible: opacity > 0.01 + opacity: root.statsVisible ? 1 : 0 + scale: root.statsVisible ? 1 : 0.96 + z: 25 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Column { + id: statsCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 } + spacing: 4 + Repeater { + model: root.statsLines + Text { + width: statsCol.width + text: modelData + color: "white" + font.pixelSize: Theme.fontSmall + font.family: "monospace" + wrapMode: Text.WrapAnywhere + } + } + } + } + + // Top chrome + Rectangle { + id: topChrome + anchors { left: parent.left; right: parent.right; top: parent.top } + height: 72 + opacity: root.chromeVisible ? 1 : 0 + anchors.topMargin: root.chromeVisible ? 0 : -16 + visible: opacity > 0.01 + z: 20 + Behavior on opacity { + NumberAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + Behavior on anchors.topMargin { + NumberAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.72) } + GradientStop { position: 0.55; color: Qt.rgba(0, 0, 0, 0.28) } + GradientStop { position: 1.0; color: "transparent" } + } + RowLayout { + anchors { + left: parent.left; right: parent.right; top: parent.top + leftMargin: 16; rightMargin: 16; topMargin: 12 + } + spacing: 12 + + ChromeBtn { + label: "‹ " + qsTr("Back") + hPad: 14 + fontPx: Theme.fontBody + onActivated: root.leavePlayer() + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Text { + Layout.fillWidth: true + text: player.title + color: "white" + font.pixelSize: Theme.fontSubtitle + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: text.length > 0 + text: { + const pos = player.formatTime(mpv.position, mpv.duration) + const dur = player.formatTime(mpv.duration, mpv.duration) + return mpv.duration > 0 ? (pos + " · " + dur) : "" + } + color: Qt.rgba(1, 1, 1, 0.55) + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + } + } + + ChromeBtn { + label: "i" + active: root.statsVisible + hPad: 14 + onActivated: root.toggleStats() + } + ChromeBtn { + label: "⛶" + hPad: 12 + onActivated: root.toggleFullscreen() + } + } + } + + // Media switcher drawer + Rectangle { + id: switcherDrawer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: bottomChrome.top + height: root.switcherVisible ? Math.min(parent.height * 0.44, 340) : 0 + color: Qt.rgba(0, 0, 0, 0.88) + clip: true + z: 22 + // 顶部分隔线 + Rectangle { + anchors { left: parent.left; right: parent.right; top: parent.top } + height: 1 + color: Qt.rgba(1, 1, 1, 0.1) + visible: root.switcherVisible + } + Behavior on height { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + opacity: root.switcherVisible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Rectangle { + width: 3 + height: 16 + radius: 1.5 + color: Theme.accent + } + Text { + text: player.switcherTitle + color: "white" + font.pixelSize: Theme.fontSubtitle + font.weight: Font.DemiBold + Layout.fillWidth: true + } + Text { + visible: player.switcherLoading + text: qsTr("Loading…") + color: Qt.rgba(1, 1, 1, 0.5) + font.pixelSize: Theme.fontSmall + } + ChromeBtn { + label: "✕" + hPad: 12 + onActivated: root.switcherVisible = false + } + } + + ListView { + visible: player.switcherSeriesMode && player.switcherSeasons.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: 34 + orientation: ListView.Horizontal + spacing: 6 + model: player.switcherSeasons + clip: true + delegate: Rectangle { + id: seasonChip + required property var modelData + readonly property bool selected: modelData.id === player.selectedSeasonId + width: seasonLabel.implicitWidth + 18 + height: 32 + radius: height / 2 + color: selected + ? Theme.alpha(Theme.accent, 0.35) + : seasonMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.14) + : Qt.rgba(1, 1, 1, 0.08) + border.width: selected ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.7) + scale: seasonMouse.pressed ? 0.96 : 1 + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } + } + Text { + id: seasonLabel + anchors.centerIn: parent + text: modelData.name || modelData.title + color: seasonChip.selected ? Theme.accent : "white" + font.pixelSize: Theme.fontSmall + font.weight: seasonChip.selected ? Font.DemiBold : Font.Normal + } + MouseArea { + id: seasonMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: player.selectSeason(modelData.id) + } + } + } + + ListView { + id: switcherList + Layout.fillWidth: true + Layout.fillHeight: true + orientation: ListView.Horizontal + spacing: 12 + clip: true + model: player.switcherSeriesMode ? player.switcherEpisodes : player.switcherItems + boundsBehavior: Flickable.StopAtBounds + + add: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationFast + } + } + + delegate: Rectangle { + id: epCard + required property var modelData + readonly property bool current: !!modelData.current + width: 148 + height: ListView.view ? ListView.view.height - 4 : 170 + radius: 12 + color: current ? Theme.alpha(Theme.accent, 0.22) + : epMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.12) + : Qt.rgba(1, 1, 1, 0.06) + border.color: current ? Theme.accent + : epMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.2) + : "transparent" + border.width: 1 + scale: epMouse.pressed ? 0.96 : epMouse.containsMouse ? 1.03 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Item { + width: parent.width + height: width * 9 / 16 + clip: true + + Rectangle { + anchors.fill: parent + radius: 8 + color: Qt.rgba(1, 1, 1, 0.06) + } + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + asynchronous: true + source: modelData.primaryImageTag + ? ("image://emby/" + modelData.id + "/Primary?tag=" + + modelData.primaryImageTag + "&maxHeight=180") + : "" + opacity: status === Image.Ready ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast } + } + } + // 当前集角标 + Rectangle { + visible: epCard.current + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 6 + width: nowLabel.implicitWidth + 10 + height: 18 + radius: 9 + color: Theme.accent + Text { + id: nowLabel + anchors.centerIn: parent + text: qsTr("Now") + color: "white" + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } + } + Text { + width: parent.width + text: modelData.title || modelData.name + color: "white" + font.pixelSize: Theme.fontSmall + font.weight: epCard.current ? Font.DemiBold : Font.Normal + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + } + MouseArea { + id: epMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + player.playSwitcherItem(modelData.id, + modelData.title || modelData.name, + modelData.startPosition || 0) + root.switcherVisible = false + } + } + } + } + } + } + + // Bottom chrome + Rectangle { + id: bottomChrome + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 128 + opacity: root.chromeVisible ? 1 : 0 + anchors.bottomMargin: root.chromeVisible ? 0 : -20 + visible: opacity > 0.01 + z: 20 + Behavior on opacity { + NumberAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + Behavior on anchors.bottomMargin { + NumberAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 0.35; color: Qt.rgba(0, 0, 0, 0.35) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.82) } + } + + ColumnLayout { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + anchors.leftMargin: 18 + anchors.rightMargin: 18 + anchors.bottomMargin: 14 + spacing: 10 + + // 进度条 + 悬浮时间气泡 + Item { + Layout.fillWidth: true + Layout.preferredHeight: 28 + + Comp.ThemedSlider { + id: seekBar + anchors.fill: parent + playerStyle: true + bufferPosition: root.bufferRatio + from: 0 + to: mpv.duration > 0 ? mpv.duration : 1 + enabled: mpv.duration > 0 + Binding { + target: seekBar + property: "value" + value: mpv.position + when: !seekBar.pressed + restoreMode: Binding.RestoreNone + } + onPressedChanged: { + if (pressed) + hideTimer.stop() + else { + mpv.seek(value) + player.notifySeeked() + root.wakeChrome() + } + } + HoverHandler { + id: seekHover + enabled: mpv.duration > 0 + } + } + + // 悬停时间气泡(含缓冲提示) + Rectangle { + id: seekBubble + visible: seekHover.hovered && mpv.duration > 0 + property real ratio: Math.max(0, Math.min(1, + seekHover.point.position.x / Math.max(1, seekBar.width))) + property real hoverTime: ratio * (mpv.duration > 0 ? mpv.duration : 0) + property bool inBuffer: ratio <= root.bufferRatio + 0.001 + width: bubbleCol.implicitWidth + 20 + height: bubbleCol.implicitHeight + 14 + radius: 12 + color: Qt.rgba(0, 0, 0, 0.84) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.12) + y: -height - 8 + x: Math.max(0, Math.min(ratio * seekBar.width - width / 2, seekBar.width - width)) + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.9 + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Column { + id: bubbleCol + anchors.centerIn: parent + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: player.formatTime(seekBubble.hoverTime, mpv.duration) + color: "white" + font.pixelSize: Theme.fontSmall + font.weight: Font.DemiBold + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + visible: root.cacheAhead > 0.5 + text: seekBubble.inBuffer ? qsTr("Buffered") : qsTr("Not buffered") + color: seekBubble.inBuffer + ? Theme.alpha(Theme.success, 0.95) + : Qt.rgba(1, 1, 1, 0.45) + font.pixelSize: 10 + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + // 左:上/下集 + 播放 + ChromeBtn { + label: "⏮" + hPad: 12 + fontPx: Theme.fontBody + onActivated: player.playAdjacent(-1) + } + ChromeBtn { + label: mpv.paused ? "▶" : "∥" + prominent: true + fontPx: Theme.fontSubtitle + onActivated: root.togglePlay() + } + ChromeBtn { + label: "⏭" + hPad: 12 + fontPx: Theme.fontBody + onActivated: player.playAdjacent(1) + } + + Text { + text: player.formatTime(seekBar.pressed ? seekBar.value : mpv.position, mpv.duration) + + " / " + player.formatTime(mpv.duration, mpv.duration) + color: Qt.rgba(1, 1, 1, 0.88) + font.pixelSize: Theme.fontSmall + font.weight: Font.Medium + Layout.leftMargin: 4 + } + + Item { Layout.fillWidth: true } + + // 右:音量 / 倍速 / 轨道 / 弹幕 / 选集 + ChromeBtn { + label: mpv.muted || mpv.volume <= 0 ? "🔇" : "🔊" + hPad: 10 + onActivated: { mpv.toggleMute(); root.flashVolumeOsd() } + } + Comp.ThemedSlider { + id: volumeBar + Layout.preferredWidth: 88 + playerStyle: true + from: 0 + to: 100 + Binding { + target: volumeBar + property: "value" + value: mpv.muted ? 0 : mpv.volume + when: !volumeBar.pressed + restoreMode: Binding.RestoreNone + } + onPressedChanged: { + if (!pressed) { + mpv.volume = value + if (value > 0 && mpv.muted) + mpv.muted = false + root.flashVolumeOsd() + root.wakeChrome() + } + } + onMoved: { + if (pressed) { + mpv.volume = value + if (value > 0 && mpv.muted) + mpv.muted = false + } + } + } + + ChromeBtn { + label: Number(mpv.speed).toFixed(mpv.speed % 1 === 0 ? 0 : 2) + "×" + hPad: 10 + active: speedSheet.visible + onActivated: root.openSheet(speedSheet) + } + ChromeBtn { + label: qsTr("Audio") + active: audioSheet.visible + onActivated: root.openSheet(audioSheet) + } + ChromeBtn { + label: qsTr("Subs") + active: subSheet.visible + onActivated: root.openSheet(subSheet) + } + ChromeBtn { + label: qsTr("弹幕") + active: player.danmakuVisible || danmakuSheet.visible + onActivated: root.openSheet(danmakuSheet) + } + ChromeBtn { + label: qsTr("Episodes") + active: root.switcherVisible + onActivated: { + root.closeSheets() + root.toggleSwitcher() + } + } + } + } + } + + // ── 音轨 ────────────────────────────────────────────────────────── + GlassSheet { + id: audioSheet + parent: root + title: qsTr("Audio") + + SheetRow { + label: qsTr("Disable Audio") + selected: player.currentAudioId < 0 + onActivated: { + player.disableAudio() + audioSheet.close() + root.flashOsd(qsTr("Audio off")) + } + } + Repeater { + model: player.audioTracks + SheetRow { + required property var modelData + label: modelData.label || ("#" + modelData.id) + selected: !!modelData.selected || modelData.id === player.currentAudioId + onActivated: { + player.selectAudioTrack(modelData.id) + audioSheet.close() + root.flashOsd(label) + } + } + } + } + + // ── 字幕 ────────────────────────────────────────────────────────── + GlassSheet { + id: subSheet + parent: root + title: qsTr("Subtitles") + + SheetRow { + label: qsTr("Disable Subtitles") + selected: player.currentSubtitleId < 0 + onActivated: { + player.disableSubtitles() + subSheet.close() + root.flashOsd(qsTr("Subtitles off")) + } + } + SheetRow { + label: qsTr("Subtitle settings…") + onActivated: { + subSheet.close() + player.openSubtitleSettings() + } + } + Repeater { + model: player.subtitleTracks + SheetRow { + required property var modelData + label: modelData.label || ("#" + modelData.id) + selected: !!modelData.selected || modelData.id === player.currentSubtitleId + onActivated: { + player.selectSubtitleTrack(modelData.id) + subSheet.close() + root.flashOsd(label) + } + } + } + } + + // ── 倍速 ────────────────────────────────────────────────────────── + GlassSheet { + id: speedSheet + parent: root + title: qsTr("Playback Speed") + + Flow { + Layout.fillWidth: true + spacing: 8 + Repeater { + model: root.speedSteps + Rectangle { + required property real modelData + readonly property bool selected: Math.abs(mpv.speed - modelData) < 0.01 + width: 72 + height: 36 + radius: 18 + color: selected ? Theme.accent + : speedChipMouse.containsMouse ? Qt.rgba(1, 1, 1, 0.14) + : Qt.rgba(1, 1, 1, 0.08) + border.width: selected ? 0 : 1 + border.color: Qt.rgba(1, 1, 1, 0.12) + scale: speedChipMouse.pressed ? 0.94 : 1 + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } + } + Text { + anchors.centerIn: parent + text: modelData + "×" + color: "white" + font.pixelSize: Theme.fontBody + font.weight: selected ? Font.DemiBold : Font.Medium + } + MouseArea { + id: speedChipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + mpv.speed = modelData + speedSheet.close() + root.flashOsd(qsTr("Speed %1×").arg(modelData)) + root.wakeChrome() + } + } + } + } + } + } + + // ── 弹幕 ────────────────────────────────────────────────────────── + GlassSheet { + id: danmakuSheet + parent: root + title: qsTr("Danmaku") + + SheetRow { + label: player.danmakuEnabled ? qsTr("Disable danmaku") : qsTr("Enable danmaku") + selected: player.danmakuEnabled + onActivated: { + player.setDanmakuEnabled(!player.danmakuEnabled) + root.flashOsd(player.danmakuEnabled ? qsTr("Danmaku on") : qsTr("Danmaku off")) + } + } + SheetRow { + label: player.danmakuVisible ? qsTr("Hide danmaku") : qsTr("Show danmaku") + enabledRow: player.danmakuEnabled + selected: player.danmakuVisible + onActivated: { + player.setDanmakuVisible(!player.danmakuVisible) + danmakuSheet.close() + } + } + SheetRow { + label: qsTr("Reload") + onActivated: { + player.reloadDanmaku() + danmakuSheet.close() + root.flashOsd(qsTr("Reloading danmaku…")) + } + } + SheetRow { + label: qsTr("Identify…") + onActivated: { + danmakuSheet.close() + player.openDanmakuIdentify() + } + } + SheetRow { + label: qsTr("Danmaku settings…") + onActivated: { + danmakuSheet.close() + player.openDanmakuSettings() + } + } + Text { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + text: player.danmakuStatus || qsTr("No status") + color: Qt.rgba(1, 1, 1, 0.45) + font.pixelSize: Theme.fontSmall + wrapMode: Text.WordWrap + } + } + + // Toast + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: bottomChrome.top + anchors.bottomMargin: 14 + width: toastLabel.implicitWidth + 28 + height: toastLabel.implicitHeight + 16 + radius: 14 + color: Qt.rgba(0, 0, 0, 0.78) + border.width: 1 + border.color: Qt.rgba(1, 1, 1, 0.1) + visible: opacity > 0.01 + opacity: toastLabel.text.length > 0 ? 1 : 0 + scale: toastLabel.text.length > 0 ? 1 : 0.94 + z: 40 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Text { + id: toastLabel + anchors.centerIn: parent + color: "white" + font.pixelSize: Theme.fontSmall + font.weight: Font.Medium + } + Timer { + id: toastTimer + interval: 2200 + onTriggered: toastLabel.text = "" + } + } + + Keys.onEscapePressed: { + if (root.anySheetOpen) + root.closeSheets() + else if (root.switcherVisible) + root.switcherVisible = false + else + root.leavePlayer() + } + Keys.onSpacePressed: root.togglePlay() + Keys.onLeftPressed: { + if (event.isAutoRepeat && player.longPressEnabled) + root.startLongPress(-1) + else + root.doSeekRelative(-root.seekStep) + } + Keys.onRightPressed: { + if (event.isAutoRepeat && player.longPressEnabled) + root.startLongPress(1) + else + root.doSeekRelative(root.seekStep) + } + Keys.onReleased: function (event) { + if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) + root.endLongPress() + } + Keys.onUpPressed: root.changeVolume(5) + Keys.onDownPressed: root.changeVolume(-5) + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Home) { + mpv.seek(0); player.notifySeeked(); root.wakeChrome(); event.accepted = true + } else if (event.key === Qt.Key_M) { + mpv.toggleMute(); root.flashVolumeOsd(); event.accepted = true + } else if (event.key === Qt.Key_F) { + root.toggleFullscreen(); event.accepted = true + } else if (event.key === Qt.Key_I) { + root.toggleStats(); event.accepted = true + } else if (event.key === Qt.Key_E) { + root.toggleSwitcher(); event.accepted = true + } else if (event.key === Qt.Key_D) { + player.setDanmakuVisible(!player.danmakuVisible); event.accepted = true + } else if (event.key === Qt.Key_BracketLeft) { + root.cycleSpeed(-1); event.accepted = true + } else if (event.key === Qt.Key_BracketRight) { + root.cycleSpeed(1); event.accepted = true + } else if (event.key === Qt.Key_PageUp) { + player.playAdjacent(-1); event.accepted = true + } else if (event.key === Qt.Key_PageDown) { + player.playAdjacent(1); event.accepted = true + } else if (event.key === Qt.Key_Period || event.key === Qt.Key_Greater) { + root.doSeekRelative(30); event.accepted = true + } else if (event.key === Qt.Key_Comma || event.key === Qt.Key_Less) { + root.doSeekRelative(-30); event.accepted = true + } + } + + Connections { + target: player + function onStateChanged() { root.maybeLoad() } + function onReloadMpvRequested() { + root.loadedUrl = "" + root.maybeLoad() + } + function onToastRequested(message) { + toastLabel.text = message + toastTimer.restart() + } + function onDanmakuChanged() { + if (root.statsVisible) + root.refreshStats() + } + } + + onActiveChanged: { + if (active) { + root.forceActiveFocus() + root.wakeChrome() + player.bindMpv(mpv) + player.bindDanmakuSurface(danmaku) + root.maybeLoad() + } else { + // Route is leaving: unbind C++ refs before items are deleted. + closeSheets() + player.bindDanmakuSurface(null) + player.bindMpv(null) + root.stopPlaybackSurface() + } + } + + Component.onCompleted: { + player.bindMpv(mpv) + player.bindDanmakuSurface(danmaku) + maybeLoad() + root.forceActiveFocus() + } + Component.onDestruction: { + closeSheets() + // Belt-and-suspenders if onActiveChanged(false) was skipped. + player.bindDanmakuSurface(null) + player.bindMpv(null) + stopPlaybackSurface() + } +} diff --git a/src/qEmbyApp/qml/Router.qml b/src/qEmbyApp/qml/Router.qml index b03fae1..74ff6cc 100644 --- a/src/qEmbyApp/qml/Router.qml +++ b/src/qEmbyApp/qml/Router.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts +import qEmby 1.0 Item { id: router @@ -9,8 +10,9 @@ Item { property var navigationController: navigation // Keep this allow-list deliberately small until each page's context // ViewModel has been moved to the root Quick host. - property var enabledRoutes: ["home", "favorites", "search", "category", - "library", "person", "filtered"] + property var enabledRoutes: ["login", "home", "favorites", "search", + "category", "library", "person", "filtered", + "detail", "season", "player", "settings", "manage"] property var currentRoute: navigationController ? navigationController.currentRoute : ({ "name": "navigationError", @@ -70,9 +72,11 @@ Item { } function routeImplemented(name) { - return name === "home" || name === "favorites" || name === "search" - || name === "category" || name === "library" || name === "person" - || name === "filtered"; + return name === "login" || name === "home" || name === "favorites" + || name === "search" || name === "category" || name === "library" + || name === "person" || name === "filtered" + || name === "detail" || name === "season" || name === "player" + || name === "settings" || name === "manage"; } function routeKnown(name) { @@ -103,6 +107,7 @@ Item { return null; switch (name) { + case "login": return loginComponent; case "home": return homeComponent; case "favorites": return favoritesComponent; case "search": return searchComponent; @@ -111,6 +116,11 @@ Item { case "person": case "filtered": return libraryComponent; + case "detail": return detailComponent; + case "season": return seasonComponent; + case "player": return playerComponent; + case "settings": return settingsComponent; + case "manage": return manageComponent; default: return null; } @@ -121,8 +131,7 @@ Item { if (route && route.name === "navigationError") { recoveryTitle = qsTr("Navigation error"); recoveryMessage = params.message || params.code || qsTr("The page could not be opened."); - } else if (route && (route.name === "person" || route.name === "filtered" - || route.name === "player")) { + } else if (route && (route.name === "person" || route.name === "filtered")) { recoveryTitle = qsTr("Page migration in progress"); recoveryMessage = qsTr("This page is not available in the QML shell yet."); } else { @@ -170,6 +179,17 @@ Item { loadedRoute = nextRoute; loadedRouteKey = nextKey; requestedComponent = nextComponent; + pageEntrance.stop(); + // reduceMotion:跳过缩放/位移,只做瞬时切页 + if (Theme.reduceMotion || nextRoute.name === "player") { + pageLoader.opacity = 1; + pageLoader.scale = 1; + pageTranslate.y = 0; + } else { + pageLoader.opacity = 0; + pageLoader.scale = 0.978; + pageTranslate.y = 12; + } pageLoader.sourceComponent = null; if (nextComponent) { @@ -222,8 +242,22 @@ Item { id: pageLoader anchors.fill: parent asynchronous: false + transformOrigin: Item.Center + transform: Translate { + id: pageTranslate + y: 0 + } - onLoaded: router.activateLoadedPage() + onLoaded: { + router.activateLoadedPage(); + if (Theme.reduceMotion || (router.loadedRoute && router.loadedRoute.name === "player")) { + pageLoader.opacity = 1; + pageLoader.scale = 1; + pageTranslate.y = 0; + } else { + pageEntrance.start(); + } + } onStatusChanged: { if (status !== Loader.Error) return; @@ -233,18 +267,49 @@ Item { } } + ParallelAnimation { + id: pageEntrance + NumberAnimation { + target: pageLoader + property: "opacity" + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + target: pageLoader + property: "scale" + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: pageTranslate + property: "y" + to: 0 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + + Component { id: loginComponent; LoginPage { anchors.fill: parent } } Component { id: homeComponent; DashboardPage { anchors.fill: parent } } Component { id: favoritesComponent; FavoritesPage { anchors.fill: parent } } Component { id: searchComponent; SearchPage { anchors.fill: parent } } Component { id: categoryComponent; CategoryPage { anchors.fill: parent } } Component { id: libraryComponent; LibraryPage { anchors.fill: parent } } + Component { id: detailComponent; DetailPage { anchors.fill: parent } } + Component { id: seasonComponent; SeasonPage { anchors.fill: parent } } + Component { id: playerComponent; PlayerPage { anchors.fill: parent } } + Component { id: settingsComponent; SettingsPage { anchors.fill: parent } } + Component { id: manageComponent; ManagePage { anchors.fill: parent } } Component { id: recoveryComponent Rectangle { anchors.fill: parent - color: "#12151b" + color: Theme.bgBase ColumnLayout { anchors.centerIn: parent @@ -254,7 +319,7 @@ Item { Label { Layout.fillWidth: true text: router.recoveryTitle - color: "#f3f4f6" + color: Theme.textPrimary font.pixelSize: 24 font.bold: true horizontalAlignment: Text.AlignHCenter @@ -264,7 +329,7 @@ Item { Label { Layout.fillWidth: true text: router.recoveryMessage - color: "#b8bec9" + color: Theme.textMuted font.pixelSize: 15 horizontalAlignment: Text.AlignHCenter wrapMode: Text.Wrap diff --git a/src/qEmbyApp/qml/SearchPage.qml b/src/qEmbyApp/qml/SearchPage.qml index 44a21cb..6ae4300 100644 --- a/src/qEmbyApp/qml/SearchPage.qml +++ b/src/qEmbyApp/qml/SearchPage.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts import qEmby 1.0 +import "components" Item { id: page @@ -20,69 +21,35 @@ Item { Rectangle { anchors.fill: parent color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } } ColumnLayout { anchors.fill: parent spacing: 0 - RowLayout { + PageHeader { Layout.fillWidth: true - Layout.leftMargin: 20 - Layout.rightMargin: 20 - Layout.topMargin: 14 - Layout.bottomMargin: 10 - spacing: Theme.spacingLg + title: searchPage.title + horizontalPadding: 20 + topPadding: 14 + bottomPadding: 12 - Text { - text: searchPage.title - color: Theme.textPrimary - font.pixelSize: Theme.fontTitle - font.weight: Font.Bold - elide: Text.ElideRight - Layout.maximumWidth: 360 - } - - RowLayout { - spacing: Theme.spacingSm - - Repeater { - model: page.tabLabels - - delegate: Rectangle { - required property int index - required property string modelData - implicitWidth: tabText.implicitWidth + 24 - implicitHeight: 32 - radius: Theme.radiusMd - color: searchPage.currentTab === index - ? Theme.alpha(Theme.accent, 0.18) - : tabArea.containsMouse ? Theme.bgRaised : "transparent" - border.color: searchPage.currentTab === index - ? Theme.alpha(Theme.accent, 0.45) : "transparent" - - Text { - id: tabText - anchors.centerIn: parent - text: modelData - color: searchPage.currentTab === index ? Theme.accent : Theme.textBody - font.pixelSize: Theme.fontBody - font.weight: searchPage.currentTab === index ? Font.DemiBold : Font.Normal - } - - MouseArea { - id: tabArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: searchPage.currentTab = index - } - } + middle: SegmentedTabs { + id: searchTabs + model: page.tabLabels + currentIndex: searchPage.currentTab + onCurrentIndexChanged: { + if (searchPage.currentTab !== currentIndex) + searchPage.currentTab = currentIndex } } - Item { Layout.fillWidth: true } - ThemedButton { text: searchPage.tileView ? qsTr("Poster view") : qsTr("Wide view") onClicked: searchPage.tileView = !searchPage.tileView @@ -93,13 +60,16 @@ Item { color: Theme.textMuted font.pixelSize: Theme.fontBody horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter } } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.borderSoft + Connections { + target: searchPage + function onCurrentTabChanged() { + if (searchTabs.currentIndex !== searchPage.currentTab) + searchTabs.currentIndex = searchPage.currentTab + } } Item { @@ -118,12 +88,23 @@ Item { cellWidth: page.cellWidth cellHeight: page.cellHeight boundsBehavior: Flickable.StopAtBounds + reuseItems: true + cacheBuffer: Math.max(page.cellHeight * 1.5, 180) + flickDeceleration: 3000 + maximumFlickVelocity: 4000 + pixelAligned: true + highlightMoveDuration: 0 + + readonly property bool listBusy: moving || flicking ScrollBar.vertical: ThemedScrollBar { } + add: Transition { enabled: false } + delegate: Item { id: cardCell required property int index + required property string itemId required property string name required property string subtitle required property string itemType @@ -134,145 +115,33 @@ Item { width: results.cellWidth height: results.cellHeight - Rectangle { - id: card + MediaCard { width: page.cardWidth - height: page.imageHeight + 58 - radius: Theme.radiusLg - color: cardHover.hovered ? Theme.bgRaised : Theme.bgSurface - border.color: cardHover.hovered - ? Theme.alpha(Theme.accent, 0.45) : Theme.borderSoft - clip: true - - Behavior on color { ColorAnimation { duration: Theme.animationFast } } - - Rectangle { - id: imageFrame - z: 1 - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - height: page.imageHeight - color: Theme.bgRaised - - Image { - id: mediaImage - anchors.fill: parent - source: searchPage.tileView ? cardCell.thumbSource : cardCell.posterSource - sourceSize.width: Math.ceil(page.cardWidth * 1.5) - asynchronous: true - cache: true - fillMode: Image.PreserveAspectCrop - } - - Text { - anchors.centerIn: parent - visible: mediaImage.status !== Image.Ready - text: cardCell.itemType.length > 0 ? cardCell.itemType : qsTr("Media") - color: Theme.textMuted - font.pixelSize: Theme.fontBody - } - - Rectangle { - anchors.fill: parent - visible: cardHover.hovered - color: Theme.alpha(Theme.bgBase, 0.45) - } - - Row { - anchors.centerIn: parent - visible: cardHover.hovered - spacing: Theme.spacingSm - - Rectangle { - visible: cardCell.playable - width: 40; height: 40; radius: 20 - color: Theme.accent - Image { - anchors.centerIn: parent - width: 19; height: 19 - source: "image://adaptiveicon//svg/dark/quick-play.svg?d=" + (Theme.dark ? 1 : 0) - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: searchPage.requestPlay(cardCell.index) - } - } - - Rectangle { - width: 34; height: 34; radius: 17 - color: Theme.alpha(Theme.bgRaised, 0.92) - Image { - anchors.centerIn: parent - width: 17; height: 17 - source: "image://adaptiveicon//svg/dark/" + - (cardCell.favorite ? "heart-fill.svg" : "heart-outline.svg") + - "?d=" + (Theme.dark ? 1 : 0) - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: searchPage.requestFavorite(cardCell.index) - } - } - - Rectangle { - width: 34; height: 34; radius: 17 - color: Theme.alpha(Theme.bgRaised, 0.92) - Image { - anchors.centerIn: parent - width: 17; height: 17 - source: "image://adaptiveicon//svg/dark/more-line.svg?d=" + (Theme.dark ? 1 : 0) - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - const p = mapToItem(page, width / 2, height) - searchPage.requestMore(cardCell.index, p.x, p.y) - } - } - } - } + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + interactionsEnabled: !results.listBusy + itemId: cardCell.itemId + title: cardCell.name + subtitle: cardCell.subtitle + itemType: cardCell.itemType + posterSource: cardCell.posterSource + thumbSource: cardCell.thumbSource + favorite: cardCell.favorite + playable: cardCell.playable + modelIndex: cardCell.index + imageAspect: searchPage.tileView ? "thumb" : "poster" + onActivated: function(itemId, modelIndex) { + searchPage.activate(modelIndex) } - - Column { - z: 1 - anchors.left: parent.left - anchors.right: parent.right - anchors.top: imageFrame.bottom - anchors.margins: Theme.spacingSm - spacing: 2 - - Text { - width: parent.width - text: cardCell.name - color: Theme.textPrimary - font.pixelSize: Theme.fontBodyPlus - font.weight: Font.DemiBold - elide: Text.ElideRight - } - Text { - width: parent.width - text: cardCell.subtitle - color: Theme.textMuted - font.pixelSize: Theme.fontSmall - elide: Text.ElideRight - } + onPlayRequested: function(itemId, modelIndex) { + searchPage.requestPlay(modelIndex) } - - MouseArea { - id: cardArea - anchors.fill: parent - z: 0 - cursorShape: Qt.PointingHandCursor - onClicked: searchPage.activate(cardCell.index) + onFavoriteRequested: function(itemId, modelIndex) { + searchPage.requestFavorite(modelIndex) } - - HoverHandler { - id: cardHover - acceptedDevices: PointerDevice.Mouse + onMoreRequested: function(itemId, modelIndex) { + const p = mapToItem(page, width / 2, height / 2) + searchPage.requestMore(modelIndex, p.x, p.y) } } } diff --git a/src/qEmbyApp/qml/SettingsGeneralPage.qml b/src/qEmbyApp/qml/SettingsGeneralPage.qml index 6b4c563..e952d52 100644 --- a/src/qEmbyApp/qml/SettingsGeneralPage.qml +++ b/src/qEmbyApp/qml/SettingsGeneralPage.qml @@ -36,27 +36,21 @@ Flickable { ThemedComboBox { id: langCombo - implicitWidth: 180 - - model: ListModel { - ListElement { text: qsTr("System Default"); value: "system" } - ListElement { text: qsTr("简体中文"); value: "zh_CN" } - ListElement { text: qsTr("English"); value: "en_US" } - ListElement { text: qsTr("French"); value: "fr_FR" } - } - + Layout.fillWidth: true + // 用 JS 数组 + textRole,避免 ListElement 里 qsTr 不刷新、无 textRole 时显示空白 + property var valueMap: ["system", "zh_CN", "en_US"] + model: [ + qsTr("System Default"), + qsTr("简体中文"), + qsTr("English") + ] currentIndex: { - for (var i = 0; i < model.count; i++) { - if (model.get(i).value === settings.language) - return i; - } - return 0; + const i = valueMap.indexOf(settings.language) + return i >= 0 ? i : 0 } - - onCurrentIndexChanged: { - if (currentIndex >= 0) { - settings.language = model.get(currentIndex).value; - } + onActivated: function(index) { + if (index >= 0 && index < valueMap.length) + settings.setLanguage(valueMap[index]) } } } @@ -101,10 +95,7 @@ Flickable { ThemedButton { text: qsTr("Configure...") implicitHeight: 30 - - onClicked: { - // TODO: open ProxySettingsDialog once ported to QML - } + onClicked: settings.openProxySettings() } } } diff --git a/src/qEmbyApp/qml/SettingsLibraryPage.qml b/src/qEmbyApp/qml/SettingsLibraryPage.qml index 0275e5c..80115a6 100644 --- a/src/qEmbyApp/qml/SettingsLibraryPage.qml +++ b/src/qEmbyApp/qml/SettingsLibraryPage.qml @@ -238,6 +238,7 @@ Flickable { description: qsTr("Choose how media items are presented by default") ThemedComboBox { + textRole: "text" id: viewCombo implicitWidth: 180 @@ -268,6 +269,7 @@ Flickable { description: qsTr("Set image download quality (higher uses more bandwidth)") ThemedComboBox { + textRole: "text" id: qualityCombo implicitWidth: 180 @@ -311,32 +313,85 @@ Flickable { CollapsibleSection { title: qsTr("Custom Home Section Order") - expanded: customHomeOrderSwitch.checked + expanded: settings.customHomeSectionOrderEnabled SettingsCard { title: qsTr("Custom Home Section Order") - description: qsTr("Override the default order of the main home screen sections") + description: qsTr("Override the default order of the main home screen sections for the current server") ThemedSwitch { id: customHomeOrderSwitch - checked: false + checked: settings.customHomeSectionOrderEnabled + onToggled: settings.customHomeSectionOrderEnabled = checked } } - // Placeholder — full DashboardSectionOrderWidget is complex C++ - // drag-and-drop. Will be implemented in a future phase. SettingsCard { - visible: customHomeOrderSwitch.checked + visible: settings.customHomeSectionOrderEnabled + title: qsTr("Section Order") + description: qsTr("Use the arrows to rearrange home sections") ColumnLayout { spacing: Theme.spacingSm + Layout.fillWidth: true - Text { - text: qsTr("Drag-and-drop section reordering will be available in a future update.") - color: Theme.textMuted - font.pixelSize: Theme.fontBody - wrapMode: Text.WordWrap - Layout.fillWidth: true + Repeater { + model: settings.homeSectionOrder + + delegate: Rectangle { + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: 44 + radius: Theme.radiusMd + color: Theme.bgRaised + border.color: Theme.borderSoft + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingMd + anchors.rightMargin: Theme.spacingSm + spacing: Theme.spacingSm + + Text { + text: (index + 1) + "." + color: Theme.textMuted + font.pixelSize: Theme.fontBody + Layout.preferredWidth: 24 + } + + Text { + text: modelData.title || modelData.id + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + font.weight: 500 + elide: Text.ElideRight + Layout.fillWidth: true + } + + ThemedButton { + text: "↑" + implicitWidth: 36 + enabled: index > 0 + onClicked: settings.moveHomeSection(index, index - 1) + } + + ThemedButton { + text: "↓" + implicitWidth: 36 + enabled: index < settings.homeSectionOrder.length - 1 + onClicked: settings.moveHomeSection(index, index + 1) + } + } + } + } + + ThemedButton { + text: qsTr("Reset to Default") + Layout.alignment: Qt.AlignRight + onClicked: settings.resetHomeSectionOrder() } } } @@ -360,6 +415,7 @@ Flickable { description: qsTr("Set how long to cache recommended content before refreshing") ThemedComboBox { + textRole: "text" id: dataCacheCombo implicitWidth: 180 @@ -405,10 +461,7 @@ Flickable { ThemedButton { text: qsTr("Clear") implicitHeight: 30 - - onClicked: { - // TODO: call mediaService->clearRecommendCache() via bridge - } + onClicked: settings.clearRecommendCache() } } } @@ -419,6 +472,7 @@ Flickable { description: qsTr("How long to keep downloaded images in local cache") ThemedComboBox { + textRole: "text" id: imgCacheCombo implicitWidth: 180 @@ -464,10 +518,7 @@ Flickable { ThemedButton { text: qsTr("Clear") implicitHeight: 30 - - onClicked: { - // TODO: clear image cache dir via bridge - } + onClicked: settings.clearImageCache() } } } diff --git a/src/qEmbyApp/qml/SettingsPage.qml b/src/qEmbyApp/qml/SettingsPage.qml new file mode 100644 index 0000000..af5edf7 --- /dev/null +++ b/src/qEmbyApp/qml/SettingsPage.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby +import "components" + +// Shared-engine host for the five Settings sub-pages. +Item { + id: settingsPage + + property var route: ({}) + property bool active: false + + readonly property var sections: ["general", "appearance", "library", "player", "about"] + property int currentIndex: 0 + + function sectionToIndex(section) { + const idx = sections.indexOf(section); + return idx >= 0 ? idx : 0; + } + + function applyRoute(r) { + const params = r && r.params ? r.params : ({}); + const section = params.section ? String(params.section) : ""; + if (section.length > 0) + currentIndex = sectionToIndex(section); + } + + onRouteChanged: applyRoute(route) + Component.onCompleted: applyRoute(route) + + onCurrentIndexChanged: { + contentStack.opacity = 0 + contentShift.y = 10 + contentIn.restart() + } + + Rectangle { + anchors.fill: parent + color: Theme.bgBase + Behavior on color { + ColorAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // ─── Left navigation panel ─────────────────────────────────────── + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 268 + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.35 : 0.55) + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + + // 右侧细分隔 + Rectangle { + anchors { top: parent.top; bottom: parent.bottom; right: parent.right } + width: 1 + color: Theme.alpha(Theme.border, 0.7) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 28 + spacing: 20 + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingSm + + Rectangle { + Layout.preferredWidth: 3 + Layout.preferredHeight: 18 + radius: 1.5 + color: Theme.accent + opacity: 0.9 + } + Text { + text: qsTr("Settings") + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: [ + { label: qsTr("General"), icon: "/svg/dark/general.svg" }, + { label: qsTr("Appearance"), icon: "/svg/dark/appearance.svg" }, + { label: qsTr("Library"), icon: "/svg/dark/library.svg" }, + { label: qsTr("Player"), icon: "/svg/dark/player.svg" }, + { label: qsTr("About"), icon: "/svg/dark/about.svg" } + ] + + delegate: Rectangle { + id: navRow + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 44 + radius: 12 + readonly property bool selected: settingsPage.currentIndex === index + color: selected ? Theme.alpha(Theme.accent, Theme.dark ? 0.16 : 0.12) + : (navHover.hovered ? Theme.alpha(Theme.textPrimary, 0.06) + : "transparent") + scale: navTap.pressed ? 0.97 : navHover.hovered ? 1.012 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: navRow.selected ? 18 : 0 + radius: 1.5 + color: Theme.accent + Behavior on height { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutBack + } + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 14 + spacing: 12 + + Image { + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + source: "image://adaptiveicon/" + modelData.icon + + "?d=" + (Theme.dark ? 1 : 0) + sourceSize.width: 20 + sourceSize.height: 20 + fillMode: Image.PreserveAspectFit + opacity: navRow.selected || navHover.hovered ? 1 : 0.75 + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + } + + Text { + Layout.fillWidth: true + text: modelData.label + color: navRow.selected ? Theme.accent + : navHover.hovered ? Theme.textPrimary + : Theme.textBody + font.pixelSize: Theme.fontBody + font.weight: navRow.selected ? Font.DemiBold : Font.Normal + verticalAlignment: Text.AlignVCenter + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + } + + HoverHandler { id: navHover } + TapHandler { + id: navTap + onTapped: settingsPage.currentIndex = index + } + } + } + } + + Item { Layout.fillHeight: true } + } + } + + // ─── Content stack ─────────────────────────────────────────────── + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + StackLayout { + id: contentStack + anchors.fill: parent + anchors.margins: 8 + currentIndex: settingsPage.currentIndex + opacity: 1 + // 切换时由 contentFade 驱动淡入 + transform: Translate { id: contentShift; y: 0 } + + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: generalComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: appearanceComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: libraryComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: playerComponent + } + Loader { + active: StackLayout.isCurrentItem || item !== null + sourceComponent: aboutComponent + } + } + + ParallelAnimation { + id: contentIn + NumberAnimation { + target: contentStack + property: "opacity" + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + target: contentShift + property: "y" + to: 0 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } + } + + Component { id: generalComponent; SettingsGeneralPage { } } + Component { id: appearanceComponent; SettingsAppearancePage { } } + Component { id: libraryComponent; SettingsLibraryPage { } } + Component { id: playerComponent; SettingsPlayerPage { } } + Component { id: aboutComponent; SettingsAboutPage { } } +} diff --git a/src/qEmbyApp/qml/SettingsPlayerPage.qml b/src/qEmbyApp/qml/SettingsPlayerPage.qml index 8276a4e..1e75d3e 100644 --- a/src/qEmbyApp/qml/SettingsPlayerPage.qml +++ b/src/qEmbyApp/qml/SettingsPlayerPage.qml @@ -39,6 +39,7 @@ Flickable { description: qsTr("Select GPU acceleration method for video decoding") ThemedComboBox { + textRole: "text" id: hwDecCombo implicitWidth: 180 @@ -71,6 +72,7 @@ Flickable { description: qsTr("Rendering backend for embedded player (Render API mode)") ThemedComboBox { + textRole: "text" id: voCombo implicitWidth: 180 @@ -99,6 +101,7 @@ Flickable { description: qsTr("Method for synchronizing video frames to display") ThemedComboBox { + textRole: "text" id: videoSyncCombo implicitWidth: 180 @@ -129,6 +132,7 @@ Flickable { description: qsTr("HDR to SDR tone mapping algorithm") ThemedComboBox { + textRole: "text" id: toneMapCombo implicitWidth: 180 @@ -229,6 +233,7 @@ Flickable { description: qsTr("Number of seconds to skip when pressing forward or rewind") ThemedComboBox { + textRole: "text" id: seekStepCombo implicitWidth: 160 @@ -260,6 +265,7 @@ Flickable { description: qsTr("Set how long left/right keys must be held before the long press action starts") ThemedComboBox { + textRole: "text" id: longPressTriggerCombo implicitWidth: 160 @@ -290,6 +296,7 @@ Flickable { description: qsTr("Choose whether holding left/right keys performs time jump seek or temporary speed playback") ThemedComboBox { + textRole: "text" id: longPressModeCombo implicitWidth: 160 @@ -331,6 +338,7 @@ Flickable { description: qsTr("Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD") ThemedComboBox { + textRole: "text" id: mediaSwitcherCombo implicitWidth: 180 @@ -469,6 +477,7 @@ Flickable { visible: danmakuSwitch.checked ThemedComboBox { + textRole: "text" id: danmakuRendererCombo implicitWidth: 200 @@ -499,6 +508,7 @@ Flickable { visible: danmakuSwitch.checked ThemedComboBox { + textRole: "text" id: danmakuSourceCombo implicitWidth: 180 @@ -524,6 +534,125 @@ Flickable { } } + // ─── Danmaku API Servers ───────────────────────────── + SettingsCard { + title: qsTr("Danmaku API Servers") + description: qsTr("Manage online danmaku providers for the current Emby server") + visible: danmakuSwitch.checked + + ColumnLayout { + spacing: Theme.spacingSm + Layout.fillWidth: true + Layout.preferredWidth: Math.min(page.width - 80, 560) + + Repeater { + model: settings.danmakuServers + + delegate: Rectangle { + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: serverRow.implicitHeight + Theme.spacingMd * 2 + radius: Theme.radiusMd + color: modelData.selected ? Theme.alpha(Theme.accent, 0.12) : Theme.bgRaised + border.color: modelData.selected ? Theme.alpha(Theme.accent, 0.45) : Theme.borderSoft + border.width: 1 + + ColumnLayout { + id: serverRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Theme.spacingMd + spacing: 6 + + RowLayout { + spacing: Theme.spacingSm + Layout.fillWidth: true + + Text { + text: modelData.name || qsTr("Unnamed") + color: Theme.textPrimary + font.pixelSize: Theme.fontBody + font.weight: 600 + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + visible: !!modelData.builtIn + text: qsTr("Built-in") + color: Theme.accent + font.pixelSize: Theme.fontSmall + } + + ThemedSwitch { + checked: !!modelData.enabled + onToggled: settings.setDanmakuServerEnabled(modelData.id, checked) + } + } + + Text { + text: modelData.baseUrl || "" + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + RowLayout { + spacing: Theme.spacingSm + Layout.fillWidth: true + + ThemedButton { + text: modelData.selected ? qsTr("Selected") : qsTr("Use") + enabled: !!modelData.enabled && !modelData.selected + onClicked: settings.selectedDanmakuServerId = modelData.id + } + + ThemedButton { + text: qsTr("Edit") + visible: !modelData.builtIn + onClicked: { + danmakuServerDialog.editingId = modelData.id + danmakuServerDialog.nameField = modelData.name || "" + danmakuServerDialog.urlField = modelData.baseUrl || "" + danmakuServerDialog.appIdField = modelData.appId || "" + danmakuServerDialog.appSecretField = modelData.appSecret || "" + danmakuServerDialog.descField = modelData.description || "" + danmakuServerDialog.open() + } + } + + ThemedButton { + text: qsTr("Remove") + visible: !modelData.builtIn + onClicked: settings.removeDanmakuServer(modelData.id) + } + + Item { Layout.fillWidth: true } + } + } + } + } + + ThemedButton { + text: qsTr("Add Server") + Layout.alignment: Qt.AlignRight + onClicked: { + danmakuServerDialog.editingId = "" + danmakuServerDialog.nameField = "" + danmakuServerDialog.urlField = "https://" + danmakuServerDialog.appIdField = "" + danmakuServerDialog.appSecretField = "" + danmakuServerDialog.descField = "" + danmakuServerDialog.open() + } + } + } + } + // ─── 21. Auto Load Danmaku ─────────────────────────── SettingsCard { title: qsTr("Auto Load Danmaku") @@ -791,6 +920,7 @@ Flickable { visible: danmakuSwitch.checked ThemedComboBox { + textRole: "text" id: cacheDurationCombo implicitWidth: 160 @@ -850,6 +980,7 @@ Flickable { visible: extPlayerSwitch.checked ThemedComboBox { + textRole: "text" id: extPlayerCombo implicitWidth: 200 @@ -915,4 +1046,87 @@ Flickable { // ─── Bottom spacer ─────────────────────────────────── Item { Layout.fillHeight: true; Layout.preferredHeight: 1 } } + + ThemedDialog { + id: danmakuServerDialog + title: editingId.length > 0 ? qsTr("Edit Danmaku Server") : qsTr("Add Danmaku Server") + confirmText: editingId.length > 0 ? qsTr("Save") : qsTr("Add") + confirmEnabled: urlInput.text.trim().length > 0 + implicitWidth: 480 + + property string editingId: "" + property alias nameField: nameInput.text + property alias urlField: urlInput.text + property alias appIdField: appIdInput.text + property alias appSecretField: appSecretInput.text + property alias descField: descInput.text + + ColumnLayout { + spacing: Theme.spacingMd + Layout.fillWidth: true + + Text { + text: qsTr("Name") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + ThemedTextField { + id: nameInput + Layout.fillWidth: true + placeholderText: qsTr("Display name") + } + + Text { + text: qsTr("Base URL") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + ThemedTextField { + id: urlInput + Layout.fillWidth: true + placeholderText: "https://api.example.com" + } + + Text { + text: qsTr("App ID") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + ThemedTextField { + id: appIdInput + Layout.fillWidth: true + } + + Text { + text: qsTr("App Secret") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + ThemedTextField { + id: appSecretInput + Layout.fillWidth: true + echoMode: TextInput.Password + } + + Text { + text: qsTr("Description") + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + } + ThemedTextField { + id: descInput + Layout.fillWidth: true + } + } + + onAccepted: { + if (editingId.length > 0) { + settings.updateDanmakuServer(editingId, nameField, urlField, + appIdField, appSecretField, descField) + } else { + settings.addDanmakuServer(nameField, urlField, + appIdField, appSecretField, descField) + } + } + } } diff --git a/src/qEmbyApp/qml/SideBar.qml b/src/qEmbyApp/qml/SideBar.qml new file mode 100644 index 0000000..e9662e9 --- /dev/null +++ b/src/qEmbyApp/qml/SideBar.qml @@ -0,0 +1,651 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby +import "components" as Comp + +// ===== 外壳侧边栏(QML 复刻) ===== +// 复刻 Widgets 外壳 views/user/homeview.cpp:729-901 的侧边栏,含两态几何: +// · 固定(pinned/docked): 宽 136,嵌在内容左/右侧,Router 让出等宽内缩; +// 无动画、无自动隐藏、无边缘触发;服务器地址隐藏、头像/图标收窄。 +// · 浮动(floating): 宽 240,覆盖在 Router 之上滑入/滑出;leading 边缘 +// 触发条(左 15 / 右 20)唤出,5000ms 无交互自动隐藏,离开即隐藏。 +// 显示 400ms OutCubic / 隐藏 300ms InCubic;reduceMotion 时直接吸附。 +// · 登录页 / 沉浸态(player, presentation==="immersive") 整体隐藏。 +// 数据源:sidebar (SidebarViewModel) —— 布局态、身份、动态媒体库列表。 +// 路由:home/favorites/settings/manage 直接 navigation.push;library 行经 +// sidebar.openLibrary(id, name) 以匹配 HomeView::openLibraryRoute。 +// 挂靠:本项填满内容区,透明部分放行鼠标事件到底层 Router;仅面板与边缘 +// 触发条捕获输入。AppShell 用 dockLeftInset/dockRightInset 内缩 Router。 +Item { + id: root + + // ── 当前路由态(直接读 navigation) ──────────────────────────────── + readonly property string routeName: + (navigation.currentRoute && navigation.currentRoute.name) || "" + readonly property string presentation: + (navigation.currentRoute && navigation.currentRoute.presentation) || "page" + readonly property bool onLogin: routeName === "login" + readonly property bool immersive: presentation === "immersive" + + // ── 布局态(来自 SidebarViewModel) ──────────────────────────────── + readonly property bool onRight: sidebar.position === "right" + readonly property bool pinned: sidebar.pinned + readonly property bool reduceMotion: sidebar.reduceMotion + readonly property int panelWidth: pinned ? 148 : 252 + readonly property int panelMargin: pinned ? 0 : 10 + readonly property int inset: pinned ? 12 : 16 + + // "hidden" | "docked" | "floating" + readonly property bool shellActive: !onLogin && !immersive + readonly property string mode: + !shellActive ? "hidden" : (pinned ? "docked" : "floating") + + // 固定态时 Router 需让出的内缩宽度(浮动/隐藏态为 0,Router 满宽)。 + readonly property int dockLeftInset: (mode === "docked" && !onRight) ? panelWidth : 0 + readonly property int dockRightInset: (mode === "docked" && onRight) ? panelWidth : 0 + + // ── 面板 x 目标位置 ─────────────────────────────────────────────── + readonly property real shownX: onRight ? (width - panelWidth - panelMargin) : panelMargin + readonly property real hiddenX: onRight ? (width + 30) : (-panelWidth - 30) + // 浮动态是否已展开(供阴影/触发条判定)。 + property bool revealed: false + + // 当前高亮的媒体库(仅 library 路由时匹配行 id)。 + readonly property string activeLibraryId: + routeName === "library" && navigation.currentRoute.params + ? (navigation.currentRoute.params.libraryId || "") : "" + + // ── 模式切换:重置面板几何 ──────────────────────────────────────── + function applyMode() { + slideAnim.stop() + autoHide.stop() + if (mode === "docked") { + revealed = true + panel.x = shownX // 吸附到边缘,常驻 + } else if (mode === "floating") { + revealed = false + panel.x = hiddenX // 起始收起,等边缘触发唤出 + } else { + revealed = false // hidden:面板不可见 + } + } + onModeChanged: applyMode() + // 左右位置/宽度变化时重算(仅在非动画进行中吸附)。 + onOnRightChanged: if (!slideAnim.running) panel.x = revealed ? shownX : hiddenX + onPanelWidthChanged: if (!slideAnim.running) panel.x = revealed ? shownX : hiddenX + Component.onCompleted: applyMode() + + // ── 浮动态滑入/滑出 ─────────────────────────────────────────────── + function slideIn() { + if (mode !== "floating" || revealed) + { if (mode === "floating") autoHide.restart(); return } + revealed = true + slideAnim.stop() + if (reduceMotion) { + panel.x = shownX + } else { + slideAnim.to = shownX + slideAnim.duration = 260 + slideAnim.easing.type = Easing.OutCubic + slideAnim.start() + } + autoHide.restart() + } + function slideOut() { + if (mode !== "floating" || !revealed) + return + revealed = false + autoHide.stop() + slideAnim.stop() + if (reduceMotion) { + panel.x = hiddenX + } else { + slideAnim.to = hiddenX + slideAnim.duration = 200 + slideAnim.easing.type = Easing.InCubic + slideAnim.start() + } + } + + NumberAnimation { + id: slideAnim + target: panel + property: "x" + } + + // 5s 无交互自动隐藏(悬浮在面板上时暂停,见 panelHover)。 + Timer { + id: autoHide + interval: 5000 + repeat: false + onTriggered: if (!panelHover.hovered) root.slideOut() + } + + // ── 边缘触发条(仅浮动收起态) ────────────────────────────────────── + MouseArea { + id: edgeTrigger + visible: root.mode === "floating" && !root.revealed + enabled: visible + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + width: root.onRight ? 20 : 15 + height: parent.height + x: root.onRight ? (parent.width - width) : 0 + y: 0 + z: 30 + onEntered: root.slideIn() + } + + // ── 阴影(浮动展开态,面板内缘投影) ──────────────────────────────── + Rectangle { + id: shadow + visible: root.mode === "floating" && (root.revealed || slideAnim.running) + z: 19 + width: 16 + height: parent.height + // 落在面板的内侧边缘外。 + x: root.onRight ? (panel.x - width) : (panel.x + panel.width) + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: root.onRight ? "transparent" : Theme.alpha("#000000", 0.30) } + GradientStop { position: 1.0; color: root.onRight ? Theme.alpha("#000000", 0.30) : "transparent" } + } + } + + // ── 面板本体 ────────────────────────────────────────────────────── + Rectangle { + id: panel + y: root.panelMargin + width: root.panelWidth + height: parent.height - root.panelMargin * 2 + visible: root.mode !== "hidden" + z: 20 + // 浮动:略透的 surface;固定:贴边 base。 + radius: root.pinned ? 0 : 16 + color: root.pinned + ? Theme.bgBase + : Theme.alpha(Theme.bgSurface, Theme.dark ? 0.97 : 0.98) + border.width: root.pinned ? 0 : 1 + border.color: Theme.alpha(Theme.border, 0.6) + clip: true + + Behavior on color { + ColorAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast } + } + + // 内缘分隔线(左栏 border-right / 右栏 border-left)。 + Rectangle { + width: 1 + height: parent.height + color: Theme.alpha(Theme.border, 0.7) + anchors.right: root.onRight ? undefined : parent.right + anchors.left: root.onRight ? parent.left : undefined + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + + HoverHandler { + id: panelHover + // 悬浮时取消自动隐藏;移出即收起(浮动态)。 + onHoveredChanged: { + if (root.mode !== "floating" || !root.revealed) + return + if (hovered) + autoHide.stop() + else + root.slideOut() + } + } + + // ── 可复用导航按钮(图标 + 标签) ─────────────────────────────── + component NavButton: Rectangle { + id: nb + property string icon + property string label + property bool danger: false + property bool selected: false + signal activated() + + Layout.fillWidth: true + Layout.preferredHeight: 38 + radius: 11 + color: nb.selected ? Theme.alpha(Theme.accent, Theme.dark ? 0.18 : 0.11) + : nbMouse.containsMouse + ? (nb.danger ? Theme.alpha(Theme.danger, 0.14) + : Theme.alpha(Theme.textPrimary, 0.065)) + : "transparent" + scale: nbMouse.pressed ? 0.96 : nbMouse.containsMouse ? 1.015 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + + // 左侧选中指示条:高度弹簧式展开 + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: nb.selected ? 16 : 0 + radius: 1.5 + color: Theme.accent + opacity: nb.selected ? 1 : 0 + scale: nb.selected ? 1 : 0.6 + + Behavior on height { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutBack + } + } + Behavior on opacity { + NumberAnimation { duration: Theme.animationMicro } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 10 + Image { + anchors.verticalCenter: parent.verticalCenter + width: 16; height: 16 + sourceSize: Qt.size(32, 32) + source: "image://adaptiveicon/" + nb.icon + "?d=" + (Theme.dark ? 1 : 0) + opacity: nb.selected || nbMouse.containsMouse ? 1 : 0.78 + scale: nbMouse.containsMouse ? 1.06 : 1 + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 16 - parent.spacing - parent.anchors.leftMargin + text: nb.label + color: nb.danger + ? (nbMouse.containsMouse ? Theme.danger : Theme.alpha(Theme.danger, 0.85)) + : (nb.selected ? Theme.textPrimary + : nbMouse.containsMouse ? Theme.textPrimary : Theme.textBody) + font.pixelSize: 14 + font.weight: nb.selected ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + } + MouseArea { + id: nbMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: nb.activated() + } + } + + // 小尺寸工具图标按钮(用户行的云同步/下载)。 + component UtilButton: Rectangle { + id: ub + property string icon + signal activated() + implicitWidth: root.pinned ? 22 : 26 + implicitHeight: root.pinned ? 22 : 26 + radius: 8 + color: ubMouse.containsMouse + ? Theme.alpha(Theme.textPrimary, 0.08) + : "transparent" + scale: ubMouse.pressed ? 0.88 : ubMouse.containsMouse ? 1.08 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { duration: Theme.animationMicro } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Image { + anchors.centerIn: parent + width: root.pinned ? 12 : 14 + height: root.pinned ? 12 : 14 + sourceSize: Qt.size(28, 28) + source: "image://adaptiveicon/" + ub.icon + "?d=" + (Theme.dark ? 1 : 0) + opacity: ubMouse.containsMouse ? 1 : 0.75 + Behavior on opacity { + NumberAnimation { duration: Theme.animationMicro } + } + } + MouseArea { + id: ubMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ub.activated() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: root.inset + anchors.rightMargin: root.inset + anchors.topMargin: root.pinned ? 18 : 20 + anchors.bottomMargin: root.pinned ? 18 : 20 + spacing: root.pinned ? 4 : 6 + + // ── 1. 服务器头部 ───────────────────────────────────────── + GridLayout { + Layout.fillWidth: true + Layout.bottomMargin: 4 + columns: root.pinned ? 1 : 2 + columnSpacing: 10 + rowSpacing: 2 + + Image { + Layout.alignment: root.pinned ? Qt.AlignHCenter : Qt.AlignVCenter + Layout.rowSpan: root.pinned ? 1 : 2 + width: 32; height: 32 + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(64, 64) + source: sidebar.serverIconSource + } + Text { + Layout.fillWidth: true + Layout.alignment: root.pinned ? Qt.AlignHCenter : Qt.AlignLeft + horizontalAlignment: root.pinned ? Text.AlignHCenter : Text.AlignLeft + text: sidebar.serverName + color: Theme.textPrimary + font.pixelSize: 14 + font.weight: Font.Bold + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + visible: !root.pinned // 固定态隐藏地址 + text: sidebar.serverAddress + color: Theme.textMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + // ── 2. 搜索框(gated: sidebar.searchVisible) ─────────────── + Rectangle { + id: searchBox + Layout.fillWidth: true + Layout.preferredHeight: 34 + visible: sidebar.searchVisible + radius: 10 + color: Theme.dark ? Theme.alpha(Theme.bgRaised, 0.45) : Theme.bgRaised + border.width: 1 + border.color: searchInput.activeFocus + ? Theme.accent + : Theme.alpha(Theme.borderSoft, 0.8) + scale: searchInput.activeFocus ? 1.02 : 1 + transformOrigin: Item.Center + + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Image { + id: searchIcon + anchors { left: parent.left; leftMargin: root.pinned ? 8 : 10; verticalCenter: parent.verticalCenter } + width: 14; height: 14 + sourceSize: Qt.size(28, 28) + source: "image://adaptiveicon//svg/dark/search.svg?d=" + (Theme.dark ? 1 : 0) + opacity: searchInput.activeFocus ? 1 : 0.72 + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + } + TextInput { + id: searchInput + anchors { + left: searchIcon.right; leftMargin: 8 + right: parent.right; rightMargin: 8 + verticalCenter: parent.verticalCenter + } + clip: true + color: Theme.textPrimary + font.pixelSize: 13 + selectionColor: Theme.accent + selectByMouse: true + verticalAlignment: TextInput.AlignVCenter + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: qsTr("Search...") + color: Theme.textMuted + font: searchInput.font + visible: searchInput.text.length === 0 && !searchInput.activeFocus + } + function submit() { + const q = text.trim() + if (q.length === 0) + return + searchHistory.submit(q) + text = "" + } + Keys.onReturnPressed: submit() + Keys.onEnterPressed: submit() + } + } + + // ── 3/4. Home / Favorites(各自 gated) ───────────────────── + NavButton { + visible: sidebar.homeVisible + icon: "/svg/dark/home.svg" + label: qsTr("Home") + selected: root.routeName === "home" + onActivated: navigation.home() + } + NavButton { + visible: sidebar.favoritesVisible + icon: "/svg/dark/heart.svg" + label: qsTr("Favorites") + selected: root.routeName === "favorites" + onActivated: navigation.push({ name: "favorites" }) + } + + // ── 分隔线 ──────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.topMargin: 3 + Layout.bottomMargin: 3 + color: Theme.border + } + + // ── 5. MEDIA 区标题 ─────────────────────────────────────── + Text { + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.bottomMargin: 2 + text: qsTr("MEDIA") + color: Theme.textMuted + font.pixelSize: 12 + font.weight: Font.Bold + font.letterSpacing: 1 + } + + // ── 6. 媒体库列表(填充剩余空间) ─────────────────────────── + ListView { + id: libraryList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: sidebar.libraries + spacing: 4 + ScrollBar.vertical: Comp.ThemedScrollBar {} + + delegate: Rectangle { + id: libRow + required property var modelData + readonly property bool selected: modelData.id === root.activeLibraryId + width: ListView.view.width + height: 36 + radius: 10 + color: selected ? Theme.alpha(Theme.accent, Theme.dark ? 0.22 : 0.12) + : libMouse.containsMouse ? Theme.alpha(Theme.textPrimary, 0.065) + : "transparent" + Behavior on color { + ColorAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + scale: libMouse.pressed ? 0.96 : libMouse.containsMouse ? 1.014 : 1 + transformOrigin: Item.Center + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + width: 3 + height: libRow.selected ? 16 : 0 + radius: 1.5 + color: Theme.accent + Behavior on height { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutBack + } + } + } + + Text { + anchors { + left: parent.left; leftMargin: root.pinned ? 10 : 12 + right: parent.right; rightMargin: 8 + verticalCenter: parent.verticalCenter + } + text: (libRow.modelData.emoji || "") + libRow.modelData.name + color: libRow.selected ? Theme.accent + : libMouse.containsMouse ? Theme.textPrimary : Theme.textBody + font.pixelSize: 14 + font.weight: libRow.selected ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + MouseArea { + id: libMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: sidebar.openLibrary(libRow.modelData.id, libRow.modelData.name) + } + } + } + + // ── 分隔线 ──────────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.topMargin: 3 + Layout.bottomMargin: 3 + color: Theme.border + } + + // ── 7. 用户行(头像 + 名称 + 云同步 + 下载) ──────────────── + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: 6 + spacing: 10 + + Image { + Layout.preferredWidth: root.pinned ? 12 : 20 + Layout.preferredHeight: root.pinned ? 12 : 20 + sourceSize: Qt.size(40, 40) + source: "image://adaptiveicon/" + + (sidebar.isAdmin ? "/svg/dark/user-admin.svg" : "/svg/dark/user.svg") + + "?d=" + (Theme.dark ? 1 : 0) + } + Text { + Layout.fillWidth: true + text: sidebar.userName + color: Theme.textBody + font.pixelSize: root.pinned ? 11 : 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + // 云同步(WebDAV) —— 打开 Widget 对话框(锚在 shell 隐藏 dialogAnchor)。 + UtilButton { + icon: "/svg/dark/cloud-sync.svg" + onActivated: sidebar.cloudSync() + } + // 下载(gated: canDownloadMedia) —— 同上,Widget 对话框。 + UtilButton { + visible: sidebar.canDownloadMedia + icon: "/svg/dark/download-sidebar.svg" + onActivated: sidebar.openDownloads() + } + } + + // ── 8. 底部动作:设置 / 管理(admin) / 登出 ──────────────── + NavButton { + icon: "/svg/dark/settings.svg" + label: qsTr("Settings") + selected: root.routeName === "settings" + onActivated: navigation.push({ name: "settings" }) + } + NavButton { + visible: sidebar.isAdmin + icon: "/svg/dark/server.svg" + label: qsTr("Manage") + selected: root.routeName === "manage" + onActivated: navigation.push({ name: "manage" }) + } + NavButton { + icon: "/svg/dark/logout.svg" + label: qsTr("Logout") + danger: true + // 会话拆除:离开到 login 路由 + 清 token(SidebarViewModel::logout)。 + onActivated: sidebar.logout() + } + } + } +} diff --git a/src/qEmbyApp/qml/TitleBar.qml b/src/qEmbyApp/qml/TitleBar.qml new file mode 100644 index 0000000..4c7ca7f --- /dev/null +++ b/src/qEmbyApp/qml/TitleBar.qml @@ -0,0 +1,524 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import QWindowKit +import qEmby +import "components" as Comp + +// ===== QWK 自定义标题栏(QML 复刻) ===== +// 复刻 Widgets 外壳 mainwindow.cpp:119-345 的 QWK::WindowBar: +// 左→右: 图标(logo) · 标题 · 返回 · 主页 · 收藏 · [居中全局搜索框] · +// 主题切换 · 最小化 · 最大化 · 关闭 +// 系统按钮(min/max/close)通过 QuickWindowAgent.setSystemButton 声明; +// 交互控件(theme/back/home/fav/searchBox)通过 setHitTestVisible 打孔。 +// 全局搜索框接 searchHistory(submit/completions) + 历史弹窗 + 自动补全。 +Rectangle { + id: root + + // 由 AppShell 注入:顶层 ApplicationWindow 与其 WindowAgent。 + required property var shell + required property var windowAgent + + height: 46 + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.92 : 0.96) + z: 10 + + Behavior on color { + ColorAnimation { duration: Theme.animationNormal; easing.type: Easing.OutCubic } + } + + // 底部分隔:硬线 + 极淡强调色余晖,切换主题时一起过渡 + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 1 + color: Theme.alpha(Theme.border, 0.65) + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 12 + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, Theme.dark ? 0.06 : 0.04) } + GradientStop { position: 1.0; color: "transparent" } + } + opacity: 0.55 + } + + // 当前是否处于登录页(登录页隐藏全部导航/搜索)。 + readonly property string routeName: + (navigation.currentRoute && navigation.currentRoute.name) || "" + readonly property bool onLogin: routeName === "login" + readonly property bool onHome: routeName === "home" + + // 沉浸态(player, presentation==="immersive")整体隐藏标题栏,让视频满窗。 + readonly property string presentation: + (navigation.currentRoute && navigation.currentRoute.presentation) || "page" + readonly property bool immersive: presentation === "immersive" + visible: !immersive + + Component.onCompleted: windowAgent.setTitleBar(root) + + // ── 可复用图标按钮 ──────────────────────────────────────────────── + // 经 image://adaptiveicon 随主题重着色(Slate300/600)。 + component IconButton: Rectangle { + id: btn + property string icon // ":/svg/dark/xxx.svg" 形式 + property int iconSize: 14 + property bool checked: false + property color hoverColor: Theme.alpha(Theme.textPrimary, 0.07) + property color checkedColor: Theme.alpha(Theme.accent, Theme.dark ? 0.16 : 0.12) + signal activated() + + implicitWidth: 38 + implicitHeight: 32 + radius: 10 + color: btn.checked ? checkedColor + : mouse.containsMouse ? hoverColor : "transparent" + scale: mouse.pressed ? 0.90 : mouse.containsMouse ? 1.05 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + + Image { + id: btnIcon + anchors.centerIn: parent + width: btn.iconSize + height: btn.iconSize + sourceSize: Qt.size(btn.iconSize * 2, btn.iconSize * 2) + source: btn.icon + ? "image://adaptiveicon/" + btn.icon + "?d=" + (Theme.dark ? 1 : 0) + : "" + visible: btn.icon !== "" + opacity: mouse.containsMouse || btn.checked ? 1 : 0.78 + scale: mouse.containsMouse ? 1.04 : 1 + + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + } + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: btn.activated() + } + Component.onCompleted: root.windowAgent.setHitTestVisible(btn, true) + } + + // 系统按钮(最小化/最大化/关闭):无边距、略圆角、icon 12px。 + component SysButton: Rectangle { + id: sbtn + property string icon + property bool danger: false + signal activated() + implicitWidth: 48 + implicitHeight: parent.height + color: mouse2.containsMouse + ? (sbtn.danger ? Theme.danger + : Theme.alpha(Theme.textPrimary, 0.07)) + : "transparent" + scale: mouse2.pressed ? 0.96 : 1 + transformOrigin: Item.Center + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationMicro; easing.type: Easing.OutCubic } + } + + Image { + anchors.centerIn: parent + width: 12; height: 12 + sourceSize: Qt.size(24, 24) + source: "image://adaptiveicon/" + sbtn.icon + "?d=" + (Theme.dark ? 1 : 0) + opacity: mouse2.containsMouse ? 1 : 0.82 + Behavior on opacity { NumberAnimation { duration: Theme.animationMicro } } + } + MouseArea { + id: mouse2 + anchors.fill: parent + hoverEnabled: true + onClicked: sbtn.activated() + } + } + + // ── 左簇:logo / 标题 / 返回 / 主页 / 收藏 ──────────────────────── + Row { + id: leftCluster + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + spacing: 4 + + // 应用图标:始终保留(原先仅在无返回键时显示,进子页会消失)。 + Image { + anchors.verticalCenter: parent.verticalCenter + width: 18; height: 18 + source: "qrc:/svg/qemby_logo.svg" + opacity: 0.95 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: shell.title + color: Theme.textPrimary + font.pixelSize: 14 + font.weight: Font.DemiBold + leftPadding: 4 + rightPadding: 8 + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + + IconButton { + id: backBtn + anchors.verticalCenter: parent.verticalCenter + icon: "/svg/dark/arrow-left.svg" + visible: !root.onLogin && navigation.canGoBack + onActivated: navigation.back() + + // 出现时轻柔淡入,避免路由变化时生硬跳变 + opacity: visible ? 1 : 0 + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + } + IconButton { + id: homeBtn + anchors.verticalCenter: parent.verticalCenter + icon: "/svg/dark/bar-home.svg" + visible: !root.onLogin + checked: root.onHome + onActivated: navigation.home() + } + IconButton { + id: favBtn + anchors.verticalCenter: parent.verticalCenter + icon: "/svg/dark/bar-heart.svg" + visible: !root.onLogin + checked: root.routeName === "favorites" + onActivated: navigation.push({ name: "favorites" }) + } + // 收藏按钮右侧留白(对应 QSS fav-button margin-right:12px)。 + Item { width: 12; height: 1; visible: favBtn.visible } + } + + // ── 居中全局搜索框(仅主页显示,对应 showGlobalSearch) ───────────── + Rectangle { + id: searchBox + anchors.centerIn: parent + width: searchInput.activeFocus ? 440 : 388 + height: searchInput.activeFocus ? 36 : 34 + radius: height / 2 + visible: root.onHome + color: searchInput.activeFocus ? Theme.bgRaised + : Theme.dark ? Theme.alpha(Theme.bgBase, 0.88) : Theme.bgRaised + border.width: searchInput.activeFocus ? 1.5 : 1 + border.color: searchInput.activeFocus ? Theme.accent + : searchHover.containsMouse ? Theme.border + : Theme.borderSoft + scale: searchHover.containsMouse && !searchInput.activeFocus ? 1.012 : 1 + transformOrigin: Item.Center + + // 聚焦时的外圈柔光 + Rectangle { + anchors.centerIn: parent + width: parent.width + 6 + height: parent.height + 6 + radius: height / 2 + z: -1 + color: "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.accent, searchInput.activeFocus ? 0.28 : 0) + opacity: searchInput.activeFocus ? 1 : 0 + + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast } + } + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast } + } + } + + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on width { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on height { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + + MouseArea { + id: searchHover + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + // 前导搜索图标。 + Image { + id: leadingIcon + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: 15; height: 15 + sourceSize: Qt.size(30, 30) + source: "image://adaptiveicon//svg/dark/search.svg?d=" + (Theme.dark ? 1 : 0) + opacity: searchInput.activeFocus || searchHover.containsMouse ? 1 : 0.72 + Behavior on opacity { NumberAnimation { duration: Theme.animationQuick } } + } + + TextInput { + id: searchInput + anchors { + left: leadingIcon.right; leftMargin: 8 + right: clearBtn.visible ? clearBtn.left : parent.right + rightMargin: 8 + verticalCenter: parent.verticalCenter + } + clip: true + color: Theme.textPrimary + font.pixelSize: 13 + font.weight: Font.Medium + selectionColor: Theme.accent + selectByMouse: true + verticalAlignment: TextInput.AlignVCenter + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: qsTr("Search...") + color: Theme.textMuted + font: searchInput.font + visible: searchInput.text.length === 0 && !searchInput.activeFocus + opacity: 0.9 + } + + onTextEdited: { + historyPopup.close() + if (searchHistory.autocompleteEnabled && text.length > 0) { + completer.refresh(text) + } else { + completer.close() + } + } + onActiveFocusChanged: { + if (activeFocus && text.length === 0) + historyPopup.openBelow() + } + Keys.onReturnPressed: root.submitSearch() + Keys.onEnterPressed: root.submitSearch() + Keys.onEscapePressed: { historyPopup.close(); completer.close() } + } + + // 清除按钮(clearButtonEnabled 复刻)。 + IconButton { + id: clearBtn + anchors { right: parent.right; rightMargin: 4; verticalCenter: parent.verticalCenter } + implicitWidth: 22 + implicitHeight: 22 + iconSize: 11 + radius: 11 + icon: "/svg/dark/close.svg" + visible: searchInput.text.length > 0 + opacity: visible ? 1 : 0 + scale: visible ? 1 : 0.8 + Behavior on opacity { + NumberAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutBack } + } + onActivated: { searchInput.text = ""; searchInput.forceActiveFocus() } + } + + Component.onCompleted: root.windowAgent.setHitTestVisible(searchBox, true) + } + + function submitSearch() { + const q = searchInput.text.trim() + if (q.length === 0) + return + historyPopup.close() + completer.close() + searchHistory.submit(q) + searchInput.text = "" + } + + // ── 右簇:主题切换 / 系统按钮 ───────────────────────────────────── + Row { + id: rightCluster + anchors { right: parent.right; top: parent.top; bottom: parent.bottom } + + // 主题切换:轻旋图标(不碰 IconButton.scale 绑定,避免动画后绑定断裂) + IconButton { + id: themeBtn + anchors.verticalCenter: parent.verticalCenter + iconSize: 15 + icon: Theme.dark ? "/svg/moon.svg" : "/svg/sun.svg" + checked: Theme.dark + visible: !root.onLogin + onActivated: { + themeKick.start() + settings.setThemeMode(Theme.dark ? "light" : "dark") + } + + SequentialAnimation { + id: themeKick + NumberAnimation { + target: themeBtn + property: "rotation" + to: Theme.dark ? 22 : -22 + duration: Theme.animationMicro + easing.type: Easing.InCubic + } + NumberAnimation { + target: themeBtn + property: "rotation" + to: 0 + duration: Theme.animationFast + easing.type: Easing.OutBack + } + } + } + + SysButton { + id: minButton + icon: "/svg/dark/minimize.svg" + onActivated: shell.showMinimized() + Component.onCompleted: + root.windowAgent.setSystemButton(WindowAgent.Minimize, minButton) + } + SysButton { + id: maxButton + icon: shell.visibility === Window.Maximized + ? "/svg/dark/restore.svg" : "/svg/dark/maximize.svg" + onActivated: shell.visibility === Window.Maximized + ? shell.showNormal() : shell.showMaximized() + Component.onCompleted: + root.windowAgent.setSystemButton(WindowAgent.Maximize, maxButton) + } + SysButton { + id: closeButton + icon: "/svg/dark/close.svg" + danger: true + onActivated: shell.close() + Component.onCompleted: + root.windowAgent.setSystemButton(WindowAgent.Close, closeButton) + } + } + + // ── 搜索历史弹窗(chip 流) ───────────────────────────────────────── + Comp.SearchHistoryPopup { + id: historyPopup + anchorItem: searchBox + onTermActivated: function(term) { + searchInput.text = term + root.submitSearch() + } + function openBelow() { + if (!searchHistory.enabled || searchHistory.entries.length === 0) + return + completer.close() + open() + } + } + + // ── 自动补全下拉(与历史弹窗互斥) ────────────────────────────────── + Popup { + id: completer + y: searchBox.y + searchBox.height + 6 + x: searchBox.x + width: searchBox.width + padding: 4 + property var items: [] + + enter: Transition { + ParallelAnimation { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Theme.animationQuick } + NumberAnimation { + property: "scale" + from: 0.96 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Theme.animationMicro } + } + transformOrigin: Item.Top + + function refresh(prefix) { + const list = searchHistory.completions(prefix) + items = list + if (list.length > 0 && searchInput.activeFocus) { + open() + } else { + close() + } + } + + background: Rectangle { + color: Theme.dark ? Theme.bgBase : Theme.bgSurface + radius: 14 + border.width: 1 + border.color: Theme.alpha(Theme.border, 0.85) + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + + contentItem: ListView { + implicitHeight: Math.min(8, completer.items.length) * 40 + 2 + clip: true + model: completer.items + boundsBehavior: Flickable.StopAtBounds + spacing: 1 + delegate: ItemDelegate { + required property string modelData + width: ListView.view.width + height: 40 + background: Rectangle { + color: parent.hovered ? Theme.alpha(Theme.accent, 0.12) : "transparent" + radius: 12 + Behavior on color { + ColorAnimation { duration: Theme.animationMicro } + } + } + contentItem: Text { + leftPadding: 12 + verticalAlignment: Text.AlignVCenter + text: modelData + color: Theme.textBody + font.pixelSize: 13 + elide: Text.ElideRight + } + onClicked: { + searchInput.text = modelData + root.submitSearch() + } + } + } + } +} diff --git a/src/qEmbyApp/qml/components/CollapsibleSection.qml b/src/qEmbyApp/qml/components/CollapsibleSection.qml index aa9862d..11d5558 100644 --- a/src/qEmbyApp/qml/components/CollapsibleSection.qml +++ b/src/qEmbyApp/qml/components/CollapsibleSection.qml @@ -1,32 +1,45 @@ import QtQuick import QtQuick.Layouts +import qEmby 1.0 // Expandable section with a tappable header and animated content reveal. -// Uses Theme.animationNormal for transition duration. ColumnLayout { id: root - // ─── Public API ────────────────────────────────────────── property string title: "" property bool expanded: true default property alias content: contentColumn.data spacing: 0 - // ─── Header ────────────────────────────────────────────── Rectangle { id: header Layout.fillWidth: true implicitHeight: headerRow.implicitHeight + Theme.spacingMd * 2 - radius: expanded ? Theme.radiusLg : Theme.radiusSm + radius: expanded ? Theme.radiusLg : 10 color: headerArea.containsMouse ? Theme.bgRaised : Theme.bgSurface + border.width: 1 + border.color: headerArea.containsMouse + ? Theme.alpha(Theme.accent, 0.28) + : Theme.alpha(Theme.border, 0.7) + scale: headerArea.pressed ? 0.992 : 1 Behavior on radius { + NumberAnimation { duration: Theme.animationFast } + } + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { NumberAnimation { - duration: Theme.animationFast + duration: Theme.animationMicro + easing.type: Easing.OutCubic } } @@ -36,38 +49,38 @@ ColumnLayout { anchors.margins: Theme.spacingMd spacing: Theme.spacingSm - // Chevron indicator Text { id: chevron - - text: "\u25B6" // BLACK RIGHT-POINTING SMALL TRIANGLE + text: "\u25B6" color: Theme.textMuted font.pixelSize: Theme.fontSmall rotation: root.expanded ? 90 : 0 - Behavior on rotation { RotationAnimation { duration: Theme.animationNormal direction: RotationAnimation.Shortest + easing.type: Easing.OutCubic } } } - // Section title Text { text: root.title color: Theme.textPrimary font.pixelSize: Theme.fontHeading - font.weight: 600 + font.weight: Font.DemiBold Layout.fillWidth: true } - // Expand/collapse toggle area Text { - text: root.expanded ? "\u2212" : "+" // − or + + text: root.expanded ? "\u2212" : "+" color: Theme.textMuted font.pixelSize: Theme.fontHeading font.weight: 300 + opacity: headerArea.containsMouse ? 1 : 0.7 + Behavior on opacity { + NumberAnimation { duration: Theme.animationMicro } + } } } @@ -80,26 +93,30 @@ ColumnLayout { } } - // ─── Content area with animated height ─────────────────── Item { id: contentWrapper Layout.fillWidth: true Layout.topMargin: root.expanded ? Theme.spacingXs : 0 - // Height is driven by the contentColumn; animation handled by Behavior implicitHeight: root.expanded ? contentColumn.implicitHeight : 0 clip: true + opacity: root.expanded ? 1 : 0 Behavior on implicitHeight { NumberAnimation { duration: Theme.animationNormal - easing.type: Easing.InOutQuad + easing.type: Easing.OutCubic + } + } + Behavior on opacity { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic } } ColumnLayout { id: contentColumn - anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top diff --git a/src/qEmbyApp/qml/components/ConfirmDialog.qml b/src/qEmbyApp/qml/components/ConfirmDialog.qml new file mode 100644 index 0000000..b745fbe --- /dev/null +++ b/src/qEmbyApp/qml/components/ConfirmDialog.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls.Basic as Basic +import QtQuick.Layouts +import qEmby 1.0 + +// 可复用的异步确认弹窗。绑定到一个 ViewModel: +// - 监听 target.confirmRequested(title, message, confirmText, cancelText, role) +// - 用户确认/取消后回调 target.resolveConfirm(bool),唤醒挂起的协程。 +// 替代原先阻塞式的 ModernMessageBox::question()。 +ThemedDialog { + id: root + + // 必须设置为暴露 confirmRequested/resolveConfirm 的 ViewModel。 + required property var target + + // 用户直接关闭(Esc / 点遮罩)时视为取消,须回传结果避免协程永久挂起。 + property bool resolved: false + + property string message: "" + + title: "" + confirmRole: "danger" + accessibleDescription: message + + Text { + Layout.fillWidth: true + text: root.message + color: Theme.textBody + font.pixelSize: Theme.fontBody + wrapMode: Text.Wrap + } + + onAccepted: { + resolved = true; + target.resolveConfirm(true); + } + onRejected: { + resolved = true; + target.resolveConfirm(false); + } + onClosed: { + // accept()/reject() 都会先触发 onClosed 之前的 resolved=true; + // 若是外部关闭(未 resolved),补一个取消结果。 + if (!resolved) + target.resolveConfirm(false); + } + + Connections { + target: root.target + + function onConfirmRequested(title, message, confirmText, cancelText, role) { + root.title = title; + root.message = message; + root.confirmText = confirmText; + root.cancelText = cancelText; + root.confirmRole = role; + root.resolved = false; + root.open(); + } + } +} diff --git a/src/qEmbyApp/qml/components/ErrorState.qml b/src/qEmbyApp/qml/components/ErrorState.qml index 1d93acc..669433f 100644 --- a/src/qEmbyApp/qml/components/ErrorState.qml +++ b/src/qEmbyApp/qml/components/ErrorState.qml @@ -18,21 +18,44 @@ FocusScope { Accessible.role: Accessible.AlertMessage Accessible.name: title Accessible.description: message - onVisibleChanged: { - if (visible && autoFocus && retryButton.visible) - Qt.callLater(retryButton.forceActiveFocus); + opacity: 0 + scale: 0.96 + Component.onCompleted: appear.start() + onVisibleChanged: { + if (visible) { + appear.restart() + if (autoFocus && retryButton.visible) + Qt.callLater(retryButton.forceActiveFocus) + } } + + ParallelAnimation { + id: appear + NumberAnimation { + target: root + property: "opacity" + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: root + property: "scale" + to: 1 + duration: Theme.animationSlow + easing.type: Easing.OutCubic + } + } + Keys.onEnterPressed: function(event) { if (retryButton.visible && retryButton.enabled) root.retryRequested(); - event.accepted = true; } Keys.onReturnPressed: function(event) { if (retryButton.visible && retryButton.enabled) root.retryRequested(); - event.accepted = true; } @@ -44,13 +67,30 @@ FocusScope { spacing: Theme.spacingMd Rectangle { - color: Theme.alpha(Theme.danger, 0.14) + color: Theme.alpha(Theme.danger, 0.12) border.width: 1 - border.color: Theme.alpha(Theme.danger, 0.5) + border.color: Theme.alpha(Theme.danger, 0.4) radius: width / 2 - Layout.preferredWidth: 44 - Layout.preferredHeight: 44 + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 Layout.alignment: Qt.AlignHCenter + scale: 1 + + SequentialAnimation on scale { + running: root.visible + loops: 1 + NumberAnimation { + from: 0.7 + to: 1.08 + duration: Theme.animationNormal + easing.type: Easing.OutBack + } + NumberAnimation { + to: 1 + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } Text { anchors.centerIn: parent @@ -59,7 +99,6 @@ FocusScope { font.pixelSize: Theme.fontTitle font.weight: Font.Bold } - } Text { @@ -88,12 +127,11 @@ FocusScope { visible: root.retryVisible text: root.retryText + prominent: true Accessible.name: text Layout.alignment: Qt.AlignHCenter Layout.topMargin: Theme.spacingXs onClicked: root.retryRequested() } - } - } diff --git a/src/qEmbyApp/qml/components/LoadingState.qml b/src/qEmbyApp/qml/components/LoadingState.qml index 4c21220..7ed7c31 100644 --- a/src/qEmbyApp/qml/components/LoadingState.qml +++ b/src/qEmbyApp/qml/components/LoadingState.qml @@ -8,13 +8,21 @@ Item { property string title: qsTr("Loading") property string message: "" property bool running: visible - property int indicatorSize: 36 + property int indicatorSize: 40 implicitWidth: 280 implicitHeight: loadingLayout.implicitHeight + Theme.spacingXl * 2 Accessible.role: Accessible.ProgressBar Accessible.name: title Accessible.description: message + opacity: visible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } ColumnLayout { id: loadingLayout @@ -30,33 +38,59 @@ Item { Layout.preferredHeight: root.indicatorSize Layout.alignment: Qt.AlignHCenter + // 外圈呼吸环 + Rectangle { + anchors.centerIn: parent + width: parent.width + 10 + height: width + radius: width / 2 + color: "transparent" + border.width: 1.5 + border.color: Theme.alpha(Theme.accent, 0.22) + opacity: root.running ? 1 : 0 + scale: 1 + + SequentialAnimation on scale { + running: root.running && root.visible && !Theme.reduceMotion + loops: Animation.Infinite + NumberAnimation { + to: 1.1 + duration: 1100 + easing.type: Easing.InOutSine + } + NumberAnimation { + to: 0.96 + duration: 1100 + easing.type: Easing.InOutSine + } + } + } + Repeater { model: 10 Rectangle { required property int index readonly property real angle: index * Math.PI * 2 / 10 - readonly property real orbitRadius: spinner.width * 0.38 + readonly property real orbitRadius: spinner.width * 0.36 - width: Math.max(3, spinner.width * 0.11) + width: Math.max(3, spinner.width * 0.1) height: width radius: width / 2 x: spinner.width / 2 + Math.cos(angle) * orbitRadius - width / 2 y: spinner.height / 2 + Math.sin(angle) * orbitRadius - height / 2 color: Theme.accent - opacity: 0.22 + index * 0.075 + opacity: 0.2 + index * 0.08 } - } RotationAnimator on rotation { from: 0 to: 360 - duration: 900 + duration: 1100 loops: Animation.Infinite - running: root.running && root.visible + running: root.running && root.visible && !Theme.reduceMotion } - } Text { @@ -79,7 +113,5 @@ Item { wrapMode: Text.WordWrap Layout.fillWidth: true } - } - } diff --git a/src/qEmbyApp/qml/components/MediaCard.qml b/src/qEmbyApp/qml/components/MediaCard.qml index ed170fe..d4fbb62 100644 --- a/src/qEmbyApp/qml/components/MediaCard.qml +++ b/src/qEmbyApp/qml/components/MediaCard.qml @@ -1,6 +1,17 @@ import QtQuick +import QtQuick.Window import qEmby 1.0 +// 海报/缩略卡片 — 帧率优先版 +// +// 动画策略(每卡最多 2~3 路并行插值): +// 1) 封面 scale(clip 内,便宜) +// 2) 遮罩 opacity +// 3) 操作钮 opacity +// 其余(背景色/描边/标题)瞬时切换,不挂 Behavior。 +// +// 列表滑动时 interactionsEnabled=false:关掉 HoverHandler + 强制收起 hover, +// 避免滑过时几十张卡同时插值。 FocusScope { id: root @@ -18,10 +29,15 @@ FocusScope { property string imageAspect: "poster" // "poster" (2:3) or "thumb" (16:9) property bool horizontal: false property real artworkWidth: horizontal ? Math.min(width * 0.32, 180) : width + // 父列表滑动中设为 false,停止 hover 动画与事件 + property bool interactionsEnabled: true + readonly property real imageRatio: imageAspect === "thumb" ? 16 / 9 : 2 / 3 readonly property real normalizedProgress: Math.max(0, Math.min(1, progress > 1 ? progress / 100 : progress)) readonly property url effectiveSource: imageAspect === "thumb" && thumbSource.toString().length > 0 ? thumbSource : posterSource - readonly property bool actionsVisible: hoverHandler.hovered || activeFocus + readonly property bool actionsVisible: interactionsEnabled && (hoverHandler.hovered || activeFocus) + // 固定短时长:列表场景 ~100ms 足够感知,且帧数少 + readonly property int hoverMs: Theme.reduceMotion ? 0 : 100 signal activated(string itemId, int modelIndex) signal playRequested(string itemId, int modelIndex) @@ -35,6 +51,9 @@ FocusScope { implicitWidth: horizontal ? 420 : 168 implicitHeight: horizontal ? Math.max(104, artwork.height) : artwork.height + labels.implicitHeight + Theme.spacingMd * 2 activeFocusOnTab: true + // z 提升不动画;仅 1 张卡为 2,成本可忽略 + z: actionsVisible ? 2 : 0 + Accessible.role: Accessible.ListItem Accessible.name: subtitle.length > 0 ? title + ", " + subtitle : title Accessible.description: itemType @@ -52,27 +71,36 @@ FocusScope { } Rectangle { + id: cardBg anchors.fill: parent + // 轻微上浮用顶 margin 模拟会改布局;改为无 y 动画,仅描边/底色瞬时反馈 radius: Theme.radiusLg color: root.actionsVisible ? Theme.bgRaised : Theme.bgSurface border.width: root.activeFocus ? 2 : 1 - border.color: root.activeFocus ? Theme.accent : root.actionsVisible ? Theme.alpha(Theme.accent, 0.45) : Theme.borderSoft - clip: true + border.color: root.activeFocus ? Theme.accent + : root.actionsVisible ? Theme.alpha(Theme.accent, 0.55) + : Theme.borderSoft + // 无 Behavior — 色/边瞬时,省 ~2 路插值 + } - Behavior on color { - ColorAnimation { - duration: Theme.animationFast + // 外圈光晕:只用 opacity(无 margins 动画) + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: Theme.radiusLg + 2 + color: "transparent" + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.25) + z: -1 + visible: !root.horizontal + opacity: root.actionsVisible ? 1 : 0 + Behavior on opacity { + enabled: root.hoverMs > 0 + NumberAnimation { + duration: root.hoverMs + easing.type: Easing.OutQuad } - } - - Behavior on border.color { - ColorAnimation { - duration: Theme.animationFast - } - - } - } Item { @@ -93,11 +121,39 @@ FocusScope { id: image anchors.fill: parent + // 复用池里 source 随 model 变;sourceSize 按逻辑宽 × DPR 限幅, + // 避免解码超大纹理。滑动中关 mipmap/smooth 减 GPU 开销。 source: root.effectiveSource - sourceSize.width: Math.ceil(width * 1.5) + sourceSize.width: { + var dpr = (typeof Screen !== "undefined" && Screen.devicePixelRatio) + ? Screen.devicePixelRatio : 1 + return Math.min(480, Math.ceil(Math.max(width, 1) * dpr)) + } asynchronous: true cache: true + mipmap: root.interactionsEnabled + smooth: root.interactionsEnabled fillMode: Image.PreserveAspectCrop + // 幅度略小:1.04 比 1.06 更省 fill rate,观感仍在 + scale: root.actionsVisible ? 1.04 : 1 + opacity: image.status === Image.Ready ? 1 : 0 + transformOrigin: Item.Center + + Behavior on scale { + enabled: root.hoverMs > 0 + NumberAnimation { + duration: root.hoverMs + easing.type: Easing.OutQuad + } + } + // 首次加载淡入:短;ready 后不再反复动画 + Behavior on opacity { + enabled: root.hoverMs > 0 && image.status !== Image.Ready + NumberAnimation { + duration: root.hoverMs + easing.type: Easing.OutQuad + } + } } Column { @@ -113,7 +169,6 @@ FocusScope { font.pixelSize: Theme.fontTitle font.weight: Font.DemiBold } - Text { width: parent.width text: root.itemType.length > 0 ? root.itemType : qsTr("Media") @@ -122,41 +177,58 @@ FocusScope { horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } - } + // 底渐变:静态,无动画 + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * 0.28 + visible: !root.horizontal + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Theme.alpha(Theme.bgBase, 0.55) } + } + } + + // Hover 遮罩 — 核心动效之一 Rectangle { anchors.fill: parent - visible: root.actionsVisible - color: Theme.alpha(Theme.bgBase, 0.48) + opacity: root.actionsVisible ? 1 : 0 + color: Theme.alpha(Theme.bgBase, 0.4) + Behavior on opacity { + enabled: root.hoverMs > 0 + NumberAnimation { + duration: root.hoverMs + easing.type: Easing.OutQuad + } + } } Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: 4 + height: 3 visible: root.normalizedProgress > 0 && root.normalizedProgress < 1 - color: Theme.alpha(Theme.bgBase, 0.7) - + color: Theme.alpha(Theme.bgBase, 0.55) Rectangle { width: parent.width * root.normalizedProgress height: parent.height color: Theme.accent } - } Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.margins: Theme.spacingSm - width: 24 - height: 24 - radius: 12 + width: 22 + height: 22 + radius: 11 visible: root.played color: Theme.success - Text { anchors.centerIn: parent text: "✓" @@ -164,13 +236,24 @@ FocusScope { font.pixelSize: Theme.fontSmall font.weight: Font.Bold } - } + // 操作钮:只动 opacity,不 scale / 不 y Row { anchors.centerIn: parent - visible: root.actionsVisible + enabled: root.actionsVisible + opacity: root.actionsVisible ? 1 : 0 spacing: Theme.spacingSm + // 未 hover 时不参与 hit-test / 绘制子项可再优化:visible 绑定 + visible: opacity > 0.01 + + Behavior on opacity { + enabled: root.hoverMs > 0 + NumberAnimation { + duration: root.hoverMs + easing.type: Easing.OutQuad + } + } ActionButton { visible: root.playable @@ -179,26 +262,23 @@ FocusScope { accessibleName: qsTr("Play %1").arg(root.title) onTriggered: root.playRequested(root.itemId, root.modelIndex) } - ActionButton { iconName: root.favorite ? "heart-fill.svg" : "heart-outline.svg" - accessibleName: root.favorite ? qsTr("Remove %1 from favorites").arg(root.title) : qsTr("Add %1 to favorites").arg(root.title) + accessibleName: root.favorite + ? qsTr("Remove %1 from favorites").arg(root.title) + : qsTr("Add %1 to favorites").arg(root.title) onTriggered: root.favoriteRequested(root.itemId, root.modelIndex) } - ActionButton { iconName: "more-line.svg" accessibleName: qsTr("More actions for %1").arg(root.title) onTriggered: root.moreRequested(root.itemId, root.modelIndex) } - } - } Column { id: labels - anchors.left: root.horizontal ? artwork.right : parent.left anchors.right: parent.right anchors.top: root.horizontal ? parent.top : artwork.bottom @@ -215,7 +295,6 @@ FocusScope { font.weight: Font.DemiBold elide: Text.ElideRight } - Text { width: parent.width visible: text.length > 0 @@ -224,41 +303,46 @@ FocusScope { font.pixelSize: Theme.fontSmall elide: Text.ElideRight } - } HoverHandler { id: hoverHandler - - acceptedDevices: PointerDevice.Mouse + enabled: root.interactionsEnabled + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad } TapHandler { acceptedButtons: Qt.LeftButton + enabled: root.interactionsEnabled onTapped: { root.forceActiveFocus(); root.triggerActivated(); } } + // 按下瞬时反馈,不挂 Behavior component ActionButton: Rectangle { id: action property string iconName: "" property string accessibleName: "" property bool emphasized: false - signal triggered() width: emphasized ? 40 : 34 height: width activeFocusOnTab: true radius: width / 2 - color: emphasized ? Theme.accent : Theme.alpha(Theme.bgRaised, 0.94) - border.width: activeFocus ? 2 : 0 - border.color: Theme.accent + color: emphasized + ? (actionTap.pressed ? Theme.accentHover : Theme.accent) + : (actionTap.pressed ? Theme.alpha(Theme.bgRaised, 0.75) + : Theme.alpha(Theme.bgRaised, 0.94)) + border.width: activeFocus ? 2 : (emphasized ? 0 : 1) + border.color: activeFocus ? Theme.accent : Qt.rgba(1, 1, 1, 0.12) Accessible.role: Accessible.Button Accessible.name: accessibleName + // 无 scale Behavior — 列表里 30 张卡 × 3 按钮 = 90 路潜在动画 + Keys.onEnterPressed: function(event) { action.triggered(); event.accepted = true; @@ -274,16 +358,19 @@ FocusScope { Image { anchors.centerIn: parent - width: action.emphasized ? 19 : 17 + width: action.emphasized ? 18 : 16 height: width - source: "image://adaptiveicon//svg/dark/" + action.iconName + "?d=" + (Theme.dark ? 1 : 0) + source: "image://adaptiveicon//svg/dark/" + action.iconName + + "?d=" + (Theme.dark ? 1 : 0) + asynchronous: true + cache: true + smooth: false } TapHandler { + id: actionTap gesturePolicy: TapHandler.ReleaseWithinBounds onTapped: action.triggered() } - } - } diff --git a/src/qEmbyApp/qml/components/MediaGrid.qml b/src/qEmbyApp/qml/components/MediaGrid.qml index ad7e1a8..783a2e8 100644 --- a/src/qEmbyApp/qml/components/MediaGrid.qml +++ b/src/qEmbyApp/qml/components/MediaGrid.qml @@ -32,6 +32,9 @@ FocusScope { return row && row[roleName] !== undefined && row[roleName] !== null ? row[roleName] : fallback; } + // 滑动/惯性中为 true:传给卡片关掉 hover 动画 + readonly property bool listBusy: view.moving || view.flicking + GridView { id: view @@ -44,9 +47,25 @@ FocusScope { boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: true activeFocusOnTab: true - highlightMoveDuration: Theme.animationFast + highlightMoveDuration: 0 + reuseItems: true + // 少预创建:约 1.25 行,降低离屏纹理与绑定开销 + cacheBuffer: Math.max(cellHeight * 1.25, 140) + flickDeceleration: 3200 + maximumFlickVelocity: 4200 + pixelAligned: true + // 拖动时跳过布局插值,减轻主线程压力 + pressDelay: 0 - ScrollBar.vertical: ThemedScrollBar { + ScrollBar.vertical: ThemedScrollBar {} + + // 入场动画会在快速刷新媒体时叠帧;默认关闭。 + // 需要时可开 Theme 且非 reduceMotion 的短 fade。 + add: Transition { + enabled: false + } + displaced: Transition { + enabled: false } delegate: Item { @@ -62,6 +81,9 @@ FocusScope { id: card width: root.cardWidth + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + interactionsEnabled: !root.listBusy itemId: root.optionalValue(cell.model, "itemId", "") title: root.optionalValue(cell.model, "name", "") subtitle: root.optionalValue(cell.model, "subtitle", "") @@ -78,7 +100,6 @@ FocusScope { onActiveFocusChanged: { if (activeFocus) view.currentIndex = cell.index; - } onActivated: function(itemId, modelIndex) { root.activated(itemId, modelIndex); @@ -93,9 +114,7 @@ FocusScope { root.moreRequested(itemId, modelIndex); } } - } - } Loader { @@ -112,7 +131,6 @@ FocusScope { title: qsTr("Loading media…") running: true } - } Component { @@ -123,7 +141,6 @@ FocusScope { message: root.errorText onRetryRequested: root.retryRequested() } - } Component { @@ -134,7 +151,5 @@ FocusScope { text: root.emptyTitle hint: root.emptyMessage } - } - } diff --git a/src/qEmbyApp/qml/components/MediaList.qml b/src/qEmbyApp/qml/components/MediaList.qml index c510620..43ee6f2 100644 --- a/src/qEmbyApp/qml/components/MediaList.qml +++ b/src/qEmbyApp/qml/components/MediaList.qml @@ -25,6 +25,8 @@ FocusScope { return row && row[roleName] !== undefined && row[roleName] !== null ? row[roleName] : fallback; } + readonly property bool listBusy: view.moving || view.flicking + ListView { id: view @@ -36,11 +38,18 @@ FocusScope { boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: true activeFocusOnTab: true - highlightMoveDuration: Theme.animationFast + highlightMoveDuration: 0 + reuseItems: true + cacheBuffer: Math.max(root.rowHeight * 3, 240) + flickDeceleration: 3000 + maximumFlickVelocity: 4000 + pixelAligned: true ScrollBar.vertical: ThemedScrollBar { } + add: Transition { enabled: false } + delegate: MediaCard { id: card @@ -50,6 +59,7 @@ FocusScope { width: view.width - (view.ScrollBar.vertical.visible ? view.ScrollBar.vertical.width : 0) height: root.rowHeight horizontal: true + interactionsEnabled: !root.listBusy imageAspect: "thumb" itemId: root.optionalValue(model, "itemId", "") title: root.optionalValue(model, "name", "") diff --git a/src/qEmbyApp/qml/components/MediaSection.qml b/src/qEmbyApp/qml/components/MediaSection.qml index 393b90c..242c540 100644 --- a/src/qEmbyApp/qml/components/MediaSection.qml +++ b/src/qEmbyApp/qml/components/MediaSection.qml @@ -28,6 +28,23 @@ ColumnLayout { } spacing: Theme.spacingMd + // 区块入场:短 fade;reduceMotion 时直接可见 + opacity: Theme.reduceMotion ? 1 : 0 + Component.onCompleted: { + if (Theme.reduceMotion) + opacity = 1 + else + sectionEntrance.start() + } + + NumberAnimation { + id: sectionEntrance + target: root + property: "opacity" + to: 1 + duration: 140 + easing.type: Easing.OutQuad + } RowLayout { Layout.fillWidth: true @@ -35,15 +52,34 @@ ColumnLayout { ColumnLayout { Layout.fillWidth: true - spacing: 2 + spacing: 4 - Text { - text: root.title - color: Theme.textPrimary - font.pixelSize: Theme.fontHeading - font.weight: Font.DemiBold - elide: Text.ElideRight + RowLayout { Layout.fillWidth: true + spacing: Theme.spacingSm + + // 标题左侧强调条 + Rectangle { + Layout.preferredWidth: 3 + Layout.preferredHeight: 16 + Layout.alignment: Qt.AlignVCenter + radius: 1.5 + color: Theme.accent + opacity: 0.9 + } + + Text { + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontHeading + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } } Text { @@ -53,8 +89,8 @@ ColumnLayout { font.pixelSize: Theme.fontSmall elide: Text.ElideRight Layout.fillWidth: true + Layout.leftMargin: 11 } - } ThemedButton { @@ -62,9 +98,10 @@ ColumnLayout { text: root.seeAllText onClicked: root.seeAllRequested() } - } + readonly property bool listBusy: view.moving || view.flicking + ListView { id: view @@ -77,15 +114,26 @@ ColumnLayout { boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: true activeFocusOnTab: true + reuseItems: true + // 横滑预取约 1.5 张卡宽,够用且少解码 + cacheBuffer: Math.max(root.cardWidth * 1.5, 180) + flickDeceleration: 3200 + maximumFlickVelocity: 4000 + pixelAligned: true + highlightMoveDuration: 0 - ScrollBar.horizontal: ThemedScrollBar { - } + ScrollBar.horizontal: ThemedScrollBar {} + + // 横滑刷新时入场动画会掉帧,关掉 + add: Transition { enabled: false } + addDisplaced: Transition { enabled: false } delegate: MediaCard { required property int index required property var model width: root.cardWidth + interactionsEnabled: !root.listBusy itemId: root.optionalValue(model, "itemId", "") title: root.optionalValue(model, "name", "") subtitle: root.optionalValue(model, "subtitle", "") @@ -102,7 +150,6 @@ ColumnLayout { onActiveFocusChanged: { if (activeFocus) view.currentIndex = index; - } onActivated: function(itemId, modelIndex) { root.activated(itemId, modelIndex); @@ -117,7 +164,5 @@ ColumnLayout { root.moreRequested(itemId, modelIndex); } } - } - } diff --git a/src/qEmbyApp/qml/components/ModernDialog.qml b/src/qEmbyApp/qml/components/ModernDialog.qml deleted file mode 100644 index 808afa8..0000000 --- a/src/qEmbyApp/qml/components/ModernDialog.qml +++ /dev/null @@ -1,150 +0,0 @@ -import QtQuick -import QtQuick.Controls.Basic -import QtQuick.Layouts - -// Themed modal dialog with dark/light-aware styling. -// Provides title, content area, and standard Ok/Cancel buttons. -Dialog { - id: root - - // ─── Public API ────────────────────────────────────────── - default property alias content: dialogContent.data - - // ─── Dialog setup ──────────────────────────────────────── - modal: true - anchors.centerIn: parent - padding: Theme.spacingXl - spacing: Theme.spacingLg - - // Close on Escape - onOpened: forceActiveFocus() - Keys.onEscapePressed: root.close() - - // ─── Background ────────────────────────────────────────── - background: Rectangle { - radius: Theme.radiusXl - color: Theme.bgSurface - border.color: Theme.border - border.width: 1 - } - - // ─── ContentItem: title + content + buttons ───────────── - contentItem: ColumnLayout { - spacing: Theme.spacingLg - - // Title bar - RowLayout { - visible: root.title.length > 0 - Layout.fillWidth: true - spacing: Theme.spacingSm - - Text { - text: root.title - color: Theme.textPrimary - font.pixelSize: Theme.fontSubtitle - font.weight: 700 - Layout.fillWidth: true - elide: Text.ElideRight - } - } - - // Separator - Rectangle { - visible: root.title.length > 0 - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.border - } - - // Content area — consumer children injected here - ColumnLayout { - id: dialogContent - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Theme.spacingMd - } - - // Separator above buttons - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.border - } - - // Standard buttons - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacingSm - - Item { Layout.fillWidth: true } - - // Cancel - Rectangle { - id: cancelBtn - - property bool hovered: cancelArea.containsMouse - - implicitWidth: cancelLabel.implicitWidth + Theme.spacingXl * 2 - implicitHeight: 32 - radius: Theme.radiusSm - color: hovered ? Theme.bgRaised : "transparent" - border.color: Theme.borderSoft - border.width: 1 - - Text { - id: cancelLabel - anchors.centerIn: parent - text: qsTr("Cancel") - color: Theme.textBody - font.pixelSize: Theme.fontBody - } - - MouseArea { - id: cancelArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.reject() - } - } - - // Ok / Accept - Rectangle { - id: okBtn - - property bool hovered: okArea.containsMouse - - implicitWidth: okLabel.implicitWidth + Theme.spacingXl * 2 - implicitHeight: 32 - radius: Theme.radiusSm - color: hovered ? Theme.accentHover : Theme.accent - - Text { - id: okLabel - anchors.centerIn: parent - text: qsTr("OK") - color: Theme.textPrimary - font.pixelSize: Theme.fontBody - font.weight: 600 - } - - MouseArea { - id: okArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.accept() - } - } - } - } - - // ─── Overlay / backdrop ────────────────────────────────── - Overlay.modal: Rectangle { - color: Theme.dark ? Qt.rgba(0, 0, 0, 0.6) : Qt.rgba(0, 0, 0, 0.35) - } - - Overlay.modeless: Rectangle { - color: Theme.dark ? Qt.rgba(0, 0, 0, 0.4) : Qt.rgba(0, 0, 0, 0.2) - } -} diff --git a/src/qEmbyApp/qml/components/PageHeader.qml b/src/qEmbyApp/qml/components/PageHeader.qml new file mode 100644 index 0000000..48d2021 --- /dev/null +++ b/src/qEmbyApp/qml/components/PageHeader.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Layouts +import qEmby 1.0 + +// 列表/工具页统一顶栏:左侧 accent 标题 + 可选副标题 + 中间/右侧插槽。 +// 用法: +// PageHeader { +// title: qsTr("Favorites") +// ThemedButton { text: qsTr("Refresh") } +// } +Item { + id: root + + property string title: "" + property string subtitle: "" + property real horizontalPadding: Theme.spacingXl + property real topPadding: Theme.spacingLg + property real bottomPadding: Theme.spacingMd + // 默认属性落入右侧操作区 + default property alias actions: actionsRow.data + // 标题与操作之间的中间区(如 Tab 条) + property alias middle: middleSlot.data + + implicitHeight: column.implicitHeight + topPadding + bottomPadding + 1 + implicitWidth: 200 + + ColumnLayout { + id: column + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: root.topPadding + anchors.leftMargin: root.horizontalPadding + anchors.rightMargin: root.horizontalPadding + spacing: Theme.spacingSm + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacingMd + + // 标题簇 + RowLayout { + Layout.maximumWidth: middleSlot.children.length > 0 ? 280 : 480 + Layout.fillWidth: middleSlot.children.length === 0 + spacing: Theme.spacingSm + + Rectangle { + Layout.preferredWidth: 3 + Layout.preferredHeight: 20 + Layout.alignment: Qt.AlignVCenter + radius: 1.5 + color: Theme.accent + opacity: 0.9 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: root.title + color: Theme.textPrimary + font.pixelSize: Theme.fontTitle + font.weight: Font.Bold + elide: Text.ElideRight + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + Text { + visible: root.subtitle.length > 0 + Layout.fillWidth: true + text: root.subtitle + color: Theme.textMuted + font.pixelSize: Theme.fontSmall + elide: Text.ElideRight + } + } + } + + // 中间插槽(tabs 等) + RowLayout { + id: middleSlot + Layout.fillWidth: true + spacing: Theme.spacingSm + } + + // 右侧操作 + RowLayout { + id: actionsRow + spacing: Theme.spacingSm + Layout.alignment: Qt.AlignVCenter + } + } + } + + // 底部分隔 + 极淡 accent 余晖 + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 1 + color: Theme.alpha(Theme.borderSoft, 0.9) + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 10 + opacity: 0.4 + gradient: Gradient { + GradientStop { + position: 0.0 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.08 : 0.05) + } + GradientStop { + position: 1.0 + color: "transparent" + } + } + } +} diff --git a/src/qEmbyApp/qml/components/SearchHistoryPopup.qml b/src/qEmbyApp/qml/components/SearchHistoryPopup.qml new file mode 100644 index 0000000..c22a52b --- /dev/null +++ b/src/qEmbyApp/qml/components/SearchHistoryPopup.qml @@ -0,0 +1,339 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import qEmby + +// ===== 搜索历史弹窗(QML 复刻) ===== +// 复刻 Widgets 外壳 components/searchhistorypopup.cpp 的 chip 流弹窗: +// · 居中挂在搜索框下方 3px,宽 = max(360, anchor+12) +// · chip 流式换行,热度着色(SearchTint::surface 蓝色渐变刻度) +// · 悬浮显示计数徽标 + 删除 x +// · 右上浮动控件: 最近/最热排序切换 · 分隔 · 清空(两击确认) · 展开/收起 +// · 折叠上限 20 条,展开显示全部(>20 才出现展开按钮) +// 数据来自 searchHistory.entries: [{term,count,lastMs}]。 +Popup { + id: root + + // 挂靠的搜索框(用于定位/取宽)。 + required property var anchorItem + + signal termActivated(string term) + + // "recent" | "count" + property string sortMode: "recent" + property bool expanded: false + property bool clearArmed: false + + readonly property int visibleCap: 20 + + padding: 0 + modal: false + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + transformOrigin: Item.Top + + width: Math.max(360, (anchorItem ? anchorItem.width : 0) + 12) + // 居中挂在 anchor 下方 6px(anchor 与 root 同一父项 titleBar)。 + x: anchorItem ? anchorItem.x + (anchorItem.width - width) / 2 : 0 + y: anchorItem ? anchorItem.y + anchorItem.height + 6 : 0 + + enter: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationQuick + } + NumberAnimation { + property: "scale" + from: 0.96 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + } + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: Theme.animationMicro + } + } + + onOpened: { clearArmed = false } + onClosed: { clearArmed = false } + + // ── 数据:按当前排序取,折叠时截断到 20 ────────────────────────── + readonly property var sortedEntries: { + const src = (searchHistory.entries || []).slice() + if (sortMode === "count") { + src.sort(function(a, b) { + if (b.count !== a.count) return b.count - a.count + if (b.lastMs !== a.lastMs) return b.lastMs - a.lastMs + return a.term.localeCompare(b.term) + }) + } else { + src.sort(function(a, b) { + if (b.lastMs !== a.lastMs) return b.lastMs - a.lastMs + if (b.count !== a.count) return b.count - a.count + return a.term.localeCompare(b.term) + }) + } + return src + } + readonly property var shownEntries: + expanded ? sortedEntries : sortedEntries.slice(0, visibleCap) + readonly property bool canExpand: sortedEntries.length > visibleCap + + // ── 热度着色:复刻 ColorTokens::SearchTint::surface 的 mix 刻度 ───── + function _mix(a, b, f) { + return Qt.rgba(a.r + (b.r - a.r) * f, + a.g + (b.g - a.g) * f, + a.b + (b.b - a.b) * f, 1) + } + function _heatT(count) { + if (count >= 10) return 1.0 // top + if (count >= 6) return 2 / 3 // high + if (count >= 3) return 1 / 3 // mid + return 0.0 // low + } + function heatColors(count) { + const t = _heatT(count) + const d = Theme.dark + // Tailwind tokens. + const Slate50 = "#F8FAFC", Slate100 = "#F1F5F9", Slate200 = "#E2E8F0", + Slate300 = "#CBD5E1", Slate600 = "#475569", Slate700 = "#334155", + Slate800 = "#1E293B", Slate900 = "#0F172A" + const Blue50 = "#EFF6FF", Blue100 = "#DBEAFE", Blue200 = "#BFDBFE", + Blue300 = "#93C5FD", Blue400 = "#60A5FA", Blue800 = "#1E40AF", + Blue900 = "#1E3A8A" + if (d) { + return { + bgTop: _mix(Qt.color(Slate800), Qt.color(Blue800), t * 0.45), + bgBottom: _mix(Qt.color(Slate900), Qt.color(Blue900), t * 0.45), + border: _mix(Qt.color(Slate600), Qt.color(Blue400), t * 0.45), + text: _mix(Qt.color(Slate300), Qt.color(Blue50), t) + } + } + return { + bgTop: _mix(Qt.color(Slate50), Qt.color(Blue100), t * 0.75), + bgBottom: _mix(Qt.color(Slate100), Qt.color(Blue200), t * 0.6), + border: _mix(Qt.color(Slate200), Qt.color(Blue300), t * 0.6), + text: _mix(Qt.color(Slate700), Qt.color(Blue800), t) + } + } + + background: Rectangle { + color: Theme.dark ? Theme.alpha(Theme.bgBase, 0.98) : Theme.alpha(Theme.bgSurface, 0.98) + radius: 16 + border.width: 1 + border.color: Theme.alpha(Theme.border, 0.85) + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + } + + contentItem: Item { + implicitHeight: bodyScroll.implicitHeight + 16 + + // ── 右上浮动控件簇 ──────────────────────────────────────────── + Row { + id: controls + z: 2 + anchors { top: parent.top; right: parent.right; topMargin: 6; rightMargin: 8 } + spacing: 2 + + component HeaderBtn: Rectangle { + id: hb + property string icon + property bool active: false + property bool armedStyle: false + signal activated() + width: 22; height: 22; radius: 8 + color: hb.armedStyle ? Theme.alpha(Theme.danger, Theme.dark ? 0.18 : 0.14) + : hb.active ? Theme.alpha(Theme.accent, Theme.dark ? 0.16 : 0.11) + : hbMouse.containsMouse ? Theme.alpha(Theme.textPrimary, 0.07) + : "transparent" + scale: hbMouse.pressed ? 0.9 : hbMouse.containsMouse ? 1.08 : 1 + Behavior on color { + ColorAnimation { duration: Theme.animationMicro } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Image { + anchors.centerIn: parent + width: 13; height: 13 + sourceSize: Qt.size(26, 26) + source: "image://adaptiveicon/" + hb.icon + "?d=" + (Theme.dark ? 1 : 0) + opacity: hb.active || hbMouse.containsMouse ? 1 : 0.78 + Behavior on opacity { + NumberAnimation { duration: Theme.animationMicro } + } + } + MouseArea { + id: hbMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: hb.activated() + } + } + + HeaderBtn { + icon: "/svg/dark/search-history-recent.svg" + active: root.sortMode === "recent" + onActivated: { root.sortMode = "recent"; root.clearArmed = false } + } + HeaderBtn { + icon: "/svg/dark/search-history-hot.svg" + active: root.sortMode === "count" + onActivated: { root.sortMode = "count"; root.clearArmed = false } + } + Rectangle { // divider + width: 1; height: 13 + anchors.verticalCenter: parent.verticalCenter + color: Theme.border + } + HeaderBtn { + // 两击确认清空:首击置 armed,再击真正清空。 + icon: root.clearArmed ? "/svg/dark/check.svg" : "/svg/dark/delete.svg" + armedStyle: root.clearArmed + onActivated: { + if (!root.clearArmed) { + root.clearArmed = true + } else { + root.clearArmed = false + searchHistory.clearHistory() + root.close() + } + } + } + HeaderBtn { + visible: root.canExpand + icon: "/svg/dark/chevron-down.svg" + active: root.expanded + rotation: root.expanded ? 180 : 0 + Behavior on rotation { NumberAnimation { duration: Theme.animationFast } } + onActivated: root.expanded = !root.expanded + } + } + + // ── chip 流 ─────────────────────────────────────────────────── + ScrollView { + id: bodyScroll + anchors.fill: parent + anchors.margins: 8 + clip: true + implicitHeight: Math.min(root.expanded ? 320 : 228, + flow.implicitHeight) + ScrollBar.vertical: ThemedScrollBar {} + ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AlwaysOff } + + Flow { + id: flow + width: bodyScroll.availableWidth + spacing: 2 + // 首行为浮动控件预留空间。 + topPadding: 2 + + Repeater { + model: root.shownEntries + delegate: Rectangle { + id: chip + required property var modelData + required property int index + readonly property var hc: root.heatColors(modelData.count) + + height: 28 + width: Math.max(46, Math.min(158, chipText.implicitWidth + 24)) + radius: height / 2 + color: hc.bgTop + border.width: 1 + border.color: hc.border + scale: chipMouse.containsMouse ? 1.05 : 1 + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + // 渐变 bgTop→bgBottom(用叠加层近似)。 + Rectangle { + anchors.fill: parent + radius: parent.radius + gradient: Gradient { + GradientStop { position: 0.0; color: chip.hc.bgTop } + GradientStop { position: 1.0; color: chip.hc.bgBottom } + } + border.width: 1 + border.color: chip.hc.border + } + + Text { + id: chipText + anchors.centerIn: parent + leftPadding: 10; rightPadding: 10 + text: chip.modelData.term + color: chip.hc.text + font.pixelSize: Math.max(11, Math.round(12 * 1.0)) + font.weight: Font.Medium + elide: Text.ElideRight + width: Math.min(implicitWidth, 158 - 20) + } + + // 计数徽标(左上,悬浮显示)。 + Rectangle { + visible: chipMouse.containsMouse + anchors { left: parent.left; top: parent.top; leftMargin: -2; topMargin: -2 } + width: 16; height: 12; radius: 6 + color: Theme.alpha(chip.hc.border, 0.9) + Text { + anchors.centerIn: parent + text: chip.modelData.count > 999 ? "999+" : chip.modelData.count + color: Theme.dark ? "#F8FAFC" : "#1D4ED8" + font.pixelSize: 8 + font.weight: Font.Bold + } + } + + // 删除 x(右上,悬浮显示)。 + Rectangle { + visible: chipMouse.containsMouse + anchors { right: parent.right; top: parent.top; rightMargin: -2; topMargin: -2 } + width: 14; height: 14; radius: 7 + color: Theme.danger + Text { + anchors.centerIn: parent + text: "×" + color: "white" + font.pixelSize: 10 + font.weight: Font.Bold + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: searchHistory.removeTerm(chip.modelData.term) + } + } + + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.termActivated(chip.modelData.term) + } + } + } + } + } + } +} diff --git a/src/qEmbyApp/qml/components/SegmentedTabs.qml b/src/qEmbyApp/qml/components/SegmentedTabs.qml new file mode 100644 index 0000000..7a10fc9 --- /dev/null +++ b/src/qEmbyApp/qml/components/SegmentedTabs.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Layouts +import qEmby 1.0 + +// 轻量分段 Tab 条(搜索 / 媒体库 等共用) +// model: string[] labels +// currentIndex + currentIndexChanged +Row { + id: root + + property var model: [] + property int currentIndex: 0 + property int tabHeight: 32 + + spacing: 4 + + Repeater { + model: root.model + + delegate: Rectangle { + id: tab + required property int index + required property string modelData + readonly property bool selected: root.currentIndex === index + + implicitWidth: label.implicitWidth + 24 + implicitHeight: root.tabHeight + radius: height / 2 + color: selected + ? Theme.alpha(Theme.accent, Theme.dark ? 0.2 : 0.14) + : tabHover.hovered ? Theme.alpha(Theme.textPrimary, 0.06) + : "transparent" + border.width: 1 + border.color: selected + ? Theme.alpha(Theme.accent, 0.45) + : "transparent" + scale: tabTap.pressed ? 0.96 : tabHover.hovered ? 1.03 : 1 + + Behavior on color { + ColorAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationQuick } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + Text { + id: label + anchors.centerIn: parent + text: tab.modelData + color: tab.selected ? Theme.accent + : tabHover.hovered ? Theme.textPrimary + : Theme.textBody + font.pixelSize: Theme.fontBody + font.weight: tab.selected ? Font.DemiBold : Font.Normal + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + + HoverHandler { id: tabHover } + TapHandler { + id: tabTap + onTapped: root.currentIndex = tab.index + } + } + } +} diff --git a/src/qEmbyApp/qml/components/SettingsCard.qml b/src/qEmbyApp/qml/components/SettingsCard.qml index d871239..249e0d5 100644 --- a/src/qEmbyApp/qml/components/SettingsCard.qml +++ b/src/qEmbyApp/qml/components/SettingsCard.qml @@ -1,9 +1,10 @@ import QtQuick import QtQuick.Layouts +import qEmby 1.0 // Reusable styled card container for settings pages. // Provides optional title/description header and a content area. -// All styling flows through Theme.tokens — zero hardcoded values. +// 微交互:悬停时边框略亮、轻微上浮,主题切换平滑变色。 Rectangle { id: root @@ -21,9 +22,32 @@ Rectangle { // ─── Appearance ────────────────────────────────────────── radius: Theme.radiusLg - color: Theme.bgSurface - border.color: Theme.border + color: cardHover.hovered ? Theme.bgRaised : Theme.bgSurface + border.color: cardHover.hovered + ? Theme.alpha(Theme.accent, Theme.dark ? 0.28 : 0.22) + : Theme.border border.width: 1 + scale: cardHover.hovered ? 1.006 : 1 + transformOrigin: Item.Center + z: cardHover.hovered ? 1 : 0 + + Behavior on color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + + HoverHandler { + id: cardHover + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + } // ─── Internal layout ───────────────────────────────────── ColumnLayout { @@ -42,6 +66,10 @@ Rectangle { font.pixelSize: Theme.fontSubtitle font.weight: 700 Layout.fillWidth: true + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } } // Optional description @@ -63,7 +91,11 @@ Rectangle { Layout.topMargin: Theme.spacingXs Layout.bottomMargin: Theme.spacingXs Layout.preferredHeight: 1 - color: Theme.border + color: Theme.alpha(Theme.border, 0.85) + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } } // Content area — consumers inject children here via default property diff --git a/src/qEmbyApp/qml/components/ThemedButton.qml b/src/qEmbyApp/qml/components/ThemedButton.qml index 50b042e..e642de3 100644 --- a/src/qEmbyApp/qml/components/ThemedButton.qml +++ b/src/qEmbyApp/qml/components/ThemedButton.qml @@ -5,31 +5,62 @@ import qEmby 1.0 Basic.Button { id: control + property bool prominent: false + implicitWidth: Math.max(88, contentItem.implicitWidth + leftPadding + rightPadding) - implicitHeight: 34 + implicitHeight: 36 leftPadding: Theme.spacingLg rightPadding: Theme.spacingLg font.pixelSize: Theme.fontBody font.weight: Font.Medium + scale: control.down ? 0.96 : control.hovered ? 1.018 : 1 + transformOrigin: Item.Center + opacity: control.enabled ? 1 : 0.55 + + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast } + } contentItem: Text { text: control.text font: control.font - color: control.enabled ? Theme.textPrimary : Theme.textMuted + color: !control.enabled ? Theme.textMuted + : control.prominent ? "#FFFFFF" : Theme.textPrimary horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight + + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } } background: Rectangle { + id: buttonBackground radius: Theme.radiusMd color: !control.enabled ? Theme.alpha(Theme.bgRaised, 0.55) - : control.down ? Theme.alpha(Theme.accent, 0.28) - : control.hovered ? Theme.alpha(Theme.accent, 0.16) + : control.prominent && control.down ? Theme.accentHover + : control.prominent ? (control.hovered ? Theme.accentHover : Theme.accent) + : control.down ? Theme.alpha(Theme.accent, 0.22) + : control.hovered ? Theme.alpha(Theme.accent, 0.11) : Theme.bgRaised border.width: control.activeFocus ? 2 : 1 - border.color: control.activeFocus ? Theme.accent : Theme.borderSoft + border.color: control.prominent ? Theme.alpha(Theme.accentHover, 0.9) + : control.activeFocus ? Theme.accent + : control.hovered ? Theme.alpha(Theme.accent, 0.42) + : Theme.borderSoft - Behavior on color { ColorAnimation { duration: Theme.animationFast } } + Behavior on color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } } } diff --git a/src/qEmbyApp/qml/components/ThemedComboBox.qml b/src/qEmbyApp/qml/components/ThemedComboBox.qml index f68d05a..3471d8b 100644 --- a/src/qEmbyApp/qml/components/ThemedComboBox.qml +++ b/src/qEmbyApp/qml/components/ThemedComboBox.qml @@ -7,12 +7,24 @@ import qEmby 1.0 Basic.ComboBox { id: control + // 使用 ListModel 且 role 为 text 时,调用方需设置 textRole: "text"。 + // 默认不设,避免纯字符串数组 model 显示异常。 + implicitWidth: Math.max(160, contentItem.implicitWidth + leftPadding + rightPadding) implicitHeight: 36 leftPadding: Theme.spacingMd rightPadding: 34 font.pixelSize: Theme.fontBody font.weight: Font.Normal + scale: control.down ? 0.985 : control.hovered ? 1.01 : 1 + transformOrigin: Item.Center + + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } delegate: Basic.ItemDelegate { id: option @@ -59,7 +71,11 @@ Basic.ComboBox { color: control.enabled ? Theme.bgSurface : Theme.alpha(Theme.bgRaised, 0.5) border.width: control.activeFocus || control.popup.visible ? 2 : 1 border.color: control.activeFocus || control.popup.visible ? Theme.accent - : Theme.borderSoft + : control.hovered ? Theme.alpha(Theme.accent, 0.4) + : Theme.borderSoft + + Behavior on color { ColorAnimation { duration: Theme.animationFast } } + Behavior on border.color { ColorAnimation { duration: Theme.animationFast } } } popup: Basic.Popup { @@ -67,6 +83,33 @@ Basic.ComboBox { width: control.width implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding, 280) padding: Theme.spacingXs + transformOrigin: Item.Top + + enter: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationQuick + } + NumberAnimation { + property: "scale" + from: 0.96 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + } + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: Theme.animationMicro + } + } contentItem: ListView { clip: true diff --git a/src/qEmbyApp/qml/components/ThemedDialog.qml b/src/qEmbyApp/qml/components/ThemedDialog.qml index bf0e515..d85c933 100644 --- a/src/qEmbyApp/qml/components/ThemedDialog.qml +++ b/src/qEmbyApp/qml/components/ThemedDialog.qml @@ -23,6 +23,45 @@ Basic.Dialog { padding: Theme.spacingXl closePolicy: Basic.Popup.CloseOnEscape implicitWidth: 440 + transformOrigin: Item.Center + + enter: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: 0.94 + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } + exit: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } + NumberAnimation { + property: "scale" + from: 1 + to: 0.96 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } + } + } + onOpened: { if (confirmButton.visible && confirmButton.enabled) confirmButton.forceActiveFocus(); @@ -38,21 +77,34 @@ Basic.Dialog { Keys.onEnterPressed: function(event) { if (confirmButton.visible && confirmButton.enabled) root.accept(); - event.accepted = true; } Keys.onReturnPressed: function(event) { if (confirmButton.visible && confirmButton.enabled) root.accept(); - event.accepted = true; } background: Rectangle { - radius: Theme.radiusXl - color: Theme.bgSurface + radius: 16 + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.98 : 0.99) border.width: 1 - border.color: Theme.borderSoft + border.color: Theme.alpha(Theme.borderSoft, 0.95) + + // 顶部极淡强调线 + Rectangle { + anchors { left: parent.left; right: parent.right; top: parent.top } + height: 1 + radius: 1 + color: Theme.alpha(Theme.accent, Theme.dark ? 0.22 : 0.16) + } + + Behavior on color { + ColorAnimation { duration: Theme.animationFast } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast } + } } contentItem: ColumnLayout { @@ -73,7 +125,7 @@ Basic.Dialog { Rectangle { visible: root.title.length > 0 - color: Theme.border + color: Theme.alpha(Theme.border, 0.85) Layout.fillWidth: true Layout.preferredHeight: 1 } @@ -87,7 +139,7 @@ Basic.Dialog { Rectangle { visible: root.showCancelButton || root.showConfirmButton - color: Theme.border + color: Theme.alpha(Theme.border, 0.85) Layout.fillWidth: true Layout.preferredHeight: 1 } @@ -131,27 +183,34 @@ Basic.Dialog { background: Rectangle { radius: Theme.radiusMd - color: !confirmButton.enabled ? Theme.alpha(Theme.bgRaised, 0.55) : confirmButton.down ? Theme.alpha(root.confirmColor, 0.72) : confirmButton.hovered ? Theme.alpha(root.confirmColor, 0.86) : root.confirmColor + color: !confirmButton.enabled + ? Theme.alpha(Theme.bgRaised, 0.55) + : confirmButton.down + ? Theme.alpha(root.confirmColor, 0.72) + : confirmButton.hovered + ? Theme.alpha(root.confirmColor, 0.9) + : root.confirmColor border.width: confirmButton.activeFocus ? 2 : 0 - border.color: confirmButton.activeFocus ? Theme.alpha("#FFFFFF", 0.9) : "transparent" + border.color: confirmButton.activeFocus + ? Theme.alpha("#FFFFFF", 0.9) + : "transparent" Behavior on color { - ColorAnimation { - duration: Theme.animationFast - } - + ColorAnimation { duration: Theme.animationFast } } - } - } - } - } Basic.Overlay.modal: Rectangle { - color: Theme.dark ? Qt.rgba(0, 0, 0, 0.62) : Qt.rgba(15, 23, 42, 0.34) - } + color: Theme.dark ? Qt.rgba(0, 0, 0, 0.58) : Qt.rgba(15 / 255, 23 / 255, 42 / 255, 0.32) + Behavior on opacity { + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + } + } } diff --git a/src/qEmbyApp/qml/components/ThemedScrollBar.qml b/src/qEmbyApp/qml/components/ThemedScrollBar.qml index c2d92e5..2918b92 100644 --- a/src/qEmbyApp/qml/components/ThemedScrollBar.qml +++ b/src/qEmbyApp/qml/components/ThemedScrollBar.qml @@ -5,20 +5,35 @@ import qEmby 1.0 Basic.ScrollBar { id: control - implicitWidth: 10 - implicitHeight: 10 + implicitWidth: control.hovered || control.pressed ? 12 : 10 + implicitHeight: control.hovered || control.pressed ? 12 : 10 padding: 2 policy: Basic.ScrollBar.AsNeeded + Behavior on implicitWidth { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + Behavior on implicitHeight { + NumberAnimation { duration: Theme.animationQuick; easing.type: Easing.OutCubic } + } + contentItem: Rectangle { implicitWidth: 6 implicitHeight: 80 radius: width / 2 - color: control.pressed || control.hovered ? Theme.textMuted : Theme.borderSoft - opacity: control.active ? 0.9 : 0.55 + color: control.pressed ? Theme.textBody + : control.hovered ? Theme.textMuted + : Theme.borderSoft + opacity: control.policy === Basic.ScrollBar.AlwaysOn + ? (control.active ? 0.95 : 0.45) + : (control.active || control.hovered ? 0.92 : 0) - Behavior on opacity { NumberAnimation { duration: Theme.animationFast } } - Behavior on color { ColorAnimation { duration: Theme.animationFast } } + Behavior on opacity { + NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } } background: Item {} diff --git a/src/qEmbyApp/qml/components/ThemedSlider.qml b/src/qEmbyApp/qml/components/ThemedSlider.qml index c2b42d1..f40b55a 100644 --- a/src/qEmbyApp/qml/components/ThemedSlider.qml +++ b/src/qEmbyApp/qml/components/ThemedSlider.qml @@ -5,37 +5,121 @@ import qEmby 1.0 Basic.Slider { id: control - implicitWidth: 220 - implicitHeight: 30 + // player: 深色半透明轨道,适合视频叠加 HUD + property bool playerStyle: false + // 缓冲进度 0–1(画在已播放进度之下,仅 playerStyle 时有意义) + property real bufferPosition: 0 + property real trackHeight: playerStyle + ? (control.pressed || control.hovered ? 7 : 4) + : 6 - background: Rectangle { + implicitWidth: 220 + implicitHeight: playerStyle ? 28 : 30 + + background: Item { x: control.leftPadding y: control.topPadding + control.availableHeight / 2 - height / 2 implicitWidth: 200 - implicitHeight: 6 + implicitHeight: control.trackHeight + 8 width: control.availableWidth height: implicitHeight - radius: height / 2 - color: Theme.bgRaised Rectangle { - width: control.visualPosition * parent.width - height: parent.height - radius: parent.radius - color: control.enabled ? Theme.accent : Theme.textMuted + id: track + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: control.trackHeight + radius: height / 2 + color: control.playerStyle + ? Qt.rgba(1, 1, 1, control.hovered || control.pressed ? 0.22 : 0.14) + : Theme.bgRaised + clip: true + + Behavior on height { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + + // 缓冲段(白色半透明,从 0 到 bufferPosition) + Rectangle { + visible: control.playerStyle + width: Math.max(0, Math.min(1, control.bufferPosition)) * parent.width + height: parent.height + radius: parent.radius + color: Qt.rgba(1, 1, 1, 0.28) + + Behavior on width { + NumberAnimation { + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + } + } + + // 已播放填充 + Rectangle { + width: control.visualPosition * parent.width + height: parent.height + radius: parent.radius + color: !control.enabled ? Theme.textMuted : Theme.accent + + Rectangle { + visible: control.playerStyle + anchors.fill: parent + radius: parent.radius + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(1, 1, 1, 0.22) } + GradientStop { position: 1.0; color: "transparent" } + } + } + } } } handle: Rectangle { x: control.leftPadding + control.visualPosition * (control.availableWidth - width) y: control.topPadding + control.availableHeight / 2 - height / 2 - implicitWidth: control.pressed ? 20 : 18 + implicitWidth: control.playerStyle + ? (control.pressed ? 16 : control.hovered ? 14 : 12) + : (control.pressed ? 21 : control.hovered ? 20 : 18) implicitHeight: implicitWidth radius: width / 2 color: "#FFFFFF" - border.width: control.activeFocus ? 3 : 2 + border.width: control.playerStyle + ? (control.pressed || control.activeFocus ? 2 : 0) + : (control.activeFocus ? 3 : 2) border.color: control.enabled ? Theme.accent : Theme.textMuted + scale: control.pressed ? 0.92 : 1 - Behavior on implicitWidth { NumberAnimation { duration: Theme.animationFast } } + Rectangle { + visible: control.playerStyle + anchors.centerIn: parent + width: parent.width + 8 + height: width + radius: width / 2 + z: -1 + color: Theme.alpha(Theme.accent, control.hovered || control.pressed ? 0.35 : 0) + Behavior on color { + ColorAnimation { duration: Theme.animationQuick } + } + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } + } } } diff --git a/src/qEmbyApp/qml/components/ThemedSwitch.qml b/src/qEmbyApp/qml/components/ThemedSwitch.qml index 2dc98dd..1cf9588 100644 --- a/src/qEmbyApp/qml/components/ThemedSwitch.qml +++ b/src/qEmbyApp/qml/components/ThemedSwitch.qml @@ -15,7 +15,8 @@ Basic.Switch { font.weight: Font.Normal indicator: Rectangle { - implicitWidth: 42 + id: track + implicitWidth: 44 implicitHeight: 24 x: control.leftPadding y: (control.height - height) / 2 @@ -26,21 +27,49 @@ Basic.Switch { border.width: control.activeFocus ? 2 : 1 border.color: control.activeFocus ? Theme.accentHover : (control.checked ? Theme.accent : Theme.border) + scale: control.down ? 0.96 : control.hovered ? 1.03 : 1 + transformOrigin: Item.Center + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast } + } + + // 拇指 Rectangle { + id: thumb width: 18 height: 18 y: 3 x: control.checked ? parent.width - width - 3 : 3 radius: width / 2 color: control.enabled ? "#FFFFFF" : Theme.textMuted + scale: control.down ? 0.88 : 1 + // 轻微抬升感 + opacity: control.enabled ? 1 : 0.7 Behavior on x { - NumberAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + NumberAnimation { + duration: Theme.animationFast + easing.type: Easing.OutBack + easing.overshoot: 1.4 + } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationMicro + easing.type: Easing.OutCubic + } } } - - Behavior on color { ColorAnimation { duration: Theme.animationFast } } } contentItem: Text { diff --git a/src/qEmbyApp/qml/components/ThemedTextField.qml b/src/qEmbyApp/qml/components/ThemedTextField.qml index 99eeb25..8711b0e 100644 --- a/src/qEmbyApp/qml/components/ThemedTextField.qml +++ b/src/qEmbyApp/qml/components/ThemedTextField.qml @@ -18,10 +18,26 @@ Basic.TextField { background: Rectangle { radius: Theme.radiusMd - color: control.enabled ? Theme.bgSurface : Theme.alpha(Theme.bgRaised, 0.5) - border.width: control.activeFocus ? 2 : 1 - border.color: control.activeFocus ? Theme.accent : Theme.borderSoft + color: !control.enabled ? Theme.alpha(Theme.bgRaised, 0.5) + : control.activeFocus ? Theme.bgRaised : Theme.bgSurface + border.width: control.activeFocus ? 1.5 : 1 + border.color: control.activeFocus ? Theme.accent + : control.hovered ? Theme.alpha(Theme.accent, 0.38) + : Theme.borderSoft + scale: control.activeFocus ? 1.008 : 1 + transformOrigin: Item.Center - Behavior on border.color { ColorAnimation { duration: Theme.animationFast } } + Behavior on color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on border.color { + ColorAnimation { duration: Theme.animationFast; easing.type: Easing.OutCubic } + } + Behavior on scale { + NumberAnimation { + duration: Theme.animationQuick + easing.type: Easing.OutCubic + } + } } } diff --git a/src/qEmbyApp/qml/components/ToastHost.qml b/src/qEmbyApp/qml/components/ToastHost.qml index 9fd9f60..20cfe58 100644 --- a/src/qEmbyApp/qml/components/ToastHost.qml +++ b/src/qEmbyApp/qml/components/ToastHost.qml @@ -72,22 +72,45 @@ Item { z: 1000 add: Transition { - NumberAnimation { - properties: "opacity,x" - from: 0 - duration: Theme.animationFast - easing.type: Easing.OutCubic + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: Theme.animationFast + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "x" + from: 40 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: 0.94 + to: 1 + duration: Theme.animationNormal + easing.type: Easing.OutCubic + } } - } remove: Transition { - NumberAnimation { - property: "opacity" - to: 0 - duration: Theme.animationFast + ParallelAnimation { + NumberAnimation { + property: "opacity" + to: 0 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } + NumberAnimation { + property: "x" + to: 24 + duration: Theme.animationQuick + easing.type: Easing.InCubic + } } - } displaced: Transition { @@ -96,7 +119,6 @@ Item { duration: Theme.animationFast easing.type: Easing.OutCubic } - } delegate: Rectangle { @@ -112,13 +134,25 @@ Item { width: toastView.width implicitHeight: toastLayout.implicitHeight + Theme.spacingMd * 2 - radius: Theme.radiusLg - color: Theme.bgSurface + radius: 14 + color: Theme.alpha(Theme.bgSurface, Theme.dark ? 0.96 : 0.98) border.width: 1 - border.color: Theme.alpha(statusColor, 0.72) + border.color: Theme.alpha(statusColor, 0.55) + transformOrigin: Item.Right Accessible.role: Accessible.AlertMessage Accessible.name: qsTr("%1: %2").arg(statusLabel).arg(message) + // 左侧状态色条 + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 1 + width: 3 + radius: 2 + color: toast.statusColor + } + RowLayout { id: toastLayout diff --git a/src/qEmbyApp/qml/theme/Theme.qml b/src/qEmbyApp/qml/theme/Theme.qml deleted file mode 100644 index 6f70a0e..0000000 --- a/src/qEmbyApp/qml/theme/Theme.qml +++ /dev/null @@ -1,81 +0,0 @@ -pragma Singleton -import QtQuick -import qEmby 1.0 - -// QML 侧的设计令牌单例 — 唯一的 QML 主题入口。 -// 所有值与 C++ tokens/ColorTokens.h、tokens/DesignTokens.h、tokens/AnimationTokens.h -// 严格一致。颜色通过 QuickTheme.dark 三元切换,间距/圆角/字号/动画为常量。 -QtObject { - id: root - - // ─── 语义色(跟随暗色 / 亮色主题) ─────────────────────── - readonly property QtObject colors: QtObject { - // 背景层级 - readonly property color bgBase: QuickTheme.dark ? "#0F172A" : "#F8FAFC" - readonly property color bgSurface: QuickTheme.dark ? "#1E293B" : "#F1F5F9" - readonly property color bgRaised: QuickTheme.dark ? "#334155" : "#E2E8F0" - - // 边框 - readonly property color border: QuickTheme.dark ? "#334155" : "#E2E8F0" - readonly property color borderSoft: QuickTheme.dark ? "#475569" : "#CBD5E1" - - // 文字 - readonly property color textPrimary: QuickTheme.dark ? "#F1F5F9" : "#0F172A" - readonly property color textSecondary: QuickTheme.dark ? "#E2E8F0" : "#1E293B" - readonly property color textBody: QuickTheme.dark ? "#CBD5E1" : "#334155" - readonly property color textMuted: QuickTheme.dark ? "#94A3B8" : "#64748B" - - // 强调色 - readonly property color accent: QuickTheme.dark ? "#3B82F6" : "#2563EB" - readonly property color accentHover: QuickTheme.dark ? "#60A5FA" : "#3B82F6" - - // 状态色(不随主题变化) - readonly property color danger: "#EF4444" - readonly property color warning: "#F59E0B" - readonly property color success: "#22C55E" - } - - // ─── 间距(DesignTokens::Spacing) ──────────────────────── - readonly property QtObject spacing: QtObject { - readonly property int xs: 4 - readonly property int sm: 8 - readonly property int md: 12 - readonly property int lg: 16 - readonly property int xl: 24 - } - - // ─── 圆角(DesignTokens::Radius) ───────────────────────── - readonly property QtObject radius: QtObject { - readonly property int xs: 2 - readonly property int sm: 4 - readonly property int md: 6 - readonly property int lg: 8 - readonly property int xl: 14 - } - - // ─── 字号(DesignTokens::FontSize,基准值) ─────────────── - // 注意:C++ 侧 QuickTheme 会乘以 ThemeManager::fontScaleFactor(), - // 此处为未缩放的基准值。需要缩放的场景请直接用 QuickTheme.fontXxx。 - readonly property QtObject fonts: QtObject { - readonly property int caption: 10 - readonly property int small: 11 - readonly property int body: 12 - readonly property int bodyPlus: 13 - readonly property int heading: 14 - readonly property int subtitle: 16 - readonly property int title: 20 - readonly property int display: 24 - readonly property int hero: 28 - } - - // ─── 动画时长(AnimationTokens::DurationMs) ────────────── - readonly property QtObject animation: QtObject { - readonly property int micro: 120 - readonly property int quick: 150 - readonly property int fast: 180 - readonly property int normal: 300 - readonly property int slow: 400 - readonly property int gentle: 800 - readonly property int shimmer: 1800 - } -} diff --git a/src/qEmbyApp/quick/appshellhost.cpp b/src/qEmbyApp/quick/appshellhost.cpp new file mode 100644 index 0000000..bf4a564 --- /dev/null +++ b/src/qEmbyApp/quick/appshellhost.cpp @@ -0,0 +1,360 @@ +#include "appshellhost.h" + +#include "aboutviewmodel.h" +#include "embyimageprovider.h" +#include "loginviewmodel.h" +#include "manageroutecontroller.h" +#include "mediaroutecontroller.h" +#include "navigationcontroller.h" +#include "playerroutecontroller.h" +#include "playerviewmodel.h" +#include "searchhistoryviewmodel.h" +#include "shelllifecyclecontroller.h" +#include "sidebarviewmodel.h" +#include "settingsviewmodel.h" + +#include "../components/moderntoast.h" +#include "../managers/playbackmanager.h" +#include "../views/baseview.h" + +#include "adminlibrariesviewmodel.h" +#include "dashboardviewmodel.h" +#include "logsviewmodel.h" +#include "tasksviewmodel.h" + +#include "../views/admin/collectionsviewmodel.h" +#include "../views/admin/transcodingviewmodel.h" +#include "../views/admin/usersviewmodel.h" + +#include "../views/media/detailviewmodel.h" +#include "../views/media/libraryviewmodel.h" +#include "../views/media/seasonviewmodel.h" +#include "../views/search/searchviewmodel.h" +#include "../views/user/categoryviewmodel.h" +#include "../views/user/dashboardviewmodel.h" +#include "../views/user/favoritesviewmodel.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ +const QUrl kShellUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AppShell.qml")); +} + +AppShellHost::AppShellHost(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core) +{ + // Hidden top-level QWidget acting as the parent for the Widget dialogs and + // popup menus that stay Widget-coupled in the QML shell. Never shown. + m_dialogAnchor = new QWidget(nullptr); + m_dialogAnchor->setObjectName(QStringLiteral("qml-shell-dialog-anchor")); + m_dialogAnchor->setAttribute(Qt::WA_DontShowOnScreen, true); + m_dialogAnchor->resize(0, 0); + + m_navigation = new NavigationController(this); + connect(m_navigation, &NavigationController::navigationRejected, this, + [](const QString &code, const QString &message, + const QVariantMap &request) + { + spdlog::warn("[AppShellHost] navigation rejected: {} {} {}", + code.toStdString(), message.toStdString(), + request.value(QStringLiteral("name")) + .toString() + .toStdString()); + }); +} + +AppShellHost::~AppShellHost() +{ + shutdownUi(); + delete m_dialogAnchor; + m_dialogAnchor = nullptr; +} + +void AppShellHost::clearContextProperties() +{ + if (!m_engine) + return; + + // Drop C++ model / VM references from the QML context *before* destroying + // the scene. ListView/Repeater (QQmlDelegateModel) must release models while + // the QObjects are still valid; otherwise engine teardown can SEGV in + // QQmlDelegateModel::~QQmlDelegateModel. + QQmlContext *ctx = m_engine->rootContext(); + static const char *kProps[] = { + "navigation", + "login", + "dashboardPage", + "favoritesPage", + "searchPage", + "categoryPage", + "libraryPage", + "detailPage", + "seasonPage", + "player", + "settings", + "about", + "manageController", + "searchHistory", + "sidebar", + "dashboard", + "librariesPage", + "collectionsPage", + "transcodingPage", + "usersPage", + "tasksPage", + "logsPage", + "shellLifecycle", + }; + for (const char *name : kProps) + ctx->setContextProperty(QString::fromLatin1(name), QVariant()); +} + +void AppShellHost::shutdownUi() +{ + if (m_uiShutdown) + return; + m_uiShutdown = true; + + if (m_lifecycle) + m_lifecycle->setWindow(nullptr); + + // Must NOT run while still inside QQuickWindow::closeEvent (that stack + // calls quit → aboutToQuit). Call only after QCoreApplication::exec() + // returns, or from ~AppShellHost once the event loop is gone. + if (m_window) { + m_window->hide(); + m_window = nullptr; + } + + if (!m_engine) + return; + + // Engine dies while ViewModels (and their QAbstractItemModels) still live. + clearContextProperties(); + delete m_engine; + m_engine = nullptr; +} + +void AppShellHost::registerImageProviders() +{ + m_engine->addImageProvider(QStringLiteral("emby"), + new EmbyImageProvider(m_core)); + m_engine->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); +} + +void AppShellHost::registerViewModels() +{ + // Login lives in the shared engine as the `login` route. It needs a QWidget + // dialogParent for the (now QML-based) confirm flow's legacy fallbacks; the + // hidden anchor satisfies the ctor contract without a visible parent. + m_loginViewModel = new LoginViewModel(m_core, m_dialogAnchor, this); + m_engine->rootContext()->setContextProperty(QStringLiteral("login"), + m_loginViewModel); + connect(m_loginViewModel, &LoginViewModel::loginCompleted, this, + &AppShellHost::onLoginCompleted); + + // The five session media pages (home/favorites/search/category/library + + // person/filtered) share one controller parented to the hidden dialog + // anchor. It supplies the BaseView action machinery their ViewModels talk + // to and drives per-route ViewModel activation off NavigationController. + // The context-property names must match what Router.qml's pages bind to. + m_mediaRoutes = new MediaRouteController(m_core, m_navigation, m_dialogAnchor); + + // Playback. In the Widgets shell HomeView called PlaybackManager::init() and + // caught BaseView::navigateToPlayer -> startPlayback. startPlayback is the + // sole arbiter of the four launch modes (external quick-play -> independent + // window config -> no-embedded-host fallback -> requestEmbeddedPlay), so we + // keep routing navigateToPlayer through it and mark an embedded host as now + // available. External quick-play and the PlayerIndependentWindow config path + // return before the embedded branch and stay unchanged; only the embedded + // branch fires requestEmbeddedPlay, which the PlayerRouteController turns + // into a push of the in-shell `player` route. + PlaybackManager::instance()->init(m_core); + PlaybackManager::instance()->setEmbeddedHostAvailable(true); + connect(m_mediaRoutes, &BaseView::navigateToPlayer, this, + [](const QString &mediaId, const QString &title, + const QString &streamUrl, long long startPositionTicks, + const QVariant &extraData) + { + PlaybackManager::instance()->startPlayback( + mediaId, title, streamUrl, startPositionTicks, extraData); + }); + + m_playerRoutes = new PlayerRouteController(m_core, m_navigation, this); + connect(PlaybackManager::instance(), &PlaybackManager::requestEmbeddedPlay, + m_playerRoutes, &PlayerRouteController::onRequestEmbeddedPlay); + m_playerRoutes->playerViewModel()->setDialogParent(m_dialogAnchor); + + QQmlContext *ctx = m_engine->rootContext(); + ctx->setContextProperty(QStringLiteral("dashboardPage"), + m_mediaRoutes->dashboardViewModel()); + ctx->setContextProperty(QStringLiteral("favoritesPage"), + m_mediaRoutes->favoritesViewModel()); + ctx->setContextProperty(QStringLiteral("searchPage"), + m_mediaRoutes->searchViewModel()); + ctx->setContextProperty(QStringLiteral("categoryPage"), + m_mediaRoutes->categoryViewModel()); + ctx->setContextProperty(QStringLiteral("libraryPage"), + m_mediaRoutes->libraryViewModel()); + ctx->setContextProperty(QStringLiteral("detailPage"), + m_mediaRoutes->detailViewModel()); + ctx->setContextProperty(QStringLiteral("seasonPage"), + m_mediaRoutes->seasonViewModel()); + ctx->setContextProperty(QStringLiteral("player"), + m_playerRoutes->playerViewModel()); + + // Settings route: ConfigStore + cache/proxy actions (core + dialog anchor). + m_settingsViewModel = + new SettingsViewModel(m_core, m_dialogAnchor, this); + ctx->setContextProperty(QStringLiteral("settings"), m_settingsViewModel); + connect(m_settingsViewModel, &SettingsViewModel::toastRequested, this, + [](const QString &message) { + ModernToast::showMessage(message, 2200); + }); + m_aboutViewModel = new AboutViewModel(m_dialogAnchor, this); + ctx->setContextProperty(QStringLiteral("about"), m_aboutViewModel); + + // Manage route. In the Widgets shell ManageView + its seven Page* wrappers + // each owned a ViewModel and (for users/tasks/transcoding) Widget dialogs. + // ManageRouteController owns all seven VMs plus that dialog wiring, parented + // to the hidden anchor. It is exposed as `manageController` so ManagePage.qml + // can drive per-section activation; the seven VMs bind by the same context + // names the Admin*Page.qml files already use. + m_manageRoutes = + new ManageRouteController(m_core, m_navigation, m_dialogAnchor, this); + ctx->setContextProperty(QStringLiteral("manageController"), m_manageRoutes); + + // Global search history. In the Widgets shell MainWindow recorded searches + // and drove SearchHistoryPopup + a QCompleter directly against the + // SearchHistoryManager singleton. That singleton has no QML surface, so the + // TitleBar's search box binds to this wrapper instead — it owns the + // serverId derivation and turns the manager's plain signals into NOTIFYs. + m_searchHistoryViewModel = + new SearchHistoryViewModel(m_core, m_navigation, this); + ctx->setContextProperty(QStringLiteral("searchHistory"), + m_searchHistoryViewModel); + + // Sidebar. In the Widgets shell HomeView built the sidebar imperatively and + // read the sidebar ConfigStore keys + active profile directly. SideBar.qml + // binds to this VM instead: config-driven layout state, profile identity, + // and the dynamic library list. Exposed as `sidebar`. + m_sidebarViewModel = + new SidebarViewModel(m_core, m_navigation, m_dialogAnchor, this); + ctx->setContextProperty(QStringLiteral("sidebar"), m_sidebarViewModel); + ctx->setContextProperty(QStringLiteral("dashboard"), + m_manageRoutes->dashboardViewModel()); + ctx->setContextProperty(QStringLiteral("librariesPage"), + m_manageRoutes->librariesViewModel()); + ctx->setContextProperty(QStringLiteral("collectionsPage"), + m_manageRoutes->collectionsViewModel()); + ctx->setContextProperty(QStringLiteral("transcodingPage"), + m_manageRoutes->transcodingViewModel()); + ctx->setContextProperty(QStringLiteral("usersPage"), + m_manageRoutes->usersViewModel()); + ctx->setContextProperty(QStringLiteral("tasksPage"), + m_manageRoutes->tasksViewModel()); + ctx->setContextProperty(QStringLiteral("logsPage"), + m_manageRoutes->logsViewModel()); + + // Shell lifecycle. The Widgets shell kept tray integration, the close-to-tray + // decision, the stop-playback-before-quit sequence, and StartupWindowState in + // MainWindow. Those non-UI duties move to this controller (plan 步骤 4). It is + // registered as `shellLifecycle` so AppShell.qml's onClosing can consult it; + // its QQuickWindow is injected in load() once the root object resolves. + m_lifecycle = new ShellLifecycleController(m_core, this); + m_lifecycle->setEmbeddedPlayer(m_playerRoutes->playerViewModel()); + ctx->setContextProperty(QStringLiteral("shellLifecycle"), m_lifecycle); +} + +void AppShellHost::onLoginCompleted() +{ + bool administrator = false; + if (m_core && m_core->serverManager()) + { + const auto profile = m_core->serverManager()->activeProfile(); + administrator = profile.isValid() && profile.isAdmin; + } + // login -> home: clears the stack and roots at `home`, unlocking every + // authenticated route in NavigationController::normalize(). + m_navigation->enterAuthenticatedSession(administrator); + + // The sidebar identity/library list depends on the now-active profile. + // activeServerChanged may have fired before this VM connected (or not at + // all if the profile was restored pre-login), so refresh explicitly. + if (m_sidebarViewModel) + m_sidebarViewModel->refresh(); +} + +bool AppShellHost::load() +{ + // Unparented: we explicitly shut the engine down on aboutToQuit / dtor so + // QApplication child destruction order cannot kill VMs before the scene. + m_engine = new QQmlApplicationEngine(); + + // QWindowKit QML types (WindowAgent) must be registered on this engine. + QWK::registerTypes(m_engine); + + // Engine-level singletons/context objects. Page ViewModels are added in a + // later increment; this foundation only wires navigation + providers so + // the shell skeleton can load and run. + m_engine->rootContext()->setContextProperty(QStringLiteral("navigation"), + m_navigation); + registerImageProviders(); + registerViewModels(); + + m_engine->load(kShellUrl); + + if (m_engine->rootObjects().isEmpty()) + { + QStringList messages; + messages.append(QStringLiteral("AppShell.qml produced no root object")); + spdlog::error("[AppShellHost] failed to load AppShell.qml"); + emit loadFailed(messages); + return false; + } + + m_window = + qobject_cast(m_engine->rootObjects().constFirst()); + if (!m_window) + { + spdlog::error("[AppShellHost] root object is not a QQuickWindow"); + emit loadFailed({QStringLiteral("AppShell root is not a Window")}); + return false; + } + + // Hand the resolved window to the lifecycle controller and apply the + // startup window state (normal/maximized) the way MainWindow's ctor did. + if (m_lifecycle) + { + m_lifecycle->setWindow(m_window); + m_lifecycle->applyStartupWindowState(); + } + + // Do not destroy the engine from aboutToQuit: quit is often reached from + // ApplicationWindow.onClosing → handleWindowClose → qApp->quit(), i.e. + // still inside QQuickWindow::closeEvent. Deleting the engine there aborts + // with a QML fatal while destroying the window mid-event. + // main() calls shutdownUi() after a.exec() returns instead. + + // The Widgets LoginView called activate() from showEvent; there is no such + // hook in the QML shell, so prime the login form once the engine is up. + m_loginViewModel->activate(); + + return true; +} diff --git a/src/qEmbyApp/quick/appshellhost.h b/src/qEmbyApp/quick/appshellhost.h new file mode 100644 index 0000000..a509e2d --- /dev/null +++ b/src/qEmbyApp/quick/appshellhost.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +class QEmbyCore; +class NavigationController; +class LoginViewModel; +class MediaRouteController; +class ManageRouteController; +class PlayerRouteController; +class SettingsViewModel; +class AboutViewModel; +class SearchHistoryViewModel; +class SidebarViewModel; +class ShellLifecycleController; +class QQmlApplicationEngine; +class QQuickWindow; +class QWidget; + +// Owns the single, application-level QML engine for the full Qt Quick shell. +// +// Unlike QuickRouterHost (a QQuickWidget embedded inside the legacy Widgets +// shell), AppShellHost drives a top-level QML ApplicationWindow via +// QQmlApplicationEngine. It is the one place where every engine-level +// registration lives: +// - the `navigation` context property (NavigationController) +// - the `emby` and `adaptiveicon` image providers +// - every page ViewModel exposed as a context property +// +// A QQmlApplicationEngine has no top-level QWidget, yet a handful of +// ViewModels/dialogs still require a QWidget parent (About/AdminLibraries/Logs +// dialogParent ctors, MediaActionMenu, QFileDialog pickers). AppShellHost owns +// a hidden top-level QWidget — the "dialog anchor" — for exactly those cases. +// It is never shown; it only provides a valid QWidget parent and follows the +// QML window position so native dialogs center correctly. +class AppShellHost final : public QObject +{ + Q_OBJECT + +public: + explicit AppShellHost(QEmbyCore *core, QObject *parent = nullptr); + ~AppShellHost() override; + + // Instantiates the engine, registers everything, and loads AppShell.qml. + // Returns false when the root object failed to load (errors logged). + bool load(); + + // Tear down the QML engine while C++ context objects are still alive. + // Safe to call multiple times. Call only after QCoreApplication::exec() + // returns (see main) — never from aboutToQuit / closeEvent. + void shutdownUi(); + + QQuickWindow *window() const { return m_window; } + QWidget *dialogAnchor() const { return m_dialogAnchor; } + NavigationController *navigation() const { return m_navigation; } + +Q_SIGNALS: + void loadFailed(const QStringList &errors); + +private: + void registerImageProviders(); + void registerViewModels(); + void clearContextProperties(); + + // Enters the authenticated session (login -> home) after a successful + // login, reading the active profile's admin flag. Also handles logout. + void onLoginCompleted(); + + QEmbyCore *m_core = nullptr; + NavigationController *m_navigation = nullptr; + LoginViewModel *m_loginViewModel = nullptr; + MediaRouteController *m_mediaRoutes = nullptr; + ManageRouteController *m_manageRoutes = nullptr; + PlayerRouteController *m_playerRoutes = nullptr; + SettingsViewModel *m_settingsViewModel = nullptr; + AboutViewModel *m_aboutViewModel = nullptr; + SearchHistoryViewModel *m_searchHistoryViewModel = nullptr; + SidebarViewModel *m_sidebarViewModel = nullptr; + ShellLifecycleController *m_lifecycle = nullptr; + QQmlApplicationEngine *m_engine = nullptr; + QQuickWindow *m_window = nullptr; + QWidget *m_dialogAnchor = nullptr; + bool m_uiShutdown = false; +}; diff --git a/src/qEmbyApp/components/nativedanmakuoverlay.cpp b/src/qEmbyApp/quick/danmakupainteditem.cpp similarity index 81% rename from src/qEmbyApp/components/nativedanmakuoverlay.cpp rename to src/qEmbyApp/quick/danmakupainteditem.cpp index 618276f..4d9b560 100644 --- a/src/qEmbyApp/components/nativedanmakuoverlay.cpp +++ b/src/qEmbyApp/quick/danmakupainteditem.cpp @@ -1,20 +1,20 @@ -#include "nativedanmakuoverlay.h" +#include "danmakupainteditem.h" +#include "../components/mpvcontroller.h" -#include "mpvcontroller.h" #include +#include #include #include #include -#include #include #include #include -#include #include -#include #include #include +#include +#include #include #include #include @@ -142,44 +142,60 @@ bool renderAppearanceEqual(const DanmakuRenderOptions &lhs, } -NativeDanmakuOverlay::NativeDanmakuOverlay(MpvController *controller, - QWidget *parent) - : QWidget(parent), m_controller(controller) +DanmakuPaintedItem::DanmakuPaintedItem(QQuickItem *parent) + : QQuickPaintedItem(parent) { - setAttribute(Qt::WA_TransparentForMouseEvents, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAttribute(Qt::WA_NoSystemBackground, true); - setAttribute(Qt::WA_StaticContents, true); - hide(); - + // Timers must exist before any setVisible/itemChange path runs — + // setVisible(false) emits ItemVisibleHasChanged which calls + // updateScreenRefreshBinding → syncFrameTimer. m_frameTimer = new QTimer(this); m_frameTimer->setTimerType(Qt::PreciseTimer); - m_frameTimer->setInterval(kFallbackFrameIntervalMs); - connect(m_frameTimer, &QTimer::timeout, this, - &NativeDanmakuOverlay::advanceFrame); + connect(m_frameTimer, &QTimer::timeout, this, [this]() { advanceFrame(); }); m_scheduleRebuildTimer = new QTimer(this); m_scheduleRebuildTimer->setSingleShot(true); - m_scheduleRebuildTimer->setInterval(kResizeRebuildDebounceMs); - connect(m_scheduleRebuildTimer, &QTimer::timeout, this, - &NativeDanmakuOverlay::rebuildScheduleNow); + connect(m_scheduleRebuildTimer, &QTimer::timeout, this, [this]() { + rebuildScheduleNow(); + }); - updateScreenRefreshBinding(); + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); + setOpaquePainting(false); + setAntialiasing(true); + setRenderTarget(QQuickPaintedItem::FramebufferObject); + setFillColor(Qt::transparent); + setVisible(false); - if (!m_controller) { + m_positionClock.invalidate(); +} + +void DanmakuPaintedItem::setController(MpvController *controller) +{ + if (m_controller == controller) + return; + if (m_controller) + disconnect(m_controller, nullptr, this, nullptr); + m_controller = controller; + if (!m_controller) return; - } + // Match NativeDanmakuOverlay: always re-anchor from the absolute mpv + // time-pos. Never feed currentPositionMs() back into refreshBasePosition + // (that only re-stamps the local extrapolated clock and ignores seeks). connect(m_controller, &MpvController::positionChanged, this, [this](double positionSeconds) { const qint64 positionMs = std::max( - 0, static_cast(std::llround(positionSeconds * 1000.0))); + 0, static_cast( + std::llround(positionSeconds * 1000.0))); const qint64 driftMs = std::llabs(positionMs - currentPositionMs()); refreshBasePosition(positionMs); + // Seeks / large jumps: rebuild the active set immediately so + // danmaku snaps to the new timeline instead of coasting. if (m_playbackPaused || driftMs > 240) { rebuildActiveItems(positionMs); - update(); + if (m_visibleRequested) + update(); } }); connect(m_controller, &MpvController::playbackStateChanged, this, @@ -187,19 +203,27 @@ NativeDanmakuOverlay::NativeDanmakuOverlay(MpvController *controller, m_basePositionMs = currentPositionMs(); m_playbackPaused = isPaused; m_positionClock.invalidate(); - if (!m_playbackPaused) { + if (!m_playbackPaused) m_positionClock.start(); - } syncFrameTimer(); update(); }); connect(m_controller, &MpvController::propertyChanged, this, - [this](const QString &property, const QVariant &value) { - handleControllerPropertyChanged(property, value); - }); + &DanmakuPaintedItem::handleControllerPropertyChanged); + + const QVariant pauseProp = m_controller->getProperty(QStringLiteral("pause")); + if (pauseProp.isValid()) + m_playbackPaused = pauseProp.toBool(); + const QVariant posProp = m_controller->getProperty(QStringLiteral("time-pos")); + if (posProp.isValid()) { + refreshBasePosition(std::max( + 0, static_cast(std::llround(posProp.toDouble() * 1000.0)))); + } + updateScreenRefreshBinding(); + syncFrameTimer(); } -void NativeDanmakuOverlay::setRenderOptions(DanmakuRenderOptions options) +void DanmakuPaintedItem::setRenderOptions(DanmakuRenderOptions options) { if (renderOptionsEqual(m_options, options)) { return; @@ -211,7 +235,7 @@ void NativeDanmakuOverlay::setRenderOptions(DanmakuRenderOptions options) requestScheduleRebuild(false, appearanceChanged); } -void NativeDanmakuOverlay::setComments(QList comments) +void DanmakuPaintedItem::setComments(QList comments) { m_comments = comments; m_sortedComments = comments; @@ -225,7 +249,7 @@ void NativeDanmakuOverlay::setComments(QList comments) requestScheduleRebuild(false, false); } -void NativeDanmakuOverlay::clearDanmaku() +void DanmakuPaintedItem::clearDanmaku() { if (m_scheduleRebuildTimer) { m_scheduleRebuildTimer->stop(); @@ -235,24 +259,24 @@ void NativeDanmakuOverlay::clearDanmaku() m_scheduleDirty = false; m_clearSpriteCacheOnNextRebuild = false; clearScheduledState(true); - hide(); + setVisible(false); syncFrameTimer(); update(); } -bool NativeDanmakuOverlay::hasComments() const +bool DanmakuPaintedItem::hasComments() const { return !m_comments.isEmpty(); } -void NativeDanmakuOverlay::setDanmakuVisible(bool visible) +void DanmakuPaintedItem::setDanmakuVisible(bool visible) { m_visibleRequested = visible; if (m_visibleRequested && m_scheduleDirty) { rebuildScheduleNow(); } const bool shouldShow = m_visibleRequested && !m_schedule.isEmpty(); - QWidget::setVisible(shouldShow); + QQuickItem::setVisible(shouldShow); if (shouldShow) { rebuildActiveItems(currentPositionMs()); } @@ -260,7 +284,7 @@ void NativeDanmakuOverlay::setDanmakuVisible(bool visible) update(); } -void NativeDanmakuOverlay::setBottomSubtitleProtected(bool enabled) +void DanmakuPaintedItem::setBottomSubtitleProtected(bool enabled) { if (m_bottomSubtitleProtected == enabled) { return; @@ -271,17 +295,17 @@ void NativeDanmakuOverlay::setBottomSubtitleProtected(bool enabled) requestScheduleRebuild(false, false); } -void NativeDanmakuOverlay::setExternalFrameClockEnabled(bool enabled) +void DanmakuPaintedItem::setExternalFrameClockEnabled(bool enabled) { if (m_externalFrameClockEnabled == enabled) { return; } - m_externalFrameClockEnabled = enabled; + bindWindow(window()); syncFrameTimer(); } -void NativeDanmakuOverlay::advanceFrame() +void DanmakuPaintedItem::advanceFrame() { if (!shouldAdvanceFrames()) { return; @@ -297,11 +321,13 @@ void NativeDanmakuOverlay::advanceFrame() update(); } -void NativeDanmakuOverlay::paintEvent(QPaintEvent *event) +void DanmakuPaintedItem::paint(QPainter *painterPtr) { - QPainter painter(this); + if (!painterPtr) + return; + QPainter &painter = *painterPtr; painter.setCompositionMode(QPainter::CompositionMode_Source); - painter.fillRect(event ? event->rect() : rect(), Qt::transparent); + painter.fillRect(QRectF(0, 0, width(), height()), Qt::transparent); if (!m_visibleRequested || m_activeIndexes.isEmpty()) { return; @@ -312,7 +338,7 @@ void NativeDanmakuOverlay::paintEvent(QPaintEvent *event) const qreal positionMs = currentPositionMsPrecise(); const QRectF surfaceRect = cachedEffectiveSurfaceRect(); - const qreal dpr = std::max(1.0, devicePixelRatioF()); + const qreal dpr = std::max(1.0, (window() ? window()->devicePixelRatio() : 1.0)); const auto alignToDevicePixel = [dpr](qreal value) { return std::round(value * dpr) / dpr; }; @@ -353,26 +379,45 @@ void NativeDanmakuOverlay::paintEvent(QPaintEvent *event) } } -void NativeDanmakuOverlay::resizeEvent(QResizeEvent *event) +void DanmakuPaintedItem::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) { - QWidget::resizeEvent(event); + QQuickPaintedItem::geometryChange(newGeometry, oldGeometry); + if (newGeometry.size() == oldGeometry.size()) + return; markSurfaceRectDirty(); requestScheduleRebuild(false, false); updateScreenRefreshBinding(); } -void NativeDanmakuOverlay::showEvent(QShowEvent *event) +void DanmakuPaintedItem::itemChange(ItemChange change, const ItemChangeData &value) { - QWidget::showEvent(event); - updateScreenRefreshBinding(); + QQuickPaintedItem::itemChange(change, value); + if (change == ItemSceneChange || change == ItemVisibleHasChanged) { + bindWindow(window()); + updateScreenRefreshBinding(); + syncFrameTimer(); + } } -void NativeDanmakuOverlay::rebuildSchedule() +void DanmakuPaintedItem::bindWindow(QQuickWindow *win) +{ + if (m_frameSwappedConnection) + disconnect(m_frameSwappedConnection); + if (!win || !m_externalFrameClockEnabled) + return; + m_frameSwappedConnection = connect(win, &QQuickWindow::frameSwapped, this, + [this]() { + if (m_externalFrameClockEnabled) + advanceFrame(); + }); +} + +void DanmakuPaintedItem::rebuildSchedule() { requestScheduleRebuild(true, false); } -void NativeDanmakuOverlay::requestScheduleRebuild(bool immediate, +void DanmakuPaintedItem::requestScheduleRebuild(bool immediate, bool clearSpriteCache) { m_scheduleDirty = true; @@ -392,7 +437,7 @@ void NativeDanmakuOverlay::requestScheduleRebuild(bool immediate, } } -void NativeDanmakuOverlay::rebuildScheduleNow() +void DanmakuPaintedItem::rebuildScheduleNow() { if (m_scheduleRebuildTimer) { m_scheduleRebuildTimer->stop(); @@ -404,15 +449,15 @@ void NativeDanmakuOverlay::rebuildScheduleNow() m_clearSpriteCacheOnNextRebuild = false; clearScheduledState(clearSpriteCache); - if (m_sortedComments.isEmpty() || size().isEmpty()) { - QWidget::setVisible(false); + if (m_sortedComments.isEmpty() || (width() <= 0 || height() <= 0)) { + QQuickItem::setVisible(false); syncFrameTimer(); update(); return; } const QRectF surfaceRect = cachedEffectiveSurfaceRect(); - const qreal viewportScale = viewportScaleForSize(size()); + const qreal viewportScale = viewportScaleForSize(QSize(qMax(1, int(width())), qMax(1, int(height())))); const QStringList blockedKeywords = normalizeBlockedKeywords(m_options.blockedKeywords); const double opacity = qBound(0.05, m_options.opacity, 1.0); @@ -592,12 +637,12 @@ void NativeDanmakuOverlay::rebuildScheduleNow() rebuildActiveItems(currentPositionMs()); warmSpriteCacheAround(currentPositionMsPrecise()); - QWidget::setVisible(m_visibleRequested && !m_schedule.isEmpty()); + QQuickItem::setVisible(m_visibleRequested && !m_schedule.isEmpty()); syncFrameTimer(); update(); } -void NativeDanmakuOverlay::warmSpriteCacheAround(qreal positionMs) +void DanmakuPaintedItem::warmSpriteCacheAround(qreal positionMs) { int warmed = 0; for (int i = 0; i < m_schedule.size() && warmed < kSpriteWarmupLimit; ++i) { @@ -614,7 +659,7 @@ void NativeDanmakuOverlay::warmSpriteCacheAround(qreal positionMs) } } -void NativeDanmakuOverlay::rebuildActiveItems(qint64 positionMs) +void DanmakuPaintedItem::rebuildActiveItems(qint64 positionMs) { m_activeIndexes.clear(); m_nextScheduleIndex = m_schedule.size(); @@ -634,7 +679,7 @@ void NativeDanmakuOverlay::rebuildActiveItems(qint64 positionMs) m_lastResolvedPositionMs = positionMs; } -void NativeDanmakuOverlay::updateActiveItems(qint64 positionMs) +void DanmakuPaintedItem::updateActiveItems(qint64 positionMs) { while (m_nextScheduleIndex < m_schedule.size() && m_schedule.at(m_nextScheduleIndex).startMs <= positionMs) { @@ -654,7 +699,7 @@ void NativeDanmakuOverlay::updateActiveItems(qint64 positionMs) m_lastResolvedPositionMs = positionMs; } -void NativeDanmakuOverlay::clearScheduledState(bool clearSpriteCache) +void DanmakuPaintedItem::clearScheduledState(bool clearSpriteCache) { m_schedule.clear(); m_activeIndexes.clear(); @@ -665,8 +710,11 @@ void NativeDanmakuOverlay::clearScheduledState(bool clearSpriteCache) m_lastResolvedPositionMs = -1; } -void NativeDanmakuOverlay::syncFrameTimer() +void DanmakuPaintedItem::syncFrameTimer() { + if (!m_frameTimer) + return; + if (m_externalFrameClockEnabled) { m_frameTimer->stop(); return; @@ -687,13 +735,13 @@ void NativeDanmakuOverlay::syncFrameTimer() m_frameTimer->stop(); } -bool NativeDanmakuOverlay::shouldAdvanceFrames() const +bool DanmakuPaintedItem::shouldAdvanceFrames() const { return m_visibleRequested && isVisible() && !m_schedule.isEmpty() && !m_playbackPaused; } -int NativeDanmakuOverlay::targetFrameIntervalMs() const +int DanmakuPaintedItem::targetFrameIntervalMs() const { const QScreen *screen = targetScreen(); const qreal refreshRate = screen ? screen->refreshRate() : 0.0; @@ -706,30 +754,18 @@ int NativeDanmakuOverlay::targetFrameIntervalMs() const kMaxFrameIntervalMs); } -QScreen *NativeDanmakuOverlay::targetScreen() const +QScreen *DanmakuPaintedItem::targetScreen() const { - if (QScreen *widgetScreen = screen()) { - return widgetScreen; + if (QQuickWindow *win = window()) { + if (QScreen *s = win->screen()) + return s; } - if (const QWidget *topLevel = window()) { - if (QWindow *handle = topLevel->windowHandle()) { - if (QScreen *windowScreen = handle->screen()) { - return windowScreen; - } - } - if (QScreen *windowScreen = topLevel->screen()) { - return windowScreen; - } - } - return nullptr; + return m_observedScreen.data(); } -void NativeDanmakuOverlay::updateScreenRefreshBinding() +void DanmakuPaintedItem::updateScreenRefreshBinding() { - QWindow *currentWindow = nullptr; - if (QWidget *topLevel = window()) { - currentWindow = topLevel->windowHandle(); - } + QWindow *currentWindow = window(); if (m_observedWindow != currentWindow) { if (m_windowScreenConnection) { @@ -763,16 +799,15 @@ void NativeDanmakuOverlay::updateScreenRefreshBinding() connect(m_observedScreen, &QScreen::refreshRateChanged, this, [this](qreal) { syncFrameTimer(); }); } - syncFrameTimer(); } -void NativeDanmakuOverlay::markSurfaceRectDirty() +void DanmakuPaintedItem::markSurfaceRectDirty() { m_surfaceRectDirty = true; } -QRectF NativeDanmakuOverlay::cachedEffectiveSurfaceRect() const +QRectF DanmakuPaintedItem::cachedEffectiveSurfaceRect() const { if (m_surfaceRectDirty) { m_cachedSurfaceRect = effectiveSurfaceRect(); @@ -781,7 +816,7 @@ QRectF NativeDanmakuOverlay::cachedEffectiveSurfaceRect() const return m_cachedSurfaceRect; } -void NativeDanmakuOverlay::updateVideoDisplaySize(const QString &property, +void DanmakuPaintedItem::updateVideoDisplaySize(const QString &property, const QVariant &value) { qreal *target = nullptr; @@ -809,7 +844,7 @@ void NativeDanmakuOverlay::updateVideoDisplaySize(const QString &property, requestScheduleRebuild(false, false); } -void NativeDanmakuOverlay::refreshBasePosition(qint64 positionMs) +void DanmakuPaintedItem::refreshBasePosition(qint64 positionMs) { m_basePositionMs = std::max(0, positionMs); m_positionClock.invalidate(); @@ -818,7 +853,7 @@ void NativeDanmakuOverlay::refreshBasePosition(qint64 positionMs) } } -qreal NativeDanmakuOverlay::currentPositionMsPrecise() const +qreal DanmakuPaintedItem::currentPositionMsPrecise() const { if (m_playbackPaused || !m_positionClock.isValid()) { return m_basePositionMs; @@ -828,14 +863,14 @@ qreal NativeDanmakuOverlay::currentPositionMsPrecise() const return m_basePositionMs + elapsedMs * m_playbackSpeed; } -qint64 NativeDanmakuOverlay::currentPositionMs() const +qint64 DanmakuPaintedItem::currentPositionMs() const { return static_cast(std::llround(currentPositionMsPrecise())); } -QRectF NativeDanmakuOverlay::effectiveSurfaceRect() const +QRectF DanmakuPaintedItem::effectiveSurfaceRect() const { - QRectF surface(rect()); + QRectF surface(0, 0, width(), height()); if (surface.isEmpty()) { return surface; } @@ -873,7 +908,7 @@ QRectF NativeDanmakuOverlay::effectiveSurfaceRect() const return surface; } -qreal NativeDanmakuOverlay::laneTop(const ScheduledItem &item) const +qreal DanmakuPaintedItem::laneTop(const ScheduledItem &item) const { const QRectF surfaceRect = cachedEffectiveSurfaceRect(); if (item.type == ItemType::Bottom) { @@ -883,12 +918,12 @@ qreal NativeDanmakuOverlay::laneTop(const ScheduledItem &item) const return surfaceRect.top() + m_topMargin + item.laneIndex * m_lineHeight; } -qreal NativeDanmakuOverlay::spriteLogicalWidth(const QPixmap &sprite) const +qreal DanmakuPaintedItem::spriteLogicalWidth(const QPixmap &sprite) const { return sprite.width() / std::max(1.0, sprite.devicePixelRatio()); } -QString NativeDanmakuOverlay::spriteCacheKey(const ScheduledItem &item) const +QString DanmakuPaintedItem::spriteCacheKey(const ScheduledItem &item) const { return QStringLiteral("%1|%2|%3|%4|%5|%6|%7") .arg(item.text) @@ -900,7 +935,7 @@ QString NativeDanmakuOverlay::spriteCacheKey(const ScheduledItem &item) const .arg(QFont().family()); } -const QPixmap *NativeDanmakuOverlay::spriteForItem(const ScheduledItem &item) +const QPixmap *DanmakuPaintedItem::spriteForItem(const ScheduledItem &item) { const QString key = spriteCacheKey(item); auto it = m_spriteCache.find(key); @@ -920,7 +955,7 @@ const QPixmap *NativeDanmakuOverlay::spriteForItem(const ScheduledItem &item) const qreal width = std::ceil(item.visualWidth); const qreal height = std::ceil(metrics.height() + m_spritePadding * 2.0); - const qreal dpr = std::max(1.0, devicePixelRatioF()); + const qreal dpr = std::max(1.0, (window() ? window()->devicePixelRatio() : 1.0)); QPixmap sprite(static_cast(std::ceil(width * dpr)), static_cast(std::ceil(height * dpr))); @@ -959,7 +994,7 @@ const QPixmap *NativeDanmakuOverlay::spriteForItem(const ScheduledItem &item) return &it.value(); } -void NativeDanmakuOverlay::handleControllerPropertyChanged(const QString &property, +void DanmakuPaintedItem::handleControllerPropertyChanged(const QString &property, const QVariant &value) { if (property == QLatin1String("speed")) { diff --git a/src/qEmbyApp/components/nativedanmakuoverlay.h b/src/qEmbyApp/quick/danmakupainteditem.h similarity index 71% rename from src/qEmbyApp/components/nativedanmakuoverlay.h rename to src/qEmbyApp/quick/danmakupainteditem.h index cc21163..fc4c008 100644 --- a/src/qEmbyApp/components/nativedanmakuoverlay.h +++ b/src/qEmbyApp/quick/danmakupainteditem.h @@ -1,47 +1,54 @@ -#ifndef NATIVEDANMAKUOVERLAY_H -#define NATIVEDANMAKUOVERLAY_H +#ifndef DANMAKUPAINTEDITEM_H +#define DANMAKUPAINTEDITEM_H #include #include #include #include #include +#include #include #include -#include +#include #include +#include "../components/idanmakusurface.h" class MpvController; -class QPaintEvent; -class QResizeEvent; class QScreen; -class QShowEvent; class QTimer; class QVariant; class QRectF; class QWindow; -class NativeDanmakuOverlay : public QWidget +// QML-native danmaku surface. Ports NativeDanmakuOverlay's schedule/sprite +// pipeline onto QQuickPaintedItem so it can sit above MpvItem in PlayerPage. +// Frame clock is driven externally (PlayerPage connects beforeRendering / +// frameSwapped → advanceFrame) when externalFrameClockEnabled is true. +class DanmakuPaintedItem : public QQuickPaintedItem, public IDanmakuSurface { + Q_OBJECT + QML_ELEMENT + public: - explicit NativeDanmakuOverlay(MpvController *controller, - QWidget *parent = nullptr); + explicit DanmakuPaintedItem(QQuickItem *parent = nullptr); - void setRenderOptions(DanmakuRenderOptions options); - void setComments(QList comments); - void clearDanmaku(); - bool hasComments() const; + void setController(MpvController *controller); - void setDanmakuVisible(bool visible); - void setBottomSubtitleProtected(bool enabled); - void setExternalFrameClockEnabled(bool enabled); - void advanceFrame(); + void setRenderOptions(DanmakuRenderOptions options) override; + void setComments(QList comments) override; + void clearDanmaku() override; + bool hasComments() const override; + void setDanmakuVisible(bool visible) override; + void setBottomSubtitleProtected(bool enabled) override; + void setExternalFrameClockEnabled(bool enabled) override; + void advanceFrame() override; + + void paint(QPainter *painter) override; protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - void showEvent(QShowEvent *event) override; + void geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry) override; + void itemChange(ItemChange change, const ItemChangeData &value) override; private: enum class ItemType { @@ -90,6 +97,7 @@ private: const QPixmap *spriteForItem(const ScheduledItem &item); void handleControllerPropertyChanged(const QString &property, const QVariant &value); + void bindWindow(QQuickWindow *window); QPointer m_controller; QTimer *m_frameTimer = nullptr; @@ -98,6 +106,7 @@ private: QPointer m_observedScreen; QMetaObject::Connection m_windowScreenConnection; QMetaObject::Connection m_screenRefreshConnection; + QMetaObject::Connection m_frameSwappedConnection; QElapsedTimer m_positionClock; QList m_comments; QList m_sortedComments; @@ -132,4 +141,4 @@ private: mutable bool m_surfaceRectDirty = true; }; -#endif +#endif diff --git a/src/qEmbyApp/quick/dashboardviewmodel.cpp b/src/qEmbyApp/quick/dashboardviewmodel.cpp index 15de3f1..d5a1030 100644 --- a/src/qEmbyApp/quick/dashboardviewmodel.cpp +++ b/src/qEmbyApp/quick/dashboardviewmodel.cpp @@ -1,6 +1,5 @@ #include "dashboardviewmodel.h" -#include "../components/modernmessagebox.h" #include "../components/moderntoast.h" #include @@ -12,6 +11,8 @@ #include #include +#include + namespace { // 与 NowPlayingCard::loadImage 相同的候选回退顺序,编码进 image://emby/ URL @@ -290,8 +291,8 @@ void ActivityListModel::reset(const QList &entries) // ===== DashboardViewModel ===== -DashboardViewModel::DashboardViewModel(QEmbyCore *core, QWidget *dialogParent, QObject *parent) - : QObject(parent), m_core(core), m_dialogParent(dialogParent) +DashboardViewModel::DashboardViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core) { m_refreshTimer = new QTimer(this); m_refreshTimer->setInterval(2000); @@ -420,13 +421,14 @@ QCoro::Task DashboardViewModel::doRestart() { if (m_operationBusy || !m_active || !m_core || !m_core->adminService()) co_return; - const bool confirmed = ModernMessageBox::question( - m_dialogParent, tr("Restart Server"), + QPointer confirmGuard(this); + const bool confirmed = co_await requestConfirmation( + tr("Restart Server"), tr("Are you sure you want to restart the server? All active sessions " "will be terminated."), - tr("Restart"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); + tr("Restart"), tr("Cancel"), QStringLiteral("danger")); - if (!confirmed) + if (!confirmGuard || !confirmed) co_return; m_operationBusy = true; @@ -453,17 +455,34 @@ QCoro::Task DashboardViewModel::doRestart() } } +void DashboardViewModel::resolveConfirm(bool confirmed) +{ + emit confirmResolved(confirmed); +} + +QCoro::Task DashboardViewModel::requestConfirmation( + const QString &title, const QString &message, const QString &confirmText, + const QString &cancelText, const QString &role) +{ + emit confirmRequested(title, message, confirmText, cancelText, role); + // 挂起至 resolveConfirm() 触发 confirmResolved;对象析构则 optional 为空 → false。 + const std::optional result = + co_await qCoro(this, &DashboardViewModel::confirmResolved); + co_return result.value_or(false); +} + QCoro::Task DashboardViewModel::doShutdown() { if (m_operationBusy || !m_active || !m_core || !m_core->adminService()) co_return; - const bool confirmed = ModernMessageBox::question( - m_dialogParent, tr("Shutdown Server"), + QPointer confirmGuard(this); + const bool confirmed = co_await requestConfirmation( + tr("Shutdown Server"), tr("Are you sure you want to shut down the server? This will stop all " "services and disconnect all clients."), - tr("Shutdown"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); + tr("Shutdown"), tr("Cancel"), QStringLiteral("danger")); - if (!confirmed) + if (!confirmGuard || !confirmed) co_return; m_operationBusy = true; diff --git a/src/qEmbyApp/quick/dashboardviewmodel.h b/src/qEmbyApp/quick/dashboardviewmodel.h index a7e4e76..89fef19 100644 --- a/src/qEmbyApp/quick/dashboardviewmodel.h +++ b/src/qEmbyApp/quick/dashboardviewmodel.h @@ -10,7 +10,6 @@ class QEmbyCore; class QTimer; -class QWidget; // ===== 管理端 Dashboard 的 QML 数据层 ===== // 角色全部扁平化为字符串/数值/布尔,图片以 image://emby/ URL 提供, @@ -123,9 +122,7 @@ class DashboardViewModel : public QObject Q_PROPERTY(QAbstractListModel *activityModel READ activityModel CONSTANT) public: - // dialogParent: ModernMessageBox / 确认弹窗的宿主 Widget - explicit DashboardViewModel(QEmbyCore *core, QWidget *dialogParent, - QObject *parent = nullptr); + explicit DashboardViewModel(QEmbyCore *core, QObject *parent = nullptr); QString serverName() const { return m_serverName; } QString version() const { return m_version; } @@ -143,6 +140,9 @@ public: Q_INVOKABLE void restartServer(); Q_INVOKABLE void shutdownServer(); + // QML 确认弹窗回调:用户在弹窗上点确认/取消后调用,唤醒等待的协程。 + Q_INVOKABLE void resolveConfirm(bool confirmed); + public slots: // 页面可见时开启 2 秒轮询,隐藏时停止(与旧版 show/hideEvent 一致) void setActive(bool active); @@ -152,13 +152,25 @@ signals: void refreshingChanged(); void operationBusyChanged(); + // 请求 QML 展示一个确认弹窗。role: "primary" 或 "danger"。 + void confirmRequested(const QString &title, const QString &message, + const QString &confirmText, const QString &cancelText, + const QString &role); + // 内部:resolveConfirm() 转成此信号,requestConfirmation() 协程在此挂起。 + void confirmResolved(bool confirmed); + private: QCoro::Task loadData(bool isManual); QCoro::Task doRestart(); QCoro::Task doShutdown(); + // 发出 confirmRequested 并挂起,直到 resolveConfirm() 被调用。 + QCoro::Task requestConfirmation(const QString &title, + const QString &message, + const QString &confirmText, + const QString &cancelText, + const QString &role); QEmbyCore *m_core; - QPointer m_dialogParent; QTimer *m_refreshTimer; NowPlayingListModel m_nowPlayingModel; diff --git a/src/qEmbyApp/quick/independentplayerhost.cpp b/src/qEmbyApp/quick/independentplayerhost.cpp new file mode 100644 index 0000000..ae7380e --- /dev/null +++ b/src/qEmbyApp/quick/independentplayerhost.cpp @@ -0,0 +1,146 @@ +#include "independentplayerhost.h" + +#include "embyimageprovider.h" +#include "playerviewmodel.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { +const QUrl kWindowUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/IndependentPlayerWindow.qml")); +} + +IndependentPlayerHost::IndependentPlayerHost(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core) +{ +} + +IndependentPlayerHost::~IndependentPlayerHost() +{ + teardown(false); +} + +void IndependentPlayerHost::ensureWindow() +{ + if (m_engine) + return; + + m_dialogAnchor = new QWidget(nullptr); + m_dialogAnchor->setObjectName(QStringLiteral("independent-player-dialog-anchor")); + m_dialogAnchor->setAttribute(Qt::WA_DontShowOnScreen, true); + m_dialogAnchor->resize(0, 0); + + m_player = new PlayerViewModel(m_core, this); + m_player->setDialogParent(m_dialogAnchor); + + // Back from PlayerPage chrome closes this window (not app navigation). + connect(m_player, &PlayerViewModel::backRequested, this, + &IndependentPlayerHost::close); + + m_engine = new QQmlApplicationEngine(this); + QWK::registerTypes(m_engine); + + m_engine->rootContext()->setContextProperty(QStringLiteral("player"), m_player); + m_engine->addImageProvider(QStringLiteral("emby"), new EmbyImageProvider(m_core)); + m_engine->addImageProvider(QStringLiteral("adaptiveicon"), + new AdaptiveIconProvider()); + + m_engine->load(kWindowUrl); + if (m_engine->rootObjects().isEmpty()) { + spdlog::error("[IndependentPlayerHost] failed to load IndependentPlayerWindow.qml"); + teardown(false); + return; + } + + m_window = qobject_cast(m_engine->rootObjects().constFirst()); + if (!m_window) { + spdlog::error("[IndependentPlayerHost] root is not a QQuickWindow"); + teardown(false); + return; + } + + // User close (title-bar X / Alt+F4): ApplicationWindow typically only + // hides. When it becomes Hidden, tear the secondary engine so the next + // play() rebuilds a clean scene. (Avoid QQuickCloseEvent — private header.) + connect(m_window, &QWindow::visibilityChanged, this, + [this](QWindow::Visibility visibility) { + if (visibility != QWindow::Hidden || !m_engine) + return; + if (m_player && m_player->active()) + m_player->setActive(false); + QTimer::singleShot(0, this, [this]() { + if (m_engine) + teardown(true); + }); + }); +} + +void IndependentPlayerHost::play(const QString &mediaId, const QString &title, + const QString &streamUrl, + long long startPositionTicks, + const QVariant &extraData) +{ + ensureWindow(); + if (!m_player || !m_window) + return; + + // Restart session when already active (reuse window for next item). + if (m_player->active()) + m_player->setActive(false); + + m_player->setPendingRequest(mediaId, title, streamUrl, startPositionTicks, + extraData); + m_player->setActive(true); + + m_window->show(); + m_window->raise(); + m_window->requestActivate(); + if (!title.isEmpty()) + m_window->setTitle(title); +} + +void IndependentPlayerHost::close() +{ + if (m_player && m_player->active()) + m_player->setActive(false); + teardown(true); +} + +void IndependentPlayerHost::teardown(bool emitClosed) +{ + if (!m_engine && !m_player && !m_dialogAnchor) { + m_window.clear(); + return; + } + + m_window.clear(); + + if (m_engine) { + // Deleting the engine destroys the QML tree (window + PlayerPage). + delete m_engine; + m_engine = nullptr; + } + + if (m_dialogAnchor) { + delete m_dialogAnchor; + m_dialogAnchor = nullptr; + } + + if (m_player) { + // Child of this host; drop so ensureWindow can recreate with a fresh VM. + m_player->deleteLater(); + m_player = nullptr; + } + + if (emitClosed) + emit closed(); +} diff --git a/src/qEmbyApp/quick/independentplayerhost.h b/src/qEmbyApp/quick/independentplayerhost.h new file mode 100644 index 0000000..1f0757f --- /dev/null +++ b/src/qEmbyApp/quick/independentplayerhost.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +class QEmbyCore; +class QQmlApplicationEngine; +class QQuickWindow; +class QWidget; +class PlayerViewModel; + +// Owns a secondary QML ApplicationWindow for independent playback +// (config: PlayerIndependentWindow, or no embedded host fallback). +// Replaces Widgets PlayerWindow + PlayerView. +class IndependentPlayerHost final : public QObject +{ + Q_OBJECT +public: + explicit IndependentPlayerHost(QEmbyCore *core, QObject *parent = nullptr); + ~IndependentPlayerHost() override; + + void play(const QString &mediaId, const QString &title, + const QString &streamUrl, long long startPositionTicks, + const QVariant &extraData = QVariant()); + + // Deactivate session and close the window (quit path / replace play). + void close(); + + bool isOpen() const { return m_window != nullptr; } + +Q_SIGNALS: + void closed(); + +private: + void ensureWindow(); + void teardown(bool emitClosed); + + QEmbyCore *m_core = nullptr; + QQmlApplicationEngine *m_engine = nullptr; + PlayerViewModel *m_player = nullptr; + QWidget *m_dialogAnchor = nullptr; + QPointer m_window; +}; diff --git a/src/qEmbyApp/quick/loginviewmodel.cpp b/src/qEmbyApp/quick/loginviewmodel.cpp index 36b4c48..b07e32c 100644 --- a/src/qEmbyApp/quick/loginviewmodel.cpp +++ b/src/qEmbyApp/quick/loginviewmodel.cpp @@ -1,6 +1,5 @@ #include "loginviewmodel.h" -#include "../components/modernmessagebox.h" #include "../components/proxysettingsdialog.h" #include "../components/webdavsyncdialog.h" @@ -15,6 +14,8 @@ #include #include +#include + #include LoginServerListModel::LoginServerListModel(QObject *parent) @@ -185,12 +186,34 @@ void LoginViewModel::editServer(const QString &serverId) void LoginViewModel::removeServer(const QString &serverId) { - if (!m_dialogParent) return; - const bool confirmed = ModernMessageBox::question( - m_dialogParent, tr("Remove Server"), + confirmRemoveServer(serverId); +} + +QCoro::Task LoginViewModel::confirmRemoveServer(QString serverId) +{ + QPointer guard(this); + const bool confirmed = co_await requestConfirmation( + tr("Remove Server"), tr("Are you sure you want to remove this server?\nThis action cannot be undone."), - tr("Remove"), tr("Cancel"), ModernMessageBox::Danger); - if (confirmed) { m_core->serverManager()->removeServer(serverId); refreshServers(); } + tr("Remove"), tr("Cancel"), QStringLiteral("danger")); + if (!guard || !confirmed) co_return; + m_core->serverManager()->removeServer(serverId); + refreshServers(); +} + +void LoginViewModel::resolveConfirm(bool confirmed) +{ + emit confirmResolved(confirmed); +} + +QCoro::Task LoginViewModel::requestConfirmation( + const QString &title, const QString &message, const QString &confirmText, + const QString &cancelText, const QString &role) +{ + emit confirmRequested(title, message, confirmText, cancelText, role); + const std::optional result = + co_await qCoro(this, &LoginViewModel::confirmResolved); + co_return result.value_or(false); } QUrl LoginViewModel::normalizedUrl(QString *error) const diff --git a/src/qEmbyApp/quick/loginviewmodel.h b/src/qEmbyApp/quick/loginviewmodel.h index dd3a29e..0323234 100644 --- a/src/qEmbyApp/quick/loginviewmodel.h +++ b/src/qEmbyApp/quick/loginviewmodel.h @@ -81,6 +81,9 @@ public: Q_INVOKABLE void configureProxy(); Q_INVOKABLE void cloudSync(); + // QML 确认弹窗回调:用户点确认/取消后调用,唤醒等待的协程。 + Q_INVOKABLE void resolveConfirm(bool confirmed); + signals: void loginCompleted(); void editorVisibleChanged(); @@ -90,6 +93,13 @@ signals: void formChanged(); void proxySummaryChanged(); + // 请求 QML 展示一个确认弹窗。role: "primary" 或 "danger"。 + void confirmRequested(const QString &title, const QString &message, + const QString &confirmText, const QString &cancelText, + const QString &role); + // 内部:resolveConfirm() 转成此信号,协程在此挂起。 + void confirmResolved(bool confirmed); + private: void refreshServers(); void setBusy(bool value); @@ -99,6 +109,12 @@ private: QUrl normalizedUrl(QString *error) const; QCoro::Task validateServer(QString serverId); QCoro::Task performLogin(); + QCoro::Task confirmRemoveServer(QString serverId); + QCoro::Task requestConfirmation(const QString &title, + const QString &message, + const QString &confirmText, + const QString &cancelText, + const QString &role); QEmbyCore *m_core = nullptr; QPointer m_dialogParent; diff --git a/src/qEmbyApp/quick/manageroutecontroller.cpp b/src/qEmbyApp/quick/manageroutecontroller.cpp new file mode 100644 index 0000000..5be0a98 --- /dev/null +++ b/src/qEmbyApp/quick/manageroutecontroller.cpp @@ -0,0 +1,179 @@ +#include "manageroutecontroller.h" + +#include "navigationcontroller.h" + +#include "adminlibrariesviewmodel.h" +#include "dashboardviewmodel.h" +#include "logsviewmodel.h" +#include "tasksviewmodel.h" + +#include "../views/admin/collectionsviewmodel.h" +#include "../views/admin/transcodingviewmodel.h" +#include "../views/admin/usersviewmodel.h" + +#include "../components/adduserdialog.h" +#include "../components/embycodecinfodialog.h" +#include "../components/embycodecparametersdialog.h" +#include "../components/taskeditdialog.h" +#include "../components/usereditdialog.h" + +#include +#include + +#include +#include +#include + +ManageRouteController::ManageRouteController(QEmbyCore *core, + NavigationController *navigation, + QWidget *dialogAnchor, + QObject *parent) + : QObject(parent), m_core(core), m_navigation(navigation), + m_dialogAnchor(dialogAnchor) +{ + setObjectName(QStringLiteral("manage-route-controller")); + + // Ctors mirror the Page* wrappers: libraries/logs need a QWidget dialog + // parent (image editors / save-as pickers); the other five are core-only. + m_dashboardViewModel = new DashboardViewModel(core, this); + m_librariesViewModel = new AdminLibrariesViewModel(core, m_dialogAnchor, this); + m_collectionsViewModel = new CollectionsViewModel(core, this); + m_transcodingViewModel = new TranscodingViewModel(core, this); + m_usersViewModel = new UsersViewModel(core, this); + m_tasksViewModel = new TasksViewModel(core, this); + m_logsViewModel = new LogsViewModel(core, m_dialogAnchor, this); + + wireDialogs(); + + // Release the management WebSocket while the event loop is still alive. + // ManagePage.qml's Component.onDestruction also calls setActiveSection(""), + // but that fires during QQmlApplicationEngine teardown — after a.exec() has + // returned — where QWebSocket::close() crashes. Deactivating here on + // aboutToQuit makes the later onDestruction call a no-op (section already + // empty). Mirrors ManageView::hideEvent's disconnect in the Widgets shell. + connect(qApp, &QCoreApplication::aboutToQuit, this, + [this] { setActiveSection(QString()); }); +} + +void ManageRouteController::wireDialogs() +{ + // Users: add / edit open Widget dialogs anchored to the hidden anchor and + // reload the list on completion (PageUsers::openAddUserDialog/openUserEditor). + connect(m_usersViewModel, &UsersViewModel::addUserRequested, this, + &ManageRouteController::openAddUserDialog); + connect(m_usersViewModel, &UsersViewModel::editUserRequested, this, + &ManageRouteController::openUserEditor); + + // Tasks: edit-trigger dialog (PageTasks lambda). + connect(m_tasksViewModel, &TasksViewModel::editRequested, this, + &ManageRouteController::openTaskEditor); + + // Transcoding: path picker + codec info/parameters dialogs (PageTranscoding). + connect(m_transcodingViewModel, &TranscodingViewModel::pathPickerRequested, + this, &ManageRouteController::chooseTranscodingPath); + connect(m_transcodingViewModel, &TranscodingViewModel::codecInfoRequested, + this, &ManageRouteController::showCodecInfo); + connect(m_transcodingViewModel, + &TranscodingViewModel::codecParametersRequested, this, + &ManageRouteController::showCodecParameters); + + // Dashboard's restart/shutdown confirm is self-contained in QML + // (ManageDashboardPage.qml's ConfirmDialog binds to `dashboard`), so it + // needs no host-side wiring here. +} + +void ManageRouteController::setActiveSection(const QString §ion) +{ + if (section == m_activeSection) + return; + m_activeSection = section; + + m_dashboardViewModel->setActive(section == QLatin1String("dashboard")); + m_librariesViewModel->setActive(section == QLatin1String("libraries")); + m_collectionsViewModel->setActive(section == QLatin1String("collections")); + m_transcodingViewModel->setActive(section == QLatin1String("transcoding")); + m_usersViewModel->setActive(section == QLatin1String("users")); + m_tasksViewModel->setActive(section == QLatin1String("tasks")); + m_logsViewModel->setActive(section == QLatin1String("logs")); + + updateWebSocket(); +} + +void ManageRouteController::updateWebSocket() +{ + // ManageView connected the WebSocket on entering the management view and + // disconnected on leaving. Here "in manage" == some section is active. + const bool wantConnected = !m_activeSection.isEmpty(); + if (wantConnected == m_webSocketConnected) + return; + m_webSocketConnected = wantConnected; + if (!m_core || !m_core->serverManager()) + return; + if (wantConnected) + m_core->serverManager()->connectWebSocket(); + else + m_core->serverManager()->disconnectWebSocket(); +} + +void ManageRouteController::openAddUserDialog() +{ + auto *dialog = new AddUserDialog(m_core, m_dialogAnchor); + connect(dialog, &AddUserDialog::userCreated, m_usersViewModel, + &UsersViewModel::reloadAfterDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); +} + +void ManageRouteController::openUserEditor(const QString &userId) +{ + auto *dialog = new UserEditDialog(m_core, userId, m_dialogAnchor); + connect(dialog, &UserEditDialog::userUpdated, m_usersViewModel, + &UsersViewModel::reloadAfterDialog); + connect(dialog, &UserEditDialog::userDeleted, m_usersViewModel, + &UsersViewModel::reloadAfterDialog); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); +} + +void ManageRouteController::openTaskEditor(const QString &taskId, + const QString &taskName) +{ + auto *dialog = new TaskEditDialog(m_core, taskId, taskName, m_dialogAnchor); + connect(dialog, &TaskEditDialog::triggersUpdated, m_tasksViewModel, + &TasksViewModel::refresh); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); +} + +void ManageRouteController::chooseTranscodingPath(const QString &key, + bool directory) +{ + const QString initial = m_transcodingViewModel->value(key).toString(); + const QString selected = + directory ? QFileDialog::getExistingDirectory(m_dialogAnchor, + tr("Select Folder"), initial) + : QFileDialog::getOpenFileName(m_dialogAnchor, + tr("Select File"), initial); + if (!selected.isEmpty()) + m_transcodingViewModel->setValue(key, selected); +} + +void ManageRouteController::showCodecInfo(const QString &codecId) +{ + const QJsonObject info = m_transcodingViewModel->codecInfo(codecId); + if (info.isEmpty()) + return; + auto *dialog = new EmbyCodecInfoDialog(info, m_dialogAnchor); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); +} + +void ManageRouteController::showCodecParameters(const QString &codecId) +{ + const QJsonObject info = m_transcodingViewModel->codecInfo(codecId); + if (info.isEmpty()) + return; + auto *dialog = new EmbyCodecParametersDialog(m_core, info, m_dialogAnchor); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->open(); +} diff --git a/src/qEmbyApp/quick/manageroutecontroller.h b/src/qEmbyApp/quick/manageroutecontroller.h new file mode 100644 index 0000000..4fd6390 --- /dev/null +++ b/src/qEmbyApp/quick/manageroutecontroller.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +class QEmbyCore; +class QWidget; +class NavigationController; + +class DashboardViewModel; +class AdminLibrariesViewModel; +class CollectionsViewModel; +class TranscodingViewModel; +class UsersViewModel; +class TasksViewModel; +class LogsViewModel; + +// The shared-engine analog of ManageView + its seven Page* wrappers. +// +// In the Widgets shell ManageView held a QListWidget nav + SlidingStackedWidget, +// and each admin sub-page lived in its own Page* wrapper (QuickPageHost + +// ViewModel + dialog wiring). In the full QML shell ManagePage.qml owns the nav +// and the lazy StackLayout, while this controller owns the seven session-scoped +// ViewModels plus the Widget dialogs three of them still need +// (AddUserDialog/UserEditDialog/TaskEditDialog/QFileDialog/EmbyCodec*Dialog). +// +// It is parented to AppShellHost's hidden dialog anchor so those dialogs have a +// valid top-level QWidget to anchor to, while never being shown itself. +// +// Section activation cannot be QML-driven because AdminLibrariesViewModel's +// setActive is a plain public method (not a slot / Q_INVOKABLE). So ManagePage +// calls the single Q_INVOKABLE setActiveSection() on nav/active changes and this +// controller fans it out to the seven VMs. WebSocket connect/disconnect follows +// the manage route enter/leave, mirroring ManageView::showEvent/hideEvent. +class ManageRouteController final : public QObject +{ + Q_OBJECT + +public: + ManageRouteController(QEmbyCore *core, NavigationController *navigation, + QWidget *dialogAnchor, QObject *parent = nullptr); + + // Exposed so AppShellHost can register them as engine context properties + // under the names ManagePage.qml's sub-pages bind to. + DashboardViewModel *dashboardViewModel() const { return m_dashboardViewModel; } + AdminLibrariesViewModel *librariesViewModel() const { return m_librariesViewModel; } + CollectionsViewModel *collectionsViewModel() const { return m_collectionsViewModel; } + TranscodingViewModel *transcodingViewModel() const { return m_transcodingViewModel; } + UsersViewModel *usersViewModel() const { return m_usersViewModel; } + TasksViewModel *tasksViewModel() const { return m_tasksViewModel; } + LogsViewModel *logsViewModel() const { return m_logsViewModel; } + + // Called by ManagePage.qml whenever its visible section (or active state) + // changes. An empty section deactivates every sub-page. Idempotent. + Q_INVOKABLE void setActiveSection(const QString §ion); + +private: + void wireDialogs(); + void updateWebSocket(); + + void openAddUserDialog(); + void openUserEditor(const QString &userId); + void openTaskEditor(const QString &taskId, const QString &taskName); + void chooseTranscodingPath(const QString &key, bool directory); + void showCodecInfo(const QString &codecId); + void showCodecParameters(const QString &codecId); + + QEmbyCore *m_core = nullptr; + NavigationController *m_navigation = nullptr; + QWidget *m_dialogAnchor = nullptr; + + DashboardViewModel *m_dashboardViewModel = nullptr; + AdminLibrariesViewModel *m_librariesViewModel = nullptr; + CollectionsViewModel *m_collectionsViewModel = nullptr; + TranscodingViewModel *m_transcodingViewModel = nullptr; + UsersViewModel *m_usersViewModel = nullptr; + TasksViewModel *m_tasksViewModel = nullptr; + LogsViewModel *m_logsViewModel = nullptr; + + QString m_activeSection; + bool m_webSocketConnected = false; +}; diff --git a/src/qEmbyApp/quick/mediaroutecontroller.cpp b/src/qEmbyApp/quick/mediaroutecontroller.cpp new file mode 100644 index 0000000..e49d79b --- /dev/null +++ b/src/qEmbyApp/quick/mediaroutecontroller.cpp @@ -0,0 +1,531 @@ +#include "mediaroutecontroller.h" + +#include "navigationcontroller.h" + +#include "../views/media/detailviewmodel.h" +#include "../views/media/libraryviewmodel.h" +#include "../views/media/seasonviewmodel.h" +#include "../views/search/searchviewmodel.h" +#include "../views/user/categoryviewmodel.h" +#include "../views/user/dashboardviewmodel.h" +#include "../views/user/favoritesviewmodel.h" + +#include + +#include + +MediaRouteController::MediaRouteController(QEmbyCore *core, + NavigationController *navigation, + QWidget *parent) + : BaseView(core, parent), m_navigation(navigation) +{ + setObjectName(QStringLiteral("media-route-controller")); + + m_dashboardViewModel = new UserDashboardViewModel(core, this); + m_favoritesViewModel = new FavoritesViewModel(core, this); + m_searchViewModel = new SearchViewModel(core, this); + m_categoryViewModel = new CategoryViewModel(core, this); + m_libraryViewModel = new LibraryViewModel(core, this); + m_detailViewModel = new DetailViewModel(core, this); + m_seasonViewModel = new SeasonViewModel(core, this); + + wireViewModels(); + wireDetailSeason(); + + // In the Widgets shell the parent HomeView caught these BaseView signals and + // translated them into pushView()/route pushes. Here the controller owns the + // full translation to NavigationController routes. + connect(this, &BaseView::navigateToDetail, this, + &MediaRouteController::pushDetailRoute); + connect(this, &BaseView::navigateToFolder, this, + &MediaRouteController::pushLibraryRoute); + connect(this, &BaseView::navigateToPerson, this, + &MediaRouteController::pushPersonRoute); + connect(this, &BaseView::navigateToCategory, this, + &MediaRouteController::pushCategoryRoute); + connect(this, &BaseView::navigateToFilteredView, this, + &MediaRouteController::pushFilteredRoute); + connect(this, &BaseView::triggerSearch, this, + &MediaRouteController::pushSearchRoute); + connect(this, &BaseView::navigateToSeason, this, + &MediaRouteController::pushSeasonRoute); + + if (m_navigation) + { + connect(m_navigation, &NavigationController::currentRouteChanged, this, + &MediaRouteController::updateRouteActivity); + connect(m_navigation, &NavigationController::sessionRoutesCleared, this, + &MediaRouteController::updateRouteActivity); + connect(m_navigation, &NavigationController::refreshRequested, this, + [this](const QString &key) + { + if (key == QLatin1String("home")) + refreshHome(); + else if (key == QLatin1String("favorites")) + refreshFavorites(); + }); + } + + // Reflect whatever route the session already sits on (home, after + // enterAuthenticatedSession()). + updateRouteActivity(); +} + +void MediaRouteController::wireViewModels() +{ + connect(m_dashboardViewModel, &UserDashboardViewModel::itemActivated, this, + &MediaRouteController::forwardItemActivation); + connect(m_dashboardViewModel, &UserDashboardViewModel::libraryActivated, + this, &MediaRouteController::pushLibraryRoute); + connect(m_dashboardViewModel, &UserDashboardViewModel::categoryRequested, + this, &MediaRouteController::pushCategoryRoute); + connect(m_dashboardViewModel, &UserDashboardViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_dashboardViewModel, &UserDashboardViewModel::favoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_dashboardViewModel, &UserDashboardViewModel::moreRequested, this, + [this](const MediaItem &item, const QPoint &) + { + // No embedded host to map from; anchor at the cursor like the + // Widgets favorites/category paths do. + handleMoreMenuRequested(item, QCursor::pos()); + }); + + connect(m_favoritesViewModel, &FavoritesViewModel::itemActivated, this, + &MediaRouteController::forwardItemActivation); + connect(m_favoritesViewModel, &FavoritesViewModel::categoryRequested, this, + [this](const QString &itemType, const QString &title) + { + pushCategoryRoute(QStringLiteral("Favorite_") + itemType, title); + }); + connect(m_favoritesViewModel, &FavoritesViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_favoritesViewModel, &FavoritesViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_favoritesViewModel, &FavoritesViewModel::moreRequested, this, + [this](const MediaItem &item) + { handleMoreMenuRequested(item, QCursor::pos()); }); + + connect(m_searchViewModel, &SearchViewModel::itemActivated, this, + &MediaRouteController::forwardItemActivation); + connect(m_searchViewModel, &SearchViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_searchViewModel, &SearchViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_searchViewModel, &SearchViewModel::moreRequested, this, + [this](const MediaItem &item, const QPoint &) + { handleMoreMenuRequested(item, QCursor::pos()); }); + + connect(m_categoryViewModel, &CategoryViewModel::itemActivated, this, + &MediaRouteController::forwardItemActivation); + connect(m_categoryViewModel, &CategoryViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_categoryViewModel, &CategoryViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_categoryViewModel, &CategoryViewModel::moreRequested, this, + [this](const MediaItem &item) + { handleMoreMenuRequested(item, QCursor::pos()); }); + + connect(m_libraryViewModel, &LibraryViewModel::itemActivated, this, + &MediaRouteController::forwardItemActivation); + connect(m_libraryViewModel, &LibraryViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_libraryViewModel, &LibraryViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_libraryViewModel, &LibraryViewModel::currentFavoriteRequested, + this, &BaseView::handleFavoriteRequested); + connect(m_libraryViewModel, &LibraryViewModel::moreRequested, this, + [this](const MediaItem &item) + { handleMoreMenuRequested(item, QCursor::pos()); }); +} + +void MediaRouteController::wireDetailSeason() +{ + // Detail: itemActivated has richer routing than the grid pages (Season -> + // season route with a series-id fallback), so it gets its own translator. + connect(m_detailViewModel, &DetailViewModel::itemActivated, this, + &MediaRouteController::forwardDetailActivation); + connect(m_detailViewModel, &DetailViewModel::genreActivated, this, + [this](const QString &genre) + { pushFilteredRoute(QStringLiteral("Genre"), genre); }); + connect(m_detailViewModel, &DetailViewModel::playRequested, this, + [this](const MediaItem &item, int, bool) + { handlePlayRequested(item); }); + connect(m_detailViewModel, &DetailViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_detailViewModel, &DetailViewModel::playedRequested, this, + [this](const MediaItem &item, bool played) + { + if (played) + handleMarkPlayedRequested(item); + else + handleMarkUnplayedRequested(item); + }); + connect(m_detailViewModel, &DetailViewModel::moreRequested, this, + [this](const MediaItem &item) + { handleMoreMenuRequested(item, QCursor::pos()); }); + + connect(m_seasonViewModel, &SeasonViewModel::episodeActivated, this, + &MediaRouteController::forwardDetailActivation); + connect(m_seasonViewModel, &SeasonViewModel::playRequested, this, + &BaseView::handlePlayRequested); + connect(m_seasonViewModel, &SeasonViewModel::favoriteRequested, this, + &BaseView::handleFavoriteRequested); + connect(m_seasonViewModel, &SeasonViewModel::moreRequested, this, + [this](const MediaItem &item) + { handleMoreMenuRequested(item, QCursor::pos()); }); +} + +void MediaRouteController::refreshHome() { m_dashboardViewModel->refresh(); } + +void MediaRouteController::refreshFavorites() +{ + m_favoritesViewModel->refresh(); +} + +void MediaRouteController::onMediaItemUpdated(const MediaItem &item) +{ + m_dashboardViewModel->updateItem(item); + m_favoritesViewModel->updateItem(item); + m_searchViewModel->updateItem(item); + m_categoryViewModel->updateItem(item); + m_libraryViewModel->updateItem(item); + m_detailViewModel->updateItem(item); + m_seasonViewModel->updateItem(item); +} + +void MediaRouteController::onMediaItemRemoved(const QString &itemId) +{ + m_dashboardViewModel->removeItem(itemId); + m_favoritesViewModel->removeItem(itemId); + m_categoryViewModel->removeItem(itemId); + m_libraryViewModel->removeItem(itemId); + m_seasonViewModel->removeItem(itemId); +} + +void MediaRouteController::forwardDetailActivation(const MediaItem &item) +{ + if (item.type == QLatin1String("Season")) + { + // DetailViewModel::item() is the currently loaded series; use it as the + // series-id fallback exactly like DetailView did. + const QString seriesId = item.seriesId.isEmpty() + ? m_detailViewModel->item().id + : item.seriesId; + pushSeasonRoute(seriesId, item.id, item.name); + } + else if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) + { + pushLibraryRoute(item.id, item.name); + } + else if (item.type == QLatin1String("Person")) + { + pushPersonRoute(item.id, item.name); + } + else + { + pushDetailRoute(item.id, item.name, item); + } +} + +void MediaRouteController::updateRouteActivity() +{ + const QVariantMap route = + m_navigation ? m_navigation->currentRoute() : QVariantMap{}; + const QString routeName = route.value(QStringLiteral("name")).toString(); + + m_dashboardViewModel->setActive(routeName == QLatin1String("home")); + m_favoritesViewModel->setActive(routeName == QLatin1String("favorites")); + + updateSearchRoute(route); + updateCategoryRoute(route); + updateLibraryRoute(route); + updateDetailRoute(route); + updateSeasonRoute(route); +} + +void MediaRouteController::updateSearchRoute(const QVariantMap &route) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + if (routeName != QLatin1String("search")) + { + m_searchViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString query = + params.value(QStringLiteral("query")).toString().trimmed(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const bool routeChanged = + routeKey != m_searchRouteKey || query != m_searchQuery; + if (!query.isEmpty() && routeChanged) + { + m_searchRouteKey = routeKey; + m_searchQuery = query; + QCoro::connect(m_searchViewModel->search(query), this, []() {}); + } + m_searchViewModel->setActive(true); +} + +void MediaRouteController::updateCategoryRoute(const QVariantMap &route) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + if (routeName != QLatin1String("category")) + { + m_categoryViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + QString categoryId = + params.value(QStringLiteral("categoryId")).toString().trimmed(); + if (params.value(QStringLiteral("favorite")).toBool()) + categoryId.prepend(QStringLiteral("Favorite_")); + const QString title = params.value(QStringLiteral("title")).toString(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const QString routeIdentity = routeKey + QLatin1Char('\n') + title; + if (!categoryId.isEmpty() && routeIdentity != m_categoryRouteKey) + { + m_categoryViewModel->setActive(false); + m_categoryRouteKey = routeIdentity; + QCoro::connect(m_categoryViewModel->loadCategory(categoryId, title), + this, []() {}); + } + m_categoryViewModel->setActive(true); +} + +void MediaRouteController::updateLibraryRoute(const QVariantMap &route) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + const bool libraryActive = routeName == QLatin1String("library") || + routeName == QLatin1String("person") || + routeName == QLatin1String("filtered"); + if (!libraryActive) + { + m_libraryViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + const QString title = params.value(QStringLiteral("title")).toString(); + const QString routeIdentity = routeKey + QLatin1Char('\n') + title; + if (routeIdentity != m_libraryRouteKey) + { + m_libraryViewModel->setActive(false); + m_libraryRouteKey = routeIdentity; + if (routeName == QLatin1String("library")) + { + QCoro::connect( + m_libraryViewModel->loadLibrary( + params.value(QStringLiteral("libraryId")).toString(), title), + this, []() {}); + } + else if (routeName == QLatin1String("person")) + { + QCoro::connect( + m_libraryViewModel->loadPerson( + params.value(QStringLiteral("personId")).toString(), title), + this, []() {}); + } + else + { + QCoro::connect( + m_libraryViewModel->loadFiltered( + params.value(QStringLiteral("filterType")).toString(), + params.value(QStringLiteral("filterValue")).toString()), + this, []() {}); + } + } + m_libraryViewModel->setActive(true); +} + +void MediaRouteController::updateDetailRoute(const QVariantMap &route) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + if (routeName != QLatin1String("detail")) + { + m_detailViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString itemId = + params.value(QStringLiteral("itemId")).toString().trimmed(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + if (!itemId.isEmpty() && routeKey != m_detailRouteKey) + { + m_detailViewModel->setActive(false); + m_detailRouteKey = routeKey; + // Pull (and consume) the copy-unsafe MediaItem seed stashed at push time + // so the detail page can paint instantly while the full item loads. A + // seed is optional — deep-linked/back-nav arrivals have none. + const MediaItem seed = m_detailSeeds.take(itemId); + QCoro::connect(m_detailViewModel->loadItem(itemId, seed), this, + []() {}); + } + m_detailViewModel->setActive(true); +} + +void MediaRouteController::updateSeasonRoute(const QVariantMap &route) +{ + const QString routeName = route.value(QStringLiteral("name")).toString(); + if (routeName != QLatin1String("season")) + { + m_seasonViewModel->setActive(false); + return; + } + + const QVariantMap params = route.value(QStringLiteral("params")).toMap(); + const QString seriesId = + params.value(QStringLiteral("seriesId")).toString().trimmed(); + const QString seasonId = + params.value(QStringLiteral("seasonId")).toString().trimmed(); + const QString seasonName = + params.value(QStringLiteral("seasonName")).toString(); + const QString routeKey = route.value(QStringLiteral("key")).toString(); + if (!seasonId.isEmpty() && routeKey != m_seasonRouteKey) + { + m_seasonViewModel->setActive(false); + m_seasonRouteKey = routeKey; + QCoro::connect( + m_seasonViewModel->loadSeason(seriesId, seasonId, seasonName), this, + []() {}); + } + m_seasonViewModel->setActive(true); +} + +void MediaRouteController::deactivateRoutes() +{ + m_dashboardViewModel->setActive(false); + m_favoritesViewModel->setActive(false); + m_searchViewModel->setActive(false); + m_categoryViewModel->setActive(false); + m_libraryViewModel->setActive(false); + m_detailViewModel->setActive(false); + m_seasonViewModel->setActive(false); +} + +void MediaRouteController::forwardItemActivation(const MediaItem &item) +{ + if (item.type == QLatin1String("BoxSet") || + item.type == QLatin1String("Playlist") || + item.type == QLatin1String("Folder")) + { + emit navigateToFolder(item.id, item.name); + } + else if (item.type == QLatin1String("Person")) + { + emit navigateToPerson(item.id, item.name); + } + else + { + emit navigateToDetail(item.id, item.name, item); + } +} + +void MediaRouteController::pushDetailRoute(const QString &itemId, + const QString &title, + const MediaItem &seed) +{ + if (!m_navigation || itemId.trimmed().isEmpty()) + return; + // Stash the copy-unsafe seed keyed by itemId before pushing. The detail + // route key is deterministic (`detail:`), so itemId is a + // stable rendezvous point between push time and updateDetailRoute. + if (!seed.id.isEmpty()) + m_detailSeeds.insert(itemId, seed); + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("detail")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("itemId"), itemId}, + {QStringLiteral("title"), title}}}}); +} + +void MediaRouteController::pushLibraryRoute(const QString &libraryId, + const QString &title) +{ + if (!m_navigation) + return; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("library")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("libraryId"), libraryId}, + {QStringLiteral("title"), title}}}}); +} + +void MediaRouteController::pushPersonRoute(const QString &personId, + const QString &title) +{ + if (!m_navigation) + return; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("person")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("personId"), personId}, + {QStringLiteral("title"), title}}}}); +} + +void MediaRouteController::pushCategoryRoute(const QString &categoryId, + const QString &title) +{ + if (!m_navigation) + return; + const bool favorite = categoryId.startsWith(QLatin1String("Favorite_")); + const QString normalizedId = favorite ? categoryId.mid(9) : categoryId; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("category")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("categoryId"), normalizedId}, + {QStringLiteral("title"), title}, + {QStringLiteral("favorite"), favorite}}}}); +} + +void MediaRouteController::pushFilteredRoute(const QString &filterType, + const QString &filterValue) +{ + if (!m_navigation) + return; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("filtered")}, + {QStringLiteral("title"), filterValue}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("filterType"), filterType}, + {QStringLiteral("filterValue"), filterValue}, + {QStringLiteral("title"), filterValue}}}}); +} + +void MediaRouteController::pushSearchRoute(const QString &query) +{ + if (!m_navigation) + return; + const QString trimmed = query.trimmed(); + if (trimmed.isEmpty()) + return; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("search")}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("query"), trimmed}}}}); +} + +void MediaRouteController::pushSeasonRoute(const QString &seriesId, + const QString &seasonId, + const QString &seasonName) +{ + if (!m_navigation || seasonId.trimmed().isEmpty()) + return; + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("season")}, + {QStringLiteral("title"), seasonName}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("seriesId"), seriesId}, + {QStringLiteral("seasonId"), seasonId}, + {QStringLiteral("seasonName"), seasonName}}}}); +} diff --git a/src/qEmbyApp/quick/mediaroutecontroller.h b/src/qEmbyApp/quick/mediaroutecontroller.h new file mode 100644 index 0000000..7435dbc --- /dev/null +++ b/src/qEmbyApp/quick/mediaroutecontroller.h @@ -0,0 +1,102 @@ +#pragma once + +#include "../views/baseview.h" + +#include +#include + +class CategoryViewModel; +class DetailViewModel; +class FavoritesViewModel; +class LibraryViewModel; +class NavigationController; +class SearchViewModel; +class SeasonViewModel; +class UserDashboardViewModel; + +// The shared-engine analog of HomeQuickRouterView. +// +// In the Widgets shell, HomeQuickRouterView embedded a QuickRouterHost widget +// and derived from BaseView to inherit the play/favorite/context-menu action +// machinery. In the full QML shell there is no embedded host: Router.qml (in +// the single application engine) owns the visual lifetime of every media page, +// and this controller supplies the five session-scoped ViewModels plus the +// BaseView action contracts they connect to. +// +// It is parented to AppShellHost's hidden dialog anchor so that BaseView's +// context menus / dialogs still have a valid top-level QWidget to anchor to, +// while never being shown itself. Route activation is driven directly by the +// NavigationController's currentRouteChanged signal (there is no QQuickWidget +// activated/deactivated pair anymore). +class MediaRouteController final : public BaseView +{ + Q_OBJECT + +public: + MediaRouteController(QEmbyCore *core, NavigationController *navigation, + QWidget *parent = nullptr); + + // Exposed so AppShellHost can register them as engine context properties + // under the same names Router.qml's pages expect. + UserDashboardViewModel *dashboardViewModel() const { return m_dashboardViewModel; } + FavoritesViewModel *favoritesViewModel() const { return m_favoritesViewModel; } + SearchViewModel *searchViewModel() const { return m_searchViewModel; } + CategoryViewModel *categoryViewModel() const { return m_categoryViewModel; } + LibraryViewModel *libraryViewModel() const { return m_libraryViewModel; } + DetailViewModel *detailViewModel() const { return m_detailViewModel; } + SeasonViewModel *seasonViewModel() const { return m_seasonViewModel; } + +public Q_SLOTS: + void refreshHome(); + void refreshFavorites(); + +protected: + void onMediaItemUpdated(const MediaItem &item) override; + void onMediaItemRemoved(const QString &itemId) override; + +private: + void wireViewModels(); + void wireDetailSeason(); + void updateRouteActivity(); + void updateSearchRoute(const QVariantMap &route); + void updateCategoryRoute(const QVariantMap &route); + void updateLibraryRoute(const QVariantMap &route); + void updateDetailRoute(const QVariantMap &route); + void updateSeasonRoute(const QVariantMap &route); + void deactivateRoutes(); + void forwardItemActivation(const MediaItem &item); + void forwardDetailActivation(const MediaItem &item); + + // Translates BaseView navigateTo* signals into NavigationController pushes, + // mirroring HomeView::open*Route / triggerSearch in the Widgets shell. + void pushDetailRoute(const QString &itemId, const QString &title, + const MediaItem &seed); + void pushLibraryRoute(const QString &libraryId, const QString &title); + void pushPersonRoute(const QString &personId, const QString &title); + void pushCategoryRoute(const QString &categoryId, const QString &title); + void pushFilteredRoute(const QString &filterType, const QString &filterValue); + void pushSearchRoute(const QString &query); + void pushSeasonRoute(const QString &seriesId, const QString &seasonId, + const QString &seasonName); + + NavigationController *m_navigation = nullptr; + UserDashboardViewModel *m_dashboardViewModel = nullptr; + FavoritesViewModel *m_favoritesViewModel = nullptr; + SearchViewModel *m_searchViewModel = nullptr; + CategoryViewModel *m_categoryViewModel = nullptr; + LibraryViewModel *m_libraryViewModel = nullptr; + DetailViewModel *m_detailViewModel = nullptr; + SeasonViewModel *m_seasonViewModel = nullptr; + + QString m_searchRouteKey; + QString m_searchQuery; + QString m_categoryRouteKey; + QString m_libraryRouteKey; + QString m_detailRouteKey; + QString m_seasonRouteKey; + + // MediaItem is not copy-safe through NavigationController params (they must + // survive QVariantMap round-trips), so detail seeds are stashed here keyed + // by the route's stable key and consumed when the route activates. + QHash m_detailSeeds; +}; diff --git a/src/qEmbyApp/quick/mpvitem.cpp b/src/qEmbyApp/quick/mpvitem.cpp new file mode 100644 index 0000000..d2a56fc --- /dev/null +++ b/src/qEmbyApp/quick/mpvitem.cpp @@ -0,0 +1,484 @@ +#include "mpvitem.h" + +#include "../components/mpvcontroller.h" +#include "../components/mpvhttpstreamrelay.h" +#include "../utils/logredactionutils.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + +void *mpvGetProcAddress(void *ctx, const char *name) +{ + Q_UNUSED(ctx); + QOpenGLContext *glctx = QOpenGLContext::currentContext(); + return glctx ? reinterpret_cast( + glctx->getProcAddress(QByteArray(name))) + : nullptr; +} + +// 渲染线程侧。持有 mpv_render_context,只在 render() 里碰 GL。 +// 与 MpvItem 之间不共享可变状态 —— mpv 句柄在 synchronize() 时取一次。 +class MpvRenderer : public QQuickFramebufferObject::Renderer +{ +public: + explicit MpvRenderer(std::shared_ptr bridge) + : m_bridge(std::move(bridge)) + { + } + + ~MpvRenderer() override + { + if (m_mpvGl) + { + mpv_render_context_set_update_callback(m_mpvGl, nullptr, nullptr); + mpv_render_context_free(m_mpvGl); + m_mpvGl = nullptr; + } + } + + void synchronize(QQuickFramebufferObject *item) override + { + auto *mpvItem = static_cast(item); + MpvController *controller = mpvItem->controller(); + m_mpv = controller ? controller->mpv() : nullptr; + } + + void render() override + { + if (!m_mpvGl && !initRenderContext()) + return; + + QOpenGLFramebufferObject *fbo = framebufferObject(); + if (!fbo) + return; + + // fbo->size() 已含 devicePixelRatio,不要再乘。 + const QSize size = fbo->size(); + if (size.isEmpty()) + return; + + mpv_opengl_fbo mpfbo{static_cast(fbo->handle()), size.width(), + size.height(), 0}; + // Two independent Y-axis conventions meet here: + // 1) mpv → OpenGL FBO (bottom-left origin) → FLIP_Y = 1 + // same as MpvWidget::paintGL + // 2) OpenGL FBO → Qt Quick scene graph (top-left origin) + // → QQuickFramebufferObject::mirrorVertically = true + // Using only one of the two leaves the picture upside-down. + int flipY = 1; + + mpv_render_param params[] = { + {MPV_RENDER_PARAM_OPENGL_FBO, &mpfbo}, + {MPV_RENDER_PARAM_FLIP_Y, &flipY}, + {MPV_RENDER_PARAM_INVALID, nullptr}}; + + // mpv 会污染 GL 状态,但渲染目标是本 item 独占的 FBO, + // Qt 6 走 RHI 会自行重建场景图状态(Qt 5 的 resetOpenGLState 已移除)。 + mpv_render_context_render(m_mpvGl, params); + } + + QOpenGLFramebufferObject * + createFramebufferObject(const QSize &size) override + { + QOpenGLFramebufferObjectFormat format; + format.setAttachment(QOpenGLFramebufferObject::CombinedDepthStencil); + return new QOpenGLFramebufferObject(size, format); + } + +private: + bool initRenderContext() + { + if (!m_mpv) + return false; + + QOpenGLContext *ctx = QOpenGLContext::currentContext(); + if (!ctx) + { + qCritical() << "[MpvItem] no current OpenGL context on render thread"; + return false; + } + + mpv_opengl_init_params glInitParams{mpvGetProcAddress, nullptr}; + mpv_render_param params[]{ + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInitParams}, + {MPV_RENDER_PARAM_INVALID, nullptr}}; + + const int rc = mpv_render_context_create(&m_mpvGl, m_mpv, params); + if (rc < 0) + { + qCritical() << "[MpvItem] mpv_render_context_create failed" + << mpv_error_string(rc); + m_mpvGl = nullptr; + return false; + } + + // 回调可能来自 mpv 自己的线程,bridge 负责转回 GUI 线程。 + mpv_render_context_set_update_callback( + m_mpvGl, + [](void *ctx) + { + static_cast(ctx)->requestUpdate(); + }, + m_bridge.get()); + + return true; + } + + std::shared_ptr m_bridge; + mpv_handle *m_mpv = nullptr; + mpv_render_context *m_mpvGl = nullptr; +}; + +} // namespace + +void MpvUpdateBridge::attach(QQuickItem *item) +{ + QMutexLocker locker(&m_mutex); + m_item = item; +} + +void MpvUpdateBridge::detach() +{ + QMutexLocker locker(&m_mutex); + m_item = nullptr; +} + +void MpvUpdateBridge::requestUpdate() +{ + QMutexLocker locker(&m_mutex); + if (m_item) + QMetaObject::invokeMethod(m_item, "update", Qt::QueuedConnection); +} + +MpvItem::MpvItem(QQuickItem *parent) + : QQuickFramebufferObject(parent), + m_bridge(std::make_shared()) +{ + m_bridge->attach(this); + + // Pair with MPV_RENDER_PARAM_FLIP_Y=1 in the renderer. Qt Quick samples + // the FBO with a top-left origin, so the GL texture must be mirrored. + setMirrorVertically(true); + + m_controller = new MpvController(this); + m_streamRelay = new MpvHttpStreamRelay(this); + + connect(m_streamRelay, &MpvHttpStreamRelay::upstreamSpeedChanged, this, + [this](qint64 bytesPerSecond) + { + if (m_networkSpeed == bytesPerSecond) + return; + m_networkSpeed = bytesPerSecond; + Q_EMIT networkSpeedChanged(bytesPerSecond); + }); + + connect(m_controller, &MpvController::positionChanged, this, + [this](double position) + { + m_position = position; + Q_EMIT positionChanged(position); + }); + connect(m_controller, &MpvController::durationChanged, this, + [this](double duration) + { + m_duration = duration; + Q_EMIT durationChanged(duration); + }); + connect(m_controller, &MpvController::playbackStateChanged, this, + [this](bool isPaused) + { + m_paused = isPaused; + Q_EMIT playbackStateChanged(isPaused); + }); + connect(m_controller, &MpvController::propertyChanged, this, + &MpvItem::onControllerPropertyChanged); + connect(m_controller, &MpvController::errorOccurred, this, + &MpvItem::errorOccurred); + connect(m_controller, &MpvController::endOfFile, this, + &MpvItem::endOfFile); + + m_controller->init(); + + // Seed control properties from mpv defaults so the HUD shows real values + // before the first property-change event arrives. + const QVariant vol = m_controller->getProperty(QStringLiteral("volume")); + if (vol.isValid()) + m_volume = vol.toDouble(); + const QVariant muted = m_controller->getProperty(QStringLiteral("mute")); + if (muted.isValid()) + m_muted = muted.toBool(); + const QVariant speed = m_controller->getProperty(QStringLiteral("speed")); + if (speed.isValid() && speed.toDouble() > 0.0) + m_speed = speed.toDouble(); +} + +MpvItem::~MpvItem() +{ + // renderer 可能仍在渲染线程上跑,先断开回调再释放。 + m_bridge->detach(); + shutdown(); +} + +QQuickFramebufferObject::Renderer *MpvItem::createRenderer() const +{ + return new MpvRenderer(m_bridge); +} + +void MpvItem::shutdown() +{ + if (m_shutdown || !m_controller) + return; + m_shutdown = true; + + m_controller->blockSignals(true); + + m_controller->command(QVariantList{QStringLiteral("stop")}); + if (m_streamRelay) + m_streamRelay->stop(); + if (m_usingStreamRelay) + { + m_usingStreamRelay = false; + Q_EMIT relayActiveChanged(false); + } + + m_controller->forceCleanup(); +} + +void MpvItem::loadMedia(const QString &url, const QString &serverId) +{ + if (m_shutdown) + return; + loadMediaNow(url, serverId); +} + +// 代理/中继决策:http-proxy / HTTPS relay 与历史 MpvWidget 路径一致。 +void MpvItem::loadMediaNow(const QString &url, const QString &serverId) +{ + 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() + << "[MpvItem] MPV http-proxy is not applied to HTTPS streams by libmpv" + << "| url:" << LogRedactionUtils::url(loadQUrl) << "| serverId:" + << (serverId.isEmpty() ? QStringLiteral("") : serverId); + } + + 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() << "[MpvItem] 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() << "[MpvItem] legacy loadfile per-file options failed," + << "retrying plain loadfile" + << "| error:" << mpv_error_string(legacyErr); + m_controller->command( + QVariantList{QStringLiteral("loadfile"), playbackUrl}, nullptr); + } + } +} + +void MpvItem::play() +{ + if (m_controller) + m_controller->setProperty(QStringLiteral("pause"), false); +} + +void MpvItem::pause() +{ + if (m_controller) + m_controller->setProperty(QStringLiteral("pause"), true); +} + +void MpvItem::stop() +{ + if (m_controller) + m_controller->command(QVariantList{QStringLiteral("stop")}); +} + +void MpvItem::seek(double positionInSeconds) +{ + if (m_controller) + m_controller->command(QVariantList{QStringLiteral("seek"), + positionInSeconds, + QStringLiteral("absolute")}); +} + +void MpvItem::setVolume(double volume) +{ + if (m_shutdown || !m_controller) + return; + volume = qBound(0.0, volume, 100.0); + m_controller->setProperty(QStringLiteral("volume"), volume); + if (!qFuzzyCompare(m_volume, volume)) + { + m_volume = volume; + Q_EMIT volumeChanged(m_volume); + } +} + +void MpvItem::setMuted(bool muted) +{ + if (m_shutdown || !m_controller) + return; + m_controller->setProperty(QStringLiteral("mute"), muted); + if (m_muted != muted) + { + m_muted = muted; + Q_EMIT mutedChanged(m_muted); + } +} + +void MpvItem::toggleMute() +{ + setMuted(!m_muted); +} + +void MpvItem::setSpeed(double speed) +{ + if (m_shutdown || !m_controller) + return; + if (speed <= 0.0) + speed = 1.0; + m_controller->setProperty(QStringLiteral("speed"), speed); + if (!qFuzzyCompare(m_speed, speed)) + { + m_speed = speed; + Q_EMIT speedChanged(m_speed); + } +} + +QVariant MpvItem::getProperty(const QString &name) const +{ + if (!m_controller || name.isEmpty()) + return {}; + return m_controller->getProperty(name); +} + +void MpvItem::setProperty(const QString &name, const QVariant &value) +{ + if (m_shutdown || !m_controller || name.isEmpty()) + return; + m_controller->setProperty(name, value); +} + +void MpvItem::onControllerPropertyChanged(const QString &property, + const QVariant &value) +{ + if (property == QLatin1String("volume")) + { + const double vol = value.toDouble(); + if (!qFuzzyCompare(m_volume, vol)) + { + m_volume = vol; + Q_EMIT volumeChanged(m_volume); + } + } + else if (property == QLatin1String("mute")) + { + const bool muted = value.toBool(); + if (m_muted != muted) + { + m_muted = muted; + Q_EMIT mutedChanged(m_muted); + } + } + else if (property == QLatin1String("speed")) + { + const double speed = value.toDouble(); + if (speed > 0.0 && !qFuzzyCompare(m_speed, speed)) + { + m_speed = speed; + Q_EMIT speedChanged(m_speed); + } + } + else if (property == QLatin1String("cache-speed") && !m_usingStreamRelay) + { + const qint64 bytes = value.toLongLong(); + if (m_networkSpeed != bytes) + { + m_networkSpeed = bytes; + Q_EMIT networkSpeedChanged(m_networkSpeed); + } + } +} diff --git a/src/qEmbyApp/quick/mpvitem.h b/src/qEmbyApp/quick/mpvitem.h new file mode 100644 index 0000000..0db89c9 --- /dev/null +++ b/src/qEmbyApp/quick/mpvitem.h @@ -0,0 +1,116 @@ +#ifndef MPVITEM_H +#define MPVITEM_H + +#include +#include +#include +#include +#include + +#include + +class MpvController; +class MpvHttpStreamRelay; + +// mpv 的 update callback 可能从任意线程触发,而 update() 必须在 GUI 线程调用。 +// item 析构时 detach(),此后回调变为空操作 —— 避免 renderer 比 item 活得久时 +// 访问悬垂指针。 +class MpvUpdateBridge +{ +public: + void attach(QQuickItem *item); + void detach(); + void requestUpdate(); + +private: + QMutex m_mutex; + QQuickItem *m_item = nullptr; +}; + +// QQuickItem 版的视频渲染面,替代 MpvWidget 的 QOpenGLWidget。 +// 渲染逻辑走 QQuickFramebufferObject(仅在 OpenGL 后端下工作, +// 见 main.cpp 的 setGraphicsApi);播放控制沿用 MpvController。 +class MpvItem : public QQuickFramebufferObject +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(double position READ position NOTIFY positionChanged) + Q_PROPERTY(double duration READ duration NOTIFY durationChanged) + Q_PROPERTY(bool paused READ paused NOTIFY playbackStateChanged) + Q_PROPERTY(double volume READ volume WRITE setVolume NOTIFY volumeChanged) + Q_PROPERTY(bool muted READ muted WRITE setMuted NOTIFY mutedChanged) + Q_PROPERTY(double speed READ speed WRITE setSpeed NOTIFY speedChanged) + Q_PROPERTY(qint64 networkSpeed READ networkSpeed NOTIFY networkSpeedChanged) + Q_PROPERTY(bool relayActive READ relayActive NOTIFY relayActiveChanged) + +public: + explicit MpvItem(QQuickItem *parent = nullptr); + ~MpvItem() override; + + Renderer *createRenderer() const override; + + double position() const { return m_position; } + double duration() const { return m_duration; } + bool paused() const { return m_paused; } + double volume() const { return m_volume; } + bool muted() const { return m_muted; } + double speed() const { return m_speed; } + qint64 networkSpeed() const { return m_networkSpeed; } + bool relayActive() const { return m_usingStreamRelay; } + + MpvController *controller() const { return m_controller; } + + Q_INVOKABLE void loadMedia(const QString &url, + const QString &serverId = QString()); + Q_INVOKABLE void play(); + Q_INVOKABLE void pause(); + Q_INVOKABLE void stop(); + Q_INVOKABLE void seek(double positionInSeconds); + Q_INVOKABLE void setVolume(double volume); + Q_INVOKABLE void setMuted(bool muted); + Q_INVOKABLE void toggleMute(); + Q_INVOKABLE void setSpeed(double speed); + // Thin passthrough for statistics / track menus (Batch 1–2). + Q_INVOKABLE QVariant getProperty(const QString &name) const; + Q_INVOKABLE void setProperty(const QString &name, const QVariant &value); + + // 显式释放 mpv 资源。析构会自动调用,但退出流程需要提前停止播放。 + void shutdown(); + +Q_SIGNALS: + void positionChanged(double position); + void durationChanged(double duration); + void playbackStateChanged(bool isPaused); + void volumeChanged(double volume); + void mutedChanged(bool muted); + void speedChanged(double speed); + void networkSpeedChanged(qint64 bytesPerSecond); + void relayActiveChanged(bool active); + void errorOccurred(const QString &errorMsg); + void endOfFile(const QString &reason); + +private: + void loadMediaNow(const QString &url, const QString &serverId); + void onControllerPropertyChanged(const QString &property, + const QVariant &value); + + MpvController *m_controller = nullptr; + MpvHttpStreamRelay *m_streamRelay = nullptr; + bool m_usingStreamRelay = false; + bool m_shutdown = false; + + double m_position = 0.0; + double m_duration = 0.0; + bool m_paused = true; + double m_volume = 100.0; + bool m_muted = false; + double m_speed = 1.0; + qint64 m_networkSpeed = 0; + + std::shared_ptr m_bridge; + + Q_DISABLE_COPY(MpvItem) +}; + +#endif diff --git a/src/qEmbyApp/quick/playerroutecontroller.cpp b/src/qEmbyApp/quick/playerroutecontroller.cpp new file mode 100644 index 0000000..71c7444 --- /dev/null +++ b/src/qEmbyApp/quick/playerroutecontroller.cpp @@ -0,0 +1,82 @@ +#include "playerroutecontroller.h" + +#include "navigationcontroller.h" +#include "playerviewmodel.h" + +PlayerRouteController::PlayerRouteController(QEmbyCore *core, + NavigationController *navigation, + QObject *parent) + : QObject(parent), m_navigation(navigation) +{ + setObjectName(QStringLiteral("player-route-controller")); + + m_playerViewModel = new PlayerViewModel(core, this); + + // Back from within the player (chrome back button / VM requestBack) pops the + // route, which deactivates the VM through updatePlayerRoute -> stopAndReport. + // Queued: never pop/destroy the player scene from inside a QML click stack. + connect(m_playerViewModel, &PlayerViewModel::backRequested, this, + [this] + { + if (m_navigation) + m_navigation->back(); + }, + Qt::QueuedConnection); + + if (m_navigation) + { + connect(m_navigation, &NavigationController::currentRouteChanged, this, + &PlayerRouteController::updatePlayerRoute); + connect(m_navigation, &NavigationController::sessionRoutesCleared, this, + &PlayerRouteController::updatePlayerRoute); + } + + updatePlayerRoute(); +} + +void PlayerRouteController::onRequestEmbeddedPlay(const QString &mediaId, + const QString &title, + const QString &streamUrl, + long long startPositionTicks, + const QVariant &extraData) +{ + if (!m_navigation) + return; + + // Seed the copy-unsafe payload on the VM before pushing; the route params + // carry only the mediaId (params must be copy-safe QVariantMap values). + m_playerViewModel->setPendingRequest(mediaId, title, streamUrl, + startPositionTicks, extraData); + + m_navigation->push( + {{QStringLiteral("name"), QStringLiteral("player")}, + {QStringLiteral("title"), title}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("mediaId"), mediaId}, + {QStringLiteral("title"), title}}}}); +} + +void PlayerRouteController::updatePlayerRoute() +{ + const QVariantMap route = + m_navigation ? m_navigation->currentRoute() : QVariantMap(); + const QString routeName = route.value(QStringLiteral("name")).toString(); + + if (routeName != QLatin1String("player")) + { + // Any nav-away (back / home / replace) deactivates the VM — the single + // authoritative teardown path, which reports the session stopped. + m_playerRouteKey.clear(); + m_playerViewModel->setActive(false); + return; + } + + const QString routeKey = route.value(QStringLiteral("key")).toString(); + if (routeKey != m_playerRouteKey) + { + // New player route instance: the pending request was seeded just before + // the push, so activation resolves and begins playback. + m_playerRouteKey = routeKey; + m_playerViewModel->setActive(true); + } +} diff --git a/src/qEmbyApp/quick/playerroutecontroller.h b/src/qEmbyApp/quick/playerroutecontroller.h new file mode 100644 index 0000000..139e427 --- /dev/null +++ b/src/qEmbyApp/quick/playerroutecontroller.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +class QEmbyCore; +class NavigationController; +class PlayerViewModel; + +// Owns the PlayerViewModel and bridges PlaybackManager's requestEmbeddedPlay +// signal to the `player` route. +// +// It is deliberately a lightweight standalone QObject rather than a BaseView +// (unlike MediaRouteController): the player has no context-menu / play-action +// machinery to inherit, only a play/report lifecycle. It mirrors the seed-by-key +// hand-off MediaRouteController uses for detail: the copy-unsafe launch payload +// (extraData) is stashed on the ViewModel via setPendingRequest before the +// route is pushed, and the route params carry only the mediaId. requestEmbeddedPlay +// is a same-thread direct signal, and push() synchronously emits +// currentRouteChanged, so the seed is always in place before updatePlayerRoute +// activates the VM. +class PlayerRouteController final : public QObject +{ + Q_OBJECT + +public: + PlayerRouteController(QEmbyCore *core, NavigationController *navigation, + QObject *parent = nullptr); + + // Exposed so AppShellHost can register it as the `player` context property. + PlayerViewModel *playerViewModel() const { return m_playerViewModel; } + +public Q_SLOTS: + // Slot for PlaybackManager::requestEmbeddedPlay — fires only after the + // external-player / independent-window / no-host branches have returned, so + // reaching here always means "play embedded". + void onRequestEmbeddedPlay(const QString &mediaId, const QString &title, + const QString &streamUrl, + long long startPositionTicks, + const QVariant &extraData); + +private: + // Activates/deactivates the VM off the current route, gated on the route key + // so re-entrant currentRouteChanged emissions don't restart playback. + void updatePlayerRoute(); + + NavigationController *m_navigation = nullptr; + PlayerViewModel *m_playerViewModel = nullptr; + QString m_playerRouteKey; +}; diff --git a/src/qEmbyApp/quick/playerviewmodel.cpp b/src/qEmbyApp/quick/playerviewmodel.cpp new file mode 100644 index 0000000..d221129 --- /dev/null +++ b/src/qEmbyApp/quick/playerviewmodel.cpp @@ -0,0 +1,1180 @@ +#include "playerviewmodel.h" + +#include "danmakupainteditem.h" +#include "mpvitem.h" + +#include "../components/idanmakusurface.h" +#include "../components/mpvcontroller.h" +#include "../components/playerdanmakucontroller.h" +#include "../components/playerdanmakuidentifydialog.h" +#include "../components/playerdanmakusettingsdialog.h" +#include "../components/playersubtitlesettingsdialog.h" +#include "../utils/mediaitemutils.h" +#include "../utils/mediasourcepreferenceutils.h" +#include "../utils/playerpreferenceutils.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ +constexpr int kProgressIntervalMs = 10000; + +long long secondsToTicks(double seconds) +{ + return static_cast(seconds * 10000000.0); +} + +QString trackLabel(const QVariantMap &map, const QString &fallbackPrefix, int id) +{ + const QString title = map.value(QStringLiteral("title")).toString().trimmed(); + if (!title.isEmpty()) + return title; + const QString lang = map.value(QStringLiteral("lang")).toString().trimmed(); + if (!lang.isEmpty()) + return lang; + return fallbackPrefix + QString::number(id); +} +} // namespace + +PlayerViewModel::PlayerViewModel(QEmbyCore *core, QObject *parent) + : QObject(parent), m_core(core) +{ + setObjectName(QStringLiteral("player-viewmodel")); + + m_reportTimer = new QTimer(this); + m_reportTimer->setInterval(kProgressIntervalMs); + connect(m_reportTimer, &QTimer::timeout, this, + &PlayerViewModel::reportProgress); + + connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, + [this](const QString &key, const QVariant &) + { + if (key.startsWith(QLatin1String("player/")) || + key == QLatin1String(ConfigKeys::PlayerLongPressSeek) || + key == QLatin1String(ConfigKeys::PlayerLongPressMode) || + key == QLatin1String(ConfigKeys::PlayerLongPressTriggerMs) || + key == QLatin1String(ConfigKeys::PlayerMouseEdgeLongPress)) + { + emit prefsChanged(); + } + }); + + // Tear the session down while the event loop is still alive (see + // qml-shell-teardown-websocket). ShellLifecycleController also calls + // setActive(false); this is a belt-and-suspenders path for abrupt quits. + connect(qApp, &QCoreApplication::aboutToQuit, this, [this]() { + if (m_active) + setActive(false); + }); +} + +PlayerViewModel::~PlayerViewModel() = default; + +void PlayerViewModel::setDialogParent(QWidget *parent) +{ + m_dialogParent = parent; +} + +void PlayerViewModel::setPendingRequest(const QString &mediaId, + const QString &title, + const QString &streamUrl, + long long startPositionTicks, + const QVariant &extraData) +{ + m_pendingMediaId = mediaId; + m_pendingTitle = title; + m_pendingStreamUrl = streamUrl; + m_pendingStartTicks = startPositionTicks; + m_pendingExtra = extraData; + m_hasPending = true; +} + +void PlayerViewModel::setActive(bool active) +{ + if (active == m_active) + return; + m_active = active; + + if (active) + { + beginPlayback(); + } + else + { + // Teardown order matters: QML may destroy MpvItem/DanmakuPaintedItem in + // the same event as this call (or just before). Null surface/controller + // refs *before* clearPlaybackContext so we never call into freed QML. + unbindPlaybackSurfaces(); + if (m_danmaku) + m_danmaku->clearPlaybackContext(); + stopAndReport(); + } + emit stateChanged(); +} + +bool PlayerViewModel::hasActiveSession() const +{ + return m_sessionStartIssued && !m_hasReportedStop && !m_mediaId.isEmpty(); +} + +void PlayerViewModel::unbindPlaybackSurfaces() +{ + // Prefer QPointer-backed object for setController — m_danmakuSurface alone + // may already be dangling if QML destroyed the item first. + if (auto *painted = qobject_cast(m_surfaceObject.data())) + painted->setController(nullptr); + + if (m_surfaceObject) + { + disconnect(m_surfaceObject, &QObject::destroyed, this, nullptr); + m_surfaceObject.clear(); + } + m_danmakuSurface = nullptr; + + if (m_danmaku) + m_danmaku->setDanmakuSurface(nullptr); + + if (m_mpv) + { + disconnect(m_mpv, nullptr, this, nullptr); + m_mpv.clear(); + } +} + +void PlayerViewModel::bindMpv(QObject *mpvItem) +{ + auto *item = qobject_cast(mpvItem); + MpvController *ctrl = item ? item->controller() : nullptr; + if (m_mpv.data() == ctrl) + return; + if (m_mpv) + disconnect(m_mpv, nullptr, this, nullptr); + m_mpv = ctrl; + if (auto *painted = dynamic_cast(m_danmakuSurface)) + painted->setController(m_mpv); + // Only build the danmaku controller when we have a live surface + controller. + if (m_mpv) + ensureDanmakuController(); + if (m_mpv) + { + connect(m_mpv, &MpvController::propertyChanged, this, + [this](const QString &property, const QVariant &) + { + if (!m_mpv) + return; + if (property == QLatin1String("track-list") || + property == QLatin1String("aid") || + property == QLatin1String("sid")) + { + refreshTracksFromMpv(); + } + }); + refreshTracksFromMpv(); + } + else + { + // Explicit unbind from QML (bindMpv(null)) — drop track list. + m_audioTracks.clear(); + m_subtitleTracks.clear(); + m_currentAudioId = -1; + m_currentSubtitleId = -1; + emit tracksChanged(); + } +} + +void PlayerViewModel::bindDanmakuSurface(QObject *surfaceItem) +{ + auto *painted = qobject_cast(surfaceItem); + + if (m_surfaceObject) + disconnect(m_surfaceObject, &QObject::destroyed, this, nullptr); + + m_surfaceObject = painted; + m_danmakuSurface = painted; + + // If QML destroys the painted item first (route pop), drop the raw interface + // pointer so clearPlaybackContext never touches freed memory. + if (painted) + { + connect(painted, &QObject::destroyed, this, [this]() { + m_danmakuSurface = nullptr; + m_surfaceObject.clear(); + if (m_danmaku) + m_danmaku->setDanmakuSurface(nullptr); + }); + if (m_mpv) + painted->setController(m_mpv); + painted->setExternalFrameClockEnabled(true); + } + + if (m_danmaku) + m_danmaku->setDanmakuSurface(m_danmakuSurface); + else if (painted) + ensureDanmakuController(); +} + +void PlayerViewModel::ensureDanmakuController() +{ + if (!m_mpv || !m_core) + return; + if (!m_danmaku) + { + m_danmaku = new PlayerDanmakuController(m_core, m_mpv, m_danmakuSurface, + this); + connect(m_danmaku, &PlayerDanmakuController::stateChanged, this, + &PlayerViewModel::danmakuChanged); + connect(m_danmaku, &PlayerDanmakuController::toastRequested, this, + &PlayerViewModel::toastRequested); + } + else if (m_danmakuSurface) + { + m_danmaku->setDanmakuSurface(m_danmakuSurface); + } +} + +void PlayerViewModel::beginPlayback() +{ + if (!m_hasPending) + return; + m_hasPending = false; + + m_title = m_pendingTitle; + m_mediaId = m_pendingMediaId; + m_startPositionTicks = m_pendingStartTicks; + m_currentPositionSeconds = 0.0; + m_isPaused = false; + m_playSessionId.clear(); + m_sessionStartIssued = false; + m_hasReportedStop = false; + m_autoPlayAdvanceInProgress = false; + m_switcherCacheReady = false; + m_switcherCacheMediaId.clear(); + + if (m_core && m_core->serverManager()) + m_serverId = m_core->serverManager()->activeProfile().id; + + resolveStream(m_pendingExtra); + + if (m_hasLaunchContext) + { + ensureDanmakuController(); + if (m_danmaku) + m_danmaku->setPlaybackContext(m_launchContext); + m_seriesId = m_launchContext.mediaItem.seriesId; + m_seriesName = m_launchContext.mediaItem.seriesName; + if (m_seriesName.isEmpty()) + m_seriesName = m_launchContext.mediaItem.name; + } + + m_pendingExtra = QVariant(); + m_pendingMediaId.clear(); + m_pendingTitle.clear(); + m_pendingStreamUrl.clear(); + m_pendingStartTicks = 0; + + ensureSwitcherLoaded(); + emit stateChanged(); + emit danmakuChanged(); +} + +void PlayerViewModel::resolveStream(const QVariant &extraData) +{ + const QString streamUrl = m_pendingStreamUrl; + MediaSourceInfo resolvedSourceInfo; + MediaItem resolvedItem; + m_hasLaunchContext = false; + + if (extraData.isValid() && extraData.canConvert()) + { + m_launchContext = extraData.value(); + m_hasLaunchContext = true; + resolvedItem = m_launchContext.mediaItem; + resolvedSourceInfo = m_launchContext.selectedSource; + } + else if (extraData.isValid() && extraData.canConvert()) + { + resolvedSourceInfo = extraData.value(); + } + + m_mediaSourceId = m_mediaId; + + QString actualStreamUrl = streamUrl; + if (m_core && m_core->mediaService() && !resolvedSourceInfo.id.isEmpty()) + { + const QString directUrl = + m_core->mediaService()->getStreamUrl(m_mediaId, resolvedSourceInfo); + if (!directUrl.isEmpty()) + actualStreamUrl = directUrl; + } + + const QUrl streamQUrl(actualStreamUrl); + const QUrl origQUrl(streamUrl); + QUrlQuery query(streamQUrl); + if (!query.hasQueryItem(QStringLiteral("mediaSourceId")) && + QUrlQuery(origQUrl).hasQueryItem(QStringLiteral("mediaSourceId"))) + { + query = QUrlQuery(origQUrl); + } + + if (query.hasQueryItem(QStringLiteral("mediaSourceId"))) + m_mediaSourceId = query.queryItemValue(QStringLiteral("mediaSourceId")); + else if (query.hasQueryItem(QStringLiteral("MediaSourceId"))) + m_mediaSourceId = query.queryItemValue(QStringLiteral("MediaSourceId")); + + if (resolvedSourceInfo.id.isEmpty() && !resolvedItem.mediaSources.isEmpty()) + { + for (const MediaSourceInfo &candidate : resolvedItem.mediaSources) + { + if (!m_mediaSourceId.isEmpty() && + candidate.id.compare(m_mediaSourceId, Qt::CaseInsensitive) == 0) + { + resolvedSourceInfo = candidate; + break; + } + } + if (resolvedSourceInfo.id.isEmpty()) + resolvedSourceInfo = resolvedItem.mediaSources.first(); + } + + if (!resolvedItem.seriesId.isEmpty()) + { + m_seriesId = resolvedItem.seriesId; + m_seriesName = resolvedItem.seriesName.isEmpty() ? resolvedItem.name + : resolvedItem.seriesName; + } + + m_streamUrl = actualStreamUrl; +} + +void PlayerViewModel::notifyLoaded() +{ + startReporting(); + refreshTracksFromMpv(); +} + +void PlayerViewModel::notifyPositionChanged(double positionSeconds) +{ + m_currentPositionSeconds = positionSeconds; +} + +void PlayerViewModel::notifyPausedChanged(bool paused) +{ + if (paused == m_isPaused) + return; + m_isPaused = paused; + reportProgress(); +} + +void PlayerViewModel::notifySeeked() +{ + reportProgress(); +} + +void PlayerViewModel::notifyError(const QString &message) +{ + spdlog::warn("[PlayerViewModel] playback error: {}", message.toStdString()); + emit toastRequested(message); +} + +void PlayerViewModel::notifyTracksChanged() +{ + refreshTracksFromMpv(); +} + +void PlayerViewModel::notifyPlaybackEnded(const QString &reason) +{ + spdlog::info("[PlayerViewModel] playback ended reason={} mediaId={}", + reason.toStdString(), m_mediaId.toStdString()); + // Continuous play is the only consumer; ignore non-eof reasons for now. + if (!reason.isEmpty() && reason != QLatin1String("eof")) + return; + autoPlayNextIfEnabled(); +} + +int PlayerViewModel::seekStepSeconds() const +{ + const QString step = ConfigStore::instance() + ->get(ConfigKeys::PlayerSeekStep, + QStringLiteral("10")) + .trimmed(); + bool ok = false; + const int value = step.toInt(&ok); + return ok && value > 0 ? value : 10; +} + +bool PlayerViewModel::clickToPause() const +{ + return ConfigStore::instance()->get(ConfigKeys::PlayerClickToPause, + true); +} + +void PlayerViewModel::requestBack() +{ + // Queue past the QML click handler. Navigating (and destroying PlayerPage / + // Popups / MpvItem) synchronously inside MouseArea.onClicked can free the + // sender mid-stack and SIGSEGV. + QMetaObject::invokeMethod( + this, + [this]() { + emit backRequested(); + }, + Qt::QueuedConnection); +} + +QString PlayerViewModel::formatTime(double seconds, double totalSeconds) const +{ + if (!std::isfinite(seconds) || seconds < 0.0) + seconds = 0.0; + if (!std::isfinite(totalSeconds) || totalSeconds < 0.0) + totalSeconds = 0.0; + + QTime t(0, 0, 0); + t = t.addSecs(static_cast(seconds)); + QTime totalT(0, 0, 0); + totalT = totalT.addSecs(static_cast(totalSeconds)); + + return totalT.hour() > 0 ? t.toString(QStringLiteral("hh:mm:ss")) + : t.toString(QStringLiteral("mm:ss")); +} + +void PlayerViewModel::refreshTracksFromMpv() +{ + m_audioTracks.clear(); + m_subtitleTracks.clear(); + m_currentAudioId = -1; + m_currentSubtitleId = -1; + + if (!m_mpv) + { + emit tracksChanged(); + return; + } + + const QVariantList tracks = + m_mpv->getProperty(QStringLiteral("track-list")).toList(); + for (const QVariant &entry : tracks) + { + const QVariantMap map = entry.toMap(); + const QString type = map.value(QStringLiteral("type")).toString(); + const int id = map.value(QStringLiteral("id")).toInt(); + const bool selected = map.value(QStringLiteral("selected")).toBool(); + QVariantMap row = map; + row.insert(QStringLiteral("label"), + trackLabel(map, + type == QLatin1String("audio") + ? tr("Track ") + : tr("Subtitle "), + id)); + if (type == QLatin1String("audio")) + { + m_audioTracks.append(row); + if (selected) + m_currentAudioId = id; + } + else if (type == QLatin1String("sub")) + { + // Hide the synthetic danmaku ASS track from the subtitle menu. + const QString title = + map.value(QStringLiteral("title")).toString(); + if (title.contains(QLatin1String("[qEmby] Danmaku"))) + continue; + m_subtitleTracks.append(row); + if (selected) + m_currentSubtitleId = id; + } + } + emit tracksChanged(); +} + +void PlayerViewModel::selectAudioTrack(int trackId) +{ + if (m_mpv) + m_mpv->setProperty(QStringLiteral("aid"), trackId); + refreshTracksFromMpv(); +} + +void PlayerViewModel::disableAudio() +{ + if (m_mpv) + m_mpv->setProperty(QStringLiteral("aid"), QStringLiteral("no")); + refreshTracksFromMpv(); +} + +void PlayerViewModel::selectSubtitleTrack(int trackId) +{ + if (m_danmaku) + { + m_danmaku->selectSubtitleTrack(QVariant(trackId)); + } + else if (m_mpv) + { + m_mpv->setProperty(QStringLiteral("sid"), trackId); + } + refreshTracksFromMpv(); +} + +void PlayerViewModel::disableSubtitles() +{ + if (m_danmaku) + m_danmaku->selectSubtitleTrack(QVariant(QStringLiteral("no"))); + else if (m_mpv) + m_mpv->setProperty(QStringLiteral("sid"), QStringLiteral("no")); + refreshTracksFromMpv(); +} + +bool PlayerViewModel::danmakuEnabled() const +{ + return m_danmaku && m_danmaku->isDanmakuEnabled(); +} + +bool PlayerViewModel::danmakuVisible() const +{ + return m_danmaku && m_danmaku->isDanmakuVisible(); +} + +bool PlayerViewModel::danmakuLoading() const +{ + return m_danmaku && m_danmaku->isLoading(); +} + +bool PlayerViewModel::danmakuReady() const +{ + return m_danmaku && + (m_danmaku->hasDanmakuTrack() || m_danmaku->hasPreparedDanmaku()); +} + +QString PlayerViewModel::danmakuStatus() const +{ + if (!m_danmaku) + return {}; + if (m_danmaku->isLoading()) + return tr("Loading danmaku…"); + if (!m_danmaku->isDanmakuEnabled()) + return tr("Danmaku off"); + if (!m_danmaku->hasPreparedDanmaku() && !m_danmaku->hasDanmakuTrack()) + return tr("No danmaku"); + QString title = m_danmaku->sourceTitle().trimmed(); + if (title.isEmpty()) + title = tr("Danmaku"); + if (m_danmaku->commentCount() > 0) + return tr("%1 · %2 comments") + .arg(title) + .arg(m_danmaku->commentCount()); + return title; +} + +void PlayerViewModel::setDanmakuEnabled(bool enabled) +{ + ensureDanmakuController(); + if (m_danmaku) + m_danmaku->setDanmakuEnabled(enabled); +} + +void PlayerViewModel::setDanmakuVisible(bool visible) +{ + ensureDanmakuController(); + if (m_danmaku) + m_danmaku->setDanmakuVisible(visible); +} + +void PlayerViewModel::reloadDanmaku(const QString &keyword) +{ + ensureDanmakuController(); + if (m_danmaku) + m_danmaku->reload(keyword); +} + +void PlayerViewModel::openDanmakuSettings() +{ + if (!m_dialogParent) + return; + auto *dlg = new PlayerDanmakuSettingsDialog(m_dialogParent); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &PlayerDanmakuSettingsDialog::liveReloadRequested, this, + [this]() + { + if (m_danmaku) + m_danmaku->reload(); + }); + dlg->show(); +} + +void PlayerViewModel::openDanmakuIdentify() +{ + if (!m_dialogParent || !m_core || !m_danmaku || !m_danmaku->hasPlaybackContext()) + return; + auto *dlg = new PlayerDanmakuIdentifyDialog( + m_core, m_danmaku->mediaContext(), QString(), + m_danmaku->activeTargetId(), m_danmaku->activeEndpointId(), + m_dialogParent); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &PlayerDanmakuIdentifyDialog::accepted, this, + [this, dlg]() + { + const auto candidate = dlg->selectedCandidate(); + if (m_danmaku && candidate.isValid()) + m_danmaku->loadFromCandidate(candidate, true); + }); + dlg->show(); +} + +void PlayerViewModel::openSubtitleSettings() +{ + if (!m_dialogParent) + return; + auto *dlg = new PlayerSubtitleSettingsDialog(m_dialogParent); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->show(); +} + +void PlayerViewModel::loadLocalDanmaku(const QString &filePath) +{ + ensureDanmakuController(); + if (m_danmaku && !filePath.isEmpty()) + m_danmaku->loadLocalFile(filePath); +} + +void PlayerViewModel::ensureSwitcherLoaded() +{ + if (m_mediaId.isEmpty() || !m_core || !m_core->mediaService()) + return; + if (m_switcherCacheReady && m_switcherCacheMediaId == m_mediaId) + { + rebuildSwitcherLists(); + return; + } + loadSwitcherTask(m_mediaId, m_seriesId, !m_seriesId.isEmpty()); +} + +QVariantMap PlayerViewModel::itemToMap(const MediaItem &item) const +{ + QVariantMap map; + map.insert(QStringLiteral("id"), item.id); + map.insert(QStringLiteral("name"), item.name); + map.insert(QStringLiteral("title"), MediaItemUtils::playbackTitle(item)); + map.insert(QStringLiteral("type"), item.type); + map.insert(QStringLiteral("seriesId"), item.seriesId); + map.insert(QStringLiteral("seriesName"), item.seriesName); + map.insert(QStringLiteral("indexNumber"), item.indexNumber); + map.insert(QStringLiteral("parentIndexNumber"), item.parentIndexNumber); + map.insert(QStringLiteral("startPositionTicks"), + static_cast(item.userData.playbackPositionTicks)); + map.insert(QStringLiteral("startPosition"), + item.userData.playbackPositionTicks / 10000000.0); + map.insert(QStringLiteral("primaryImageTag"), item.images.primaryTag); + map.insert(QStringLiteral("current"), item.id == m_mediaId); + return map; +} + +void PlayerViewModel::rebuildSwitcherLists() +{ + m_switcherSeriesMode = !m_seriesId.isEmpty() && !m_seasons.isEmpty(); + m_switcherTitle = + m_switcherSeriesMode + ? (m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName) + : tr("Continue Watching"); + + m_switcherItems.clear(); + for (const MediaItem &item : m_resumeItems) + m_switcherItems.append(itemToMap(item)); + + m_switcherSeasons.clear(); + for (const MediaItem &season : m_seasons) + m_switcherSeasons.append(itemToMap(season)); + + if (m_switcherSeriesMode) + { + if (m_selectedSeasonId.isEmpty() && !m_seasons.isEmpty()) + { + // Prefer the season that contains the current episode. + for (const MediaItem &season : m_seasons) + { + const auto eps = m_episodesBySeason.value(season.id); + for (const MediaItem &ep : eps) + { + if (ep.id == m_mediaId) + { + m_selectedSeasonId = season.id; + break; + } + } + if (!m_selectedSeasonId.isEmpty()) + break; + } + if (m_selectedSeasonId.isEmpty()) + m_selectedSeasonId = m_seasons.first().id; + } + m_switcherEpisodes.clear(); + for (const MediaItem &ep : m_episodesBySeason.value(m_selectedSeasonId)) + m_switcherEpisodes.append(itemToMap(ep)); + } + else + { + m_switcherEpisodes.clear(); + m_selectedSeasonId.clear(); + } + emit switcherChanged(); +} + +QCoro::Task PlayerViewModel::loadSwitcherTask(QString mediaId, + QString seriesId, + bool seriesMode) +{ + const quint64 serial = ++m_switcherSerial; + m_switcherLoading = true; + emit switcherChanged(); + + QPointer self(this); + try + { + auto *service = m_core->mediaService(); + QList seasons; + QHash> episodes; + QList resume; + + if (seriesMode && !seriesId.isEmpty()) + { + seasons = co_await service->getSeasons(seriesId); + if (!self || self->m_switcherSerial != serial || + self->m_mediaId != mediaId) + co_return; + for (const MediaItem &season : seasons) + { + episodes.insert(season.id, + co_await service->getEpisodes(seriesId, season.id)); + if (!self || self->m_switcherSerial != serial || + self->m_mediaId != mediaId) + co_return; + } + try + { + resume = co_await service->getResumeItems(30); + } + catch (...) + { + } + } + else + { + resume = co_await service->getResumeItems(30); + } + + if (!self || self->m_switcherSerial != serial || self->m_mediaId != mediaId) + co_return; + + self->m_seasons = seasons; + self->m_episodesBySeason = episodes; + self->m_resumeItems = resume; + self->m_switcherCacheReady = true; + self->m_switcherCacheMediaId = mediaId; + self->m_switcherLoading = false; + self->rebuildSwitcherLists(); + } + catch (const std::exception &e) + { + spdlog::warn("[PlayerViewModel] switcher load failed: {}", e.what()); + if (self && self->m_switcherSerial == serial) + { + self->m_switcherLoading = false; + emit self->switcherChanged(); + } + } +} + +void PlayerViewModel::selectSeason(const QString &seasonId) +{ + if (seasonId == m_selectedSeasonId) + return; + m_selectedSeasonId = seasonId; + m_switcherEpisodes.clear(); + for (const MediaItem &ep : m_episodesBySeason.value(m_selectedSeasonId)) + m_switcherEpisodes.append(itemToMap(ep)); + emit switcherChanged(); +} + +void PlayerViewModel::playSwitcherItem(const QString &mediaId, + const QString &title, + double startPositionSeconds) +{ + if (mediaId.isEmpty() || mediaId == m_mediaId) + return; + switchToMediaTask(mediaId, title, secondsToTicks(startPositionSeconds)); +} + +bool PlayerViewModel::findAdjacentTarget(int direction, QString *mediaId, + QString *title, + long long *startTicks) const +{ + if (!mediaId || !title || !startTicks) + return false; + mediaId->clear(); + title->clear(); + *startTicks = 0; + + if (m_switcherSeriesMode && !m_seasons.isEmpty()) + { + MediaItem prev; + MediaItem next; + bool found = false; + for (const MediaItem &season : m_seasons) + { + for (const MediaItem &ep : m_episodesBySeason.value(season.id)) + { + if (ep.id == m_mediaId) + found = true; + else if (!found) + prev = ep; + else if (found && next.id.isEmpty()) + next = ep; + } + } + const MediaItem &target = direction < 0 ? prev : next; + if (target.id.isEmpty()) + return false; + *mediaId = target.id; + *title = MediaItemUtils::playbackTitle(target); + *startTicks = target.userData.playbackPositionTicks; + return true; + } + + int idx = -1; + for (int i = 0; i < m_resumeItems.size(); ++i) + { + if (m_resumeItems.at(i).id == m_mediaId) + { + idx = i; + break; + } + } + const int target = idx + (direction < 0 ? -1 : 1); + if (idx < 0 || target < 0 || target >= m_resumeItems.size()) + return false; + const MediaItem &item = m_resumeItems.at(target); + *mediaId = item.id; + *title = MediaItemUtils::playbackTitle(item); + *startTicks = item.userData.playbackPositionTicks; + return true; +} + +void PlayerViewModel::playAdjacent(int direction) +{ + ensureSwitcherLoaded(); + QString targetId; + QString targetTitle; + long long startTicks = 0; + if (!findAdjacentTarget(direction, &targetId, &targetTitle, &startTicks)) + { + emit toastRequested(direction < 0 ? tr("No previous item") + : tr("No next item")); + return; + } + switchToMediaTask(targetId, targetTitle, startTicks); +} + +QCoro::Task PlayerViewModel::autoPlayNextIfEnabled() +{ + if (m_autoPlayAdvanceInProgress || m_mediaId.isEmpty() || !m_active) + co_return; + if (!ConfigStore::instance()->get(ConfigKeys::PlayerContinuousPlay, + true)) + { + spdlog::info("[PlayerViewModel] continuous play disabled"); + co_return; + } + + m_autoPlayAdvanceInProgress = true; + QPointer self(this); + const QString finishedId = m_mediaId; + + // Ensure switcher cache is warm so findAdjacent can see the next episode. + if (!m_switcherCacheReady || m_switcherCacheMediaId != finishedId) + { + co_await loadSwitcherTask(finishedId, m_seriesId, !m_seriesId.isEmpty()); + if (!self || self->m_mediaId != finishedId) + co_return; + } + + QString targetId; + QString targetTitle; + long long startTicks = 0; + if (!self->findAdjacentTarget(1, &targetId, &targetTitle, &startTicks)) + { + // Series ended: try the next resume item that is not this series. + if (!self->m_resumeItems.isEmpty()) + { + bool pastCurrent = false; + for (const MediaItem &item : self->m_resumeItems) + { + if (item.id == finishedId) + { + pastCurrent = true; + continue; + } + if (!pastCurrent) + continue; + if (!self->m_seriesId.isEmpty() && + (item.seriesId == self->m_seriesId || + item.id == self->m_seriesId)) + continue; + targetId = item.id; + targetTitle = MediaItemUtils::playbackTitle(item); + startTicks = item.userData.playbackPositionTicks; + break; + } + } + } + + if (targetId.isEmpty()) + { + spdlog::info("[PlayerViewModel] continuous play: no next item"); + if (self) + self->m_autoPlayAdvanceInProgress = false; + co_return; + } + + spdlog::info("[PlayerViewModel] continuous play {} → {}", + finishedId.toStdString(), targetId.toStdString()); + co_await self->switchToMediaTask(targetId, targetTitle, startTicks); + if (self) + self->m_autoPlayAdvanceInProgress = false; +} + +QCoro::Task PlayerViewModel::switchToMediaTask(QString mediaId, + QString title, + long long startTicks) +{ + if (!m_core || !m_core->mediaService() || mediaId.isEmpty()) + co_return; + QPointer self(this); + try + { + MediaItem detail = co_await m_core->mediaService()->getItemDetail(mediaId); + if (!self) + co_return; + if (detail.mediaSources.isEmpty()) + { + PlaybackInfo info = + co_await m_core->mediaService()->getPlaybackInfo(detail.id); + if (!self) + co_return; + detail.mediaSources = info.mediaSources; + } + + MediaSourceInfo selected; + if (!detail.mediaSources.isEmpty()) + { + int sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( + detail.mediaSources, + ConfigStore::instance() + ->get(ConfigKeys::PlayerPreferredVersion) + .trimmed()); + if (sourceIdx < 0 || sourceIdx >= detail.mediaSources.size()) + sourceIdx = 0; + selected = detail.mediaSources.at(sourceIdx); + PlayerPreferenceUtils::applyPreferredStreamRules( + selected, + ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, + QStringLiteral("auto")), + ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, + QStringLiteral("auto"))); + } + + QString streamUrl = + selected.id.isEmpty() + ? m_core->mediaService()->getStreamUrl(detail.id, detail.id) + : m_core->mediaService()->getStreamUrl(detail.id, selected); + QString resolvedTitle = MediaItemUtils::playbackTitle(detail); + if (resolvedTitle.isEmpty()) + resolvedTitle = title; + + // Tear down current server session then re-seed as a new pending play. + self->stopAndReport(); + PlayerLaunchContext ctx; + ctx.mediaItem = detail; + ctx.selectedSource = selected; + self->setPendingRequest(detail.id, resolvedTitle, streamUrl, startTicks, + QVariant::fromValue(ctx)); + self->m_sessionStartIssued = false; + self->m_hasReportedStop = false; + self->beginPlayback(); + emit self->reloadMpvRequested(); + emit self->stateChanged(); + } + catch (const std::exception &e) + { + spdlog::warn("[PlayerViewModel] switch media failed: {}", e.what()); + if (self) + emit self->toastRequested(tr("Failed to switch media")); + } +} + +bool PlayerViewModel::longPressEnabled() const +{ + return ConfigStore::instance()->get(ConfigKeys::PlayerLongPressSeek, + true); +} + +QString PlayerViewModel::longPressMode() const +{ + return ConfigStore::instance() + ->get(ConfigKeys::PlayerLongPressMode, QStringLiteral("speed")) + .trimmed(); +} + +int PlayerViewModel::longPressTriggerMs() const +{ + return ConfigStore::instance() + ->get(ConfigKeys::PlayerLongPressTriggerMs, QStringLiteral("350")) + .toInt(); +} + +bool PlayerViewModel::mouseEdgeLongPress() const +{ + return ConfigStore::instance()->get( + ConfigKeys::PlayerMouseEdgeLongPress, true); +} + +void PlayerViewModel::beginLongPressSpeed(double baseSpeed) +{ + m_longPressRestoreSpeed = baseSpeed > 0.0 ? baseSpeed : 1.0; + m_longPressSpeed = nextLongPressSpeed(m_longPressRestoreSpeed); + m_longPressActive = true; + emit prefsChanged(); +} + +void PlayerViewModel::endLongPressSpeed() +{ + m_longPressActive = false; + emit prefsChanged(); +} + +double PlayerViewModel::nextLongPressSpeed(double current) const +{ + const QList steps = {1.5, 2.0, 3.0, 4.0}; + for (double s : steps) + { + if (s > current + 0.01) + return s; + } + return steps.last(); +} + +void PlayerViewModel::startReporting() +{ + if (m_sessionStartIssued || m_hasReportedStop) + return; + if (m_mediaId.isEmpty() || !m_core || !m_core->mediaService()) + return; + m_sessionStartIssued = true; + + auto *service = m_core->mediaService(); + const long long ticks = m_startPositionTicks; + + auto startSessionTask = + [](QPointer self, MediaService *s, QString mId, + QString sId, long long positionTicks) -> QCoro::Task + { + QString sessionId; + try + { + sessionId = co_await s->reportPlaybackStart(mId, sId, positionTicks); + } + catch (const std::exception &e) + { + spdlog::warn("[PlayerViewModel] start report failed: {}", e.what()); + co_return; + } + if (self && self->m_mediaId == mId && !self->m_hasReportedStop) + self->m_playSessionId = sessionId; + }; + startSessionTask(QPointer(this), service, m_mediaId, + m_mediaSourceId, ticks); + + m_reportTimer->start(); +} + +void PlayerViewModel::reportProgress() +{ + if (m_hasReportedStop || m_mediaId.isEmpty() || m_playSessionId.isEmpty()) + return; + if (!m_core || !m_core->mediaService()) + return; + const long long ticks = secondsToTicks(m_currentPositionSeconds); + m_core->mediaService()->reportPlaybackProgress( + m_mediaId, m_mediaSourceId, ticks, m_isPaused, m_playSessionId); +} + +void PlayerViewModel::stopAndReport() +{ + if (m_hasReportedStop) + return; + m_hasReportedStop = true; + + if (m_reportTimer) + m_reportTimer->stop(); + + if (!m_mediaId.isEmpty() && m_core && m_core->mediaService()) + { + const long long ticks = secondsToTicks(m_currentPositionSeconds); + + auto *service = m_core->mediaService(); + const QString mediaId = m_mediaId; + const QString sourceId = m_mediaSourceId; + const QString sessionId = m_playSessionId; + m_playSessionId.clear(); + + auto taskRoutine = [](MediaService *s, QString mId, QString sId, + long long stopTicks, + QString sessId) -> QCoro::Task + { + try + { + co_await s->reportPlaybackProgress(mId, sId, stopTicks, true, + sessId); + } + catch (const std::exception &e) + { + spdlog::debug("[PlayerViewModel] progress sync failed: {}", + e.what()); + } + try + { + co_await s->reportPlaybackStopped(mId, sId, stopTicks, sessId); + } + catch (const std::exception &e) + { + spdlog::warn("[PlayerViewModel] stop sync failed: {}", e.what()); + } + }; + + auto *lingeringTask = new QCoro::Task( + taskRoutine(service, mediaId, sourceId, ticks, sessionId)); + QTimer::singleShot(kProgressIntervalMs, m_core, + [lingeringTask]() { delete lingeringTask; }); + } + + m_mediaId.clear(); + m_mediaSourceId.clear(); + m_sessionStartIssued = false; +} diff --git a/src/qEmbyApp/quick/playerviewmodel.h b/src/qEmbyApp/quick/playerviewmodel.h new file mode 100644 index 0000000..af21322 --- /dev/null +++ b/src/qEmbyApp/quick/playerviewmodel.h @@ -0,0 +1,226 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class QEmbyCore; +class QTimer; +class QWidget; +class MpvController; +class IDanmakuSurface; +class PlayerDanmakuController; + +// Session-scoped ViewModel behind the `player` route (context property +// `player`). Owns server reporting, track lists, danmaku controller, media +// switcher cache, and Widget dialog reparents. Does NOT hold an MpvItem +// pointer — PlayerPage owns the video surface and calls notify* / bindMpv. +class PlayerViewModel final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY stateChanged) + Q_PROPERTY(QString streamUrl READ streamUrl NOTIFY stateChanged) + Q_PROPERTY(QString serverId READ serverId NOTIFY stateChanged) + Q_PROPERTY(double startPosition READ startPosition NOTIFY stateChanged) + Q_PROPERTY(bool active READ active WRITE setActive NOTIFY stateChanged) + Q_PROPERTY(QVariantList audioTracks READ audioTracks NOTIFY tracksChanged) + Q_PROPERTY(QVariantList subtitleTracks READ subtitleTracks NOTIFY tracksChanged) + Q_PROPERTY(int currentAudioId READ currentAudioId NOTIFY tracksChanged) + Q_PROPERTY(int currentSubtitleId READ currentSubtitleId NOTIFY tracksChanged) + Q_PROPERTY(bool danmakuEnabled READ danmakuEnabled NOTIFY danmakuChanged) + Q_PROPERTY(bool danmakuVisible READ danmakuVisible NOTIFY danmakuChanged) + Q_PROPERTY(bool danmakuLoading READ danmakuLoading NOTIFY danmakuChanged) + Q_PROPERTY(bool danmakuReady READ danmakuReady NOTIFY danmakuChanged) + Q_PROPERTY(QString danmakuStatus READ danmakuStatus NOTIFY danmakuChanged) + Q_PROPERTY(bool switcherLoading READ switcherLoading NOTIFY switcherChanged) + Q_PROPERTY(bool switcherSeriesMode READ switcherSeriesMode NOTIFY switcherChanged) + Q_PROPERTY(QString switcherTitle READ switcherTitle NOTIFY switcherChanged) + Q_PROPERTY(QVariantList switcherItems READ switcherItems NOTIFY switcherChanged) + Q_PROPERTY(QVariantList switcherSeasons READ switcherSeasons NOTIFY switcherChanged) + Q_PROPERTY(QString selectedSeasonId READ selectedSeasonId NOTIFY switcherChanged) + Q_PROPERTY(QVariantList switcherEpisodes READ switcherEpisodes NOTIFY switcherChanged) + Q_PROPERTY(QString currentMediaId READ currentMediaId NOTIFY stateChanged) + Q_PROPERTY(bool longPressEnabled READ longPressEnabled NOTIFY prefsChanged) + Q_PROPERTY(QString longPressMode READ longPressMode NOTIFY prefsChanged) + Q_PROPERTY(int longPressTriggerMs READ longPressTriggerMs NOTIFY prefsChanged) + Q_PROPERTY(bool mouseEdgeLongPress READ mouseEdgeLongPress NOTIFY prefsChanged) + Q_PROPERTY(double longPressSpeed READ longPressSpeed NOTIFY prefsChanged) + +public: + explicit PlayerViewModel(QEmbyCore *core, QObject *parent = nullptr); + ~PlayerViewModel() override; + + void setDialogParent(QWidget *parent); + + QString title() const { return m_title; } + QString streamUrl() const { return m_streamUrl; } + QString serverId() const { return m_serverId; } + double startPosition() const { return m_startPositionTicks / 10000000.0; } + bool active() const { return m_active; } + QVariantList audioTracks() const { return m_audioTracks; } + QVariantList subtitleTracks() const { return m_subtitleTracks; } + int currentAudioId() const { return m_currentAudioId; } + int currentSubtitleId() const { return m_currentSubtitleId; } + bool danmakuEnabled() const; + bool danmakuVisible() const; + bool danmakuLoading() const; + bool danmakuReady() const; + QString danmakuStatus() const; + bool switcherLoading() const { return m_switcherLoading; } + bool switcherSeriesMode() const { return m_switcherSeriesMode; } + QString switcherTitle() const { return m_switcherTitle; } + QVariantList switcherItems() const { return m_switcherItems; } + QVariantList switcherSeasons() const { return m_switcherSeasons; } + QString selectedSeasonId() const { return m_selectedSeasonId; } + QVariantList switcherEpisodes() const { return m_switcherEpisodes; } + QString currentMediaId() const { return m_mediaId; } + bool longPressEnabled() const; + QString longPressMode() const; + int longPressTriggerMs() const; + bool mouseEdgeLongPress() const; + double longPressSpeed() const { return m_longPressSpeed; } + + void setPendingRequest(const QString &mediaId, const QString &title, + const QString &streamUrl, long long startPositionTicks, + const QVariant &extraData); + void setActive(bool active); + bool hasActiveSession() const; + + // Bind the live MpvController (from MpvItem) + optional danmaku surface. + Q_INVOKABLE void bindMpv(QObject *mpvItem); + Q_INVOKABLE void bindDanmakuSurface(QObject *surfaceItem); + + Q_INVOKABLE void notifyLoaded(); + Q_INVOKABLE void notifyPositionChanged(double positionSeconds); + Q_INVOKABLE void notifyPausedChanged(bool paused); + Q_INVOKABLE void notifySeeked(); + Q_INVOKABLE void notifyError(const QString &message); + Q_INVOKABLE void notifyTracksChanged(); + // Called when mpv reaches EOF. Honors continuous-play: advances to the next + // item when possible, otherwise leaves the user on the ended player. + Q_INVOKABLE void notifyPlaybackEnded(const QString &reason = QString()); + Q_INVOKABLE void requestBack(); + Q_INVOKABLE QString formatTime(double seconds, double totalSeconds) const; + Q_INVOKABLE int seekStepSeconds() const; + Q_INVOKABLE bool clickToPause() const; + + Q_INVOKABLE void selectAudioTrack(int trackId); + Q_INVOKABLE void disableAudio(); + Q_INVOKABLE void selectSubtitleTrack(int trackId); + Q_INVOKABLE void disableSubtitles(); + + Q_INVOKABLE void setDanmakuEnabled(bool enabled); + Q_INVOKABLE void setDanmakuVisible(bool visible); + Q_INVOKABLE void reloadDanmaku(const QString &keyword = QString()); + Q_INVOKABLE void openDanmakuSettings(); + Q_INVOKABLE void openDanmakuIdentify(); + Q_INVOKABLE void openSubtitleSettings(); + Q_INVOKABLE void loadLocalDanmaku(const QString &filePath); + + Q_INVOKABLE void ensureSwitcherLoaded(); + Q_INVOKABLE void selectSeason(const QString &seasonId); + Q_INVOKABLE void playSwitcherItem(const QString &mediaId, + const QString &title, + double startPositionSeconds); + Q_INVOKABLE void playAdjacent(int direction); + + Q_INVOKABLE void beginLongPressSpeed(double baseSpeed); + Q_INVOKABLE void endLongPressSpeed(); + Q_INVOKABLE double nextLongPressSpeed(double current) const; + +signals: + void stateChanged(); + void tracksChanged(); + void danmakuChanged(); + void switcherChanged(); + void prefsChanged(); + void backRequested(); + void toastRequested(const QString &message); + void reloadMpvRequested(); + +private: + void beginPlayback(); + void resolveStream(const QVariant &extraData); + void startReporting(); + void reportProgress(); + void stopAndReport(); + void ensureDanmakuController(); + // Drop live QML surface / MpvController links without touching freed objects. + void unbindPlaybackSurfaces(); + void refreshTracksFromMpv(); + void rebuildSwitcherLists(); + QVariantMap itemToMap(const MediaItem &item) const; + QCoro::Task loadSwitcherTask(QString mediaId, QString seriesId, + bool seriesMode); + QCoro::Task switchToMediaTask(QString mediaId, QString title, + long long startTicks); + QCoro::Task autoPlayNextIfEnabled(); + bool findAdjacentTarget(int direction, QString *mediaId, QString *title, + long long *startTicks) const; + + QPointer m_core; + QWidget *m_dialogParent = nullptr; + QTimer *m_reportTimer = nullptr; + QPointer m_mpv; + // Raw interface pointer; lifetime tracked via m_surfaceObject destroyed. + IDanmakuSurface *m_danmakuSurface = nullptr; + QPointer m_surfaceObject; + PlayerDanmakuController *m_danmaku = nullptr; + + QString m_pendingMediaId; + QString m_pendingTitle; + QString m_pendingStreamUrl; + long long m_pendingStartTicks = 0; + QVariant m_pendingExtra; + bool m_hasPending = false; + + QString m_title; + QString m_streamUrl; + QString m_serverId; + QString m_mediaId; + QString m_mediaSourceId; + QString m_playSessionId; + QString m_seriesId; + QString m_seriesName; + long long m_startPositionTicks = 0; + double m_currentPositionSeconds = 0.0; + bool m_isPaused = false; + bool m_active = false; + bool m_sessionStartIssued = false; + bool m_hasReportedStop = false; + bool m_autoPlayAdvanceInProgress = false; + PlayerLaunchContext m_launchContext; + bool m_hasLaunchContext = false; + + QVariantList m_audioTracks; + QVariantList m_subtitleTracks; + int m_currentAudioId = -1; + int m_currentSubtitleId = -1; + + bool m_switcherLoading = false; + bool m_switcherSeriesMode = false; + bool m_switcherCacheReady = false; + QString m_switcherCacheMediaId; + QString m_switcherTitle; + QString m_selectedSeasonId; + QVariantList m_switcherItems; + QVariantList m_switcherSeasons; + QVariantList m_switcherEpisodes; + QList m_resumeItems; + QList m_seasons; + QHash> m_episodesBySeason; + quint64 m_switcherSerial = 0; + + double m_longPressSpeed = 2.0; + double m_longPressRestoreSpeed = 1.0; + bool m_longPressActive = false; +}; diff --git a/src/qEmbyApp/quick/quickpagehost.cpp b/src/qEmbyApp/quick/quickpagehost.cpp deleted file mode 100644 index 1f77bba..0000000 --- a/src/qEmbyApp/quick/quickpagehost.cpp +++ /dev/null @@ -1,73 +0,0 @@ -#include "quickpagehost.h" - -#include "../managers/thememanager.h" -#include "../tokens/ColorTokens.h" - -#include -#include -#include -#include - -#include - -QuickPageHost::QuickPageHost(QWidget *parent) : QWidget(parent) -{ - m_quickWidget = new QQuickWidget(this); - m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); - - // QQuickWidget 的透明 FBO 在部分窗口系统/显卡组合下会合成为黑色。 - // 使用与 Widgets 主题一致的不透明底色,避免页面出现黑底和文字边缘伪影。 - const auto updateClearColor = [this] - { - const bool dark = ThemeManager::instance()->isDarkMode(); - m_quickWidget->setClearColor(dark ? ColorTokens::Dark::kBgBase - : ColorTokens::Light::kBgBase); - }; - updateClearColor(); - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [updateClearColor](ThemeManager::Theme) { updateClearColor(); }); - - m_quickWidget->setFocusPolicy(Qt::StrongFocus); - setFocusProxy(m_quickWidget); - - connect(m_quickWidget, &QQuickWidget::statusChanged, this, - [this](QQuickWidget::Status status) - { - if (status != QQuickWidget::Error) - return; - const auto errors = m_quickWidget->errors(); - for (const auto &error : errors) - spdlog::error("[QuickPageHost] {}", error.toString().toStdString()); - }); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(m_quickWidget); -} - -void QuickPageHost::setContextProperty(const QString &name, QObject *object) -{ - m_quickWidget->rootContext()->setContextProperty(name, object); -} - -void QuickPageHost::addImageProvider(const QString &providerId, QQmlImageProviderBase *provider) -{ - m_quickWidget->engine()->addImageProvider(providerId, provider); -} - -void QuickPageHost::load(const QUrl &source) -{ - m_quickWidget->setSource(source); -} - -void QuickPageHost::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - emit pageShown(); -} - -void QuickPageHost::hideEvent(QHideEvent *event) -{ - QWidget::hideEvent(event); - emit pageHidden(); -} diff --git a/src/qEmbyApp/quick/quickpagehost.h b/src/qEmbyApp/quick/quickpagehost.h deleted file mode 100644 index 398d1df..0000000 --- a/src/qEmbyApp/quick/quickpagehost.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef QUICKPAGEHOST_H -#define QUICKPAGEHOST_H - -#include -#include - -class QQuickWidget; -class QQmlImageProviderBase; - -// Widgets 页面栈中承载 QML 页面的统一容器。 -// 负责 QQuickWidget 的透明背景、尺寸同步、加载错误上报, -// 并把 show/hide 转成信号供 ViewModel 控制轮询等激活逻辑。 -class QuickPageHost : public QWidget -{ - Q_OBJECT -public: - explicit QuickPageHost(QWidget *parent = nullptr); - - // 以下两个注入接口必须在 load() 之前调用 - void setContextProperty(const QString &name, QObject *object); - void addImageProvider(const QString &providerId, QQmlImageProviderBase *provider); - - void load(const QUrl &source); - - QQuickWidget *quickWidget() const { return m_quickWidget; } - -signals: - void pageShown(); - void pageHidden(); - -protected: - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - -private: - QQuickWidget *m_quickWidget; -}; - -#endif // QUICKPAGEHOST_H diff --git a/src/qEmbyApp/quick/quickrouterhost.cpp b/src/qEmbyApp/quick/quickrouterhost.cpp deleted file mode 100644 index b9dbbaf..0000000 --- a/src/qEmbyApp/quick/quickrouterhost.cpp +++ /dev/null @@ -1,164 +0,0 @@ -#include "quickrouterhost.h" - -#include "embyimageprovider.h" -#include "navigationcontroller.h" -#include "../managers/thememanager.h" -#include "../tokens/ColorTokens.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace -{ -const QUrl kRouterUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/Router.qml")); -} - -QuickRouterHost::QuickRouterHost(QEmbyCore *core, - NavigationController *navigation, - QWidget *parent) - : QWidget(parent), m_quickWidget(new QQuickWidget(this)) -{ - m_quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); - m_quickWidget->setFocusPolicy(Qt::StrongFocus); - setFocusProxy(m_quickWidget); - - const auto updateClearColor = [this] - { - const bool dark = ThemeManager::instance()->isDarkMode(); - m_quickWidget->setClearColor(dark ? ColorTokens::Dark::kBgBase - : ColorTokens::Light::kBgBase); - }; - updateClearColor(); - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [updateClearColor](ThemeManager::Theme) { updateClearColor(); }); - - m_quickWidget->rootContext()->setContextProperty( - QStringLiteral("navigation"), navigation); - registerImageProviders(core); - - connect(m_quickWidget, &QQuickWidget::statusChanged, this, - [this](QQuickWidget::Status status) - { - if (status == QQuickWidget::Ready) - { - setReady(true); - if (auto *root = m_quickWidget->rootObject()) - { - const int activeProperty = - root->metaObject()->indexOfProperty("active"); - if (activeProperty >= 0) - root->setProperty("active", m_active); - } - return; - } - - setReady(false); - if (status == QQuickWidget::Error) - reportLoadErrors(); - }); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - layout->addWidget(m_quickWidget); -} - -bool QuickRouterHost::setContextProperty(const QString &name, QObject *object) -{ - if (!m_quickWidget->source().isEmpty()) - { - spdlog::warn( - "[QuickRouterHost] refusing late context property injection: {}", - name.toStdString()); - return false; - } - - m_quickWidget->rootContext()->setContextProperty(name, object); - return true; -} - -void QuickRouterHost::loadRouter() -{ - if (m_quickWidget->source() == kRouterUrl && - m_quickWidget->status() == QQuickWidget::Ready) - { - return; - } - - setReady(false); - m_quickWidget->setSource(kRouterUrl); -} - -void QuickRouterHost::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - setActive(true); -} - -void QuickRouterHost::hideEvent(QHideEvent *event) -{ - QWidget::hideEvent(event); - setActive(false); -} - -void QuickRouterHost::registerImageProviders(QEmbyCore *core) -{ - if (m_imageProvidersRegistered) - return; - - m_quickWidget->engine()->addImageProvider( - QStringLiteral("emby"), new EmbyImageProvider(core)); - m_quickWidget->engine()->addImageProvider( - QStringLiteral("adaptiveicon"), new AdaptiveIconProvider()); - m_imageProvidersRegistered = true; -} - -void QuickRouterHost::setActive(bool active) -{ - if (m_active == active) - return; - - m_active = active; - if (auto *root = m_quickWidget->rootObject()) - { - const int activeProperty = root->metaObject()->indexOfProperty("active"); - if (activeProperty >= 0) - root->setProperty("active", active); - } - - emit activeChanged(active); - if (active) - emit activated(); - else - emit deactivated(); -} - -void QuickRouterHost::setReady(bool ready) -{ - if (m_ready == ready) - return; - - m_ready = ready; - emit readyChanged(ready); -} - -void QuickRouterHost::reportLoadErrors() -{ - QStringList messages; - const auto errors = m_quickWidget->errors(); - messages.reserve(errors.size()); - for (const auto &error : errors) - { - const QString message = error.toString(); - messages.append(message); - spdlog::error("[QuickRouterHost] {}", message.toStdString()); - } - emit loadFailed(messages); -} diff --git a/src/qEmbyApp/quick/quickrouterhost.h b/src/qEmbyApp/quick/quickrouterhost.h deleted file mode 100644 index ed0fa01..0000000 --- a/src/qEmbyApp/quick/quickrouterhost.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include - -class NavigationController; -class QEmbyCore; -class QQuickWidget; -class QShowEvent; -class QHideEvent; - -// Hosts the application-level QML Router in one QQuickWidget/QQmlEngine. -// Unlike QuickPageHost, this class owns the shared image-provider registration -// and exposes the host visibility as an application-router lifecycle. -class QuickRouterHost final : public QWidget -{ - Q_OBJECT - Q_PROPERTY(bool active READ isActive NOTIFY activeChanged) - Q_PROPERTY(bool ready READ isReady NOTIFY readyChanged) - -public: - explicit QuickRouterHost(QEmbyCore *core, - NavigationController *navigation, - QWidget *parent = nullptr); - - // Context properties must be installed before loadRouter() creates the - // root object. Returns false when the Router source has already been set. - bool setContextProperty(const QString &name, QObject *object); - void loadRouter(); - - QQuickWidget *quickWidget() const { return m_quickWidget; } - bool isActive() const { return m_active; } - bool isReady() const { return m_ready; } - -Q_SIGNALS: - void activated(); - void deactivated(); - void activeChanged(bool active); - void readyChanged(bool ready); - void loadFailed(const QStringList &errors); - -protected: - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - -private: - void registerImageProviders(QEmbyCore *core); - void setActive(bool active); - void setReady(bool ready); - void reportLoadErrors(); - - QQuickWidget *m_quickWidget = nullptr; - bool m_imageProvidersRegistered = false; - bool m_active = false; - bool m_ready = false; -}; diff --git a/src/qEmbyApp/quick/quicktheme.cpp b/src/qEmbyApp/quick/quicktheme.cpp index e74ae0e..3468eae 100644 --- a/src/qEmbyApp/quick/quicktheme.cpp +++ b/src/qEmbyApp/quick/quicktheme.cpp @@ -5,6 +5,9 @@ #include "../tokens/ColorTokens.h" #include "../tokens/DesignTokens.h" +#include "config/config_keys.h" +#include "config/configstore.h" + namespace { inline int scaledFont(int px) @@ -18,6 +21,19 @@ QuickTheme::QuickTheme(QObject *parent) : QObject(parent) { connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, &QuickTheme::themeChanged); + + // UiAnimations == true 表示「减少动画」(与 Widgets MotionPrefs 一致) + connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, + [this](const QString &key, const QVariant &) { + if (key == QLatin1String(ConfigKeys::UiAnimations) + || key == QLatin1String(ConfigKeys::FontSize)) { + if (key == QLatin1String(ConfigKeys::UiAnimations)) + emit motionChanged(); + // 字号变化时 font* 与主题侧需刷新 + if (key == QLatin1String(ConfigKeys::FontSize)) + emit themeChanged(); + } + }); } bool QuickTheme::dark() const @@ -25,6 +41,11 @@ bool QuickTheme::dark() const return ThemeManager::instance()->isDarkMode(); } +bool QuickTheme::reduceMotion() const +{ + return ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); +} + #define QUICKTHEME_SEMANTIC(getter, token) \ QColor QuickTheme::getter() const \ { \ @@ -70,8 +91,27 @@ int QuickTheme::fontTitle() const { return scaledFont(DesignTokens::FontSize::kT int QuickTheme::fontDisplay() const { return scaledFont(DesignTokens::FontSize::kDisplay); } int QuickTheme::fontHero() const { return scaledFont(DesignTokens::FontSize::kHero); } -int QuickTheme::animationFast() const { return AnimationTokens::DurationMs::kFast; } -int QuickTheme::animationNormal() const { return AnimationTokens::DurationMs::kNormal; } +// reduceMotion 时 duration=0 → Behavior 瞬间吸附,避免无效插值帧 +int QuickTheme::animationMicro() const +{ + return reduceMotion() ? 0 : AnimationTokens::DurationMs::kMicro; +} +int QuickTheme::animationQuick() const +{ + return reduceMotion() ? 0 : AnimationTokens::DurationMs::kQuick; +} +int QuickTheme::animationFast() const +{ + return reduceMotion() ? 0 : AnimationTokens::DurationMs::kFast; +} +int QuickTheme::animationNormal() const +{ + return reduceMotion() ? 0 : AnimationTokens::DurationMs::kNormal; +} +int QuickTheme::animationSlow() const +{ + return reduceMotion() ? 0 : AnimationTokens::DurationMs::kSlow; +} QColor QuickTheme::alpha(const QColor &color, qreal alpha) const { diff --git a/src/qEmbyApp/quick/quicktheme.h b/src/qEmbyApp/quick/quicktheme.h index 51f8adb..3b41fd4 100644 --- a/src/qEmbyApp/quick/quicktheme.h +++ b/src/qEmbyApp/quick/quicktheme.h @@ -56,14 +56,19 @@ class QuickTheme : public QObject Q_PROPERTY(int fontDisplay READ fontDisplay NOTIFY themeChanged) Q_PROPERTY(int fontHero READ fontHero NOTIFY themeChanged) - // 动画时长(AnimationTokens) - Q_PROPERTY(int animationFast READ animationFast CONSTANT) - Q_PROPERTY(int animationNormal READ animationNormal CONSTANT) + // 动画:reduceMotion 时所有 duration 返回 0,无限循环应读 reduceMotion 关掉 + Q_PROPERTY(bool reduceMotion READ reduceMotion NOTIFY motionChanged) + Q_PROPERTY(int animationMicro READ animationMicro NOTIFY motionChanged) + Q_PROPERTY(int animationQuick READ animationQuick NOTIFY motionChanged) + Q_PROPERTY(int animationFast READ animationFast NOTIFY motionChanged) + Q_PROPERTY(int animationNormal READ animationNormal NOTIFY motionChanged) + Q_PROPERTY(int animationSlow READ animationSlow NOTIFY motionChanged) public: explicit QuickTheme(QObject *parent = nullptr); bool dark() const; + bool reduceMotion() const; QColor bgBase() const; QColor bgSurface() const; @@ -101,13 +106,17 @@ public: int fontDisplay() const; int fontHero() const; + int animationMicro() const; + int animationQuick() const; int animationFast() const; int animationNormal() const; + int animationSlow() const; Q_INVOKABLE QColor alpha(const QColor &color, qreal alpha) const; signals: void themeChanged(); + void motionChanged(); }; #endif // QUICKTHEME_H diff --git a/src/qEmbyApp/quick/searchhistoryviewmodel.cpp b/src/qEmbyApp/quick/searchhistoryviewmodel.cpp new file mode 100644 index 0000000..ef54a6c --- /dev/null +++ b/src/qEmbyApp/quick/searchhistoryviewmodel.cpp @@ -0,0 +1,119 @@ +#include "searchhistoryviewmodel.h" + +#include "navigationcontroller.h" + +#include "../managers/searchhistorymanager.h" + +#include +#include + +SearchHistoryViewModel::SearchHistoryViewModel(QEmbyCore *core, + NavigationController *navigation, + QObject *parent) + : QObject(parent), m_core(core), m_navigation(navigation) +{ + setObjectName(QStringLiteral("search-history-viewmodel")); + + auto *manager = SearchHistoryManager::instance(); + + // The manager emits historyChanged with the *effective bucket*, not the raw + // serverId, so rather than reconstruct the bucket here we just re-read the + // current server's entries on any change. historyEntries() is cache-backed, + // so an occasional over-refresh is cheap. + connect(manager, &SearchHistoryManager::historyChanged, this, + [this] { refreshEntries(); }); + connect(manager, &SearchHistoryManager::enabledChanged, this, + [this] + { + emit enabledChanged(); + // Enabling/disabling flips whether historyEntries() returns + // anything, so the model must be re-pulled too. + refreshEntries(); + }); + connect(manager, &SearchHistoryManager::autocompleteEnabledChanged, this, + &SearchHistoryViewModel::autocompleteEnabledChanged); + + // History is server-scoped; switching servers changes the bucket. + if (m_core && m_core->serverManager()) + connect(m_core->serverManager(), &ServerManager::activeServerChanged, + this, [this] { refreshEntries(); }); + + refreshEntries(); +} + +bool SearchHistoryViewModel::enabled() const +{ + return SearchHistoryManager::instance()->isEnabled(); +} + +bool SearchHistoryViewModel::autocompleteEnabled() const +{ + return SearchHistoryManager::instance()->isAutocompleteEnabled(); +} + +QString SearchHistoryViewModel::currentServerId() const +{ + // Mirrors MainWindow::currentSearchServerId() so the QML shell records into + // the same per-server bucket the Widgets shell used — history is continuous + // across the migration. An empty id makes the manager fall back to its + // "__global__" bucket. + if (!m_core || !m_core->serverManager()) + return {}; + return m_core->serverManager()->activeProfile().id; +} + +void SearchHistoryViewModel::refreshEntries() +{ + m_entries.clear(); + const auto rows = + SearchHistoryManager::instance()->historyEntries(currentServerId()); + m_entries.reserve(rows.size()); + for (const auto &row : rows) + { + m_entries.append(QVariantMap{ + {QStringLiteral("term"), row.term}, + {QStringLiteral("count"), row.searchCount}, + {QStringLiteral("lastMs"), row.lastSearchedAtMs}, + }); + } + emit entriesChanged(); +} + +void SearchHistoryViewModel::recordSearch(const QString &query) +{ + // No-op / blank-skip is enforced inside the manager. + SearchHistoryManager::instance()->recordSearch(currentServerId(), query); +} + +void SearchHistoryViewModel::submit(const QString &query) +{ + const QString trimmed = query.trimmed(); + if (trimmed.isEmpty()) + return; + + // Record first (so the term is in history before the search page opens), + // then push the route — the same order as HomeView::triggerSearch. + SearchHistoryManager::instance()->recordSearch(currentServerId(), trimmed); + + if (m_navigation) + m_navigation->push(QVariantMap{ + {QStringLiteral("name"), QStringLiteral("search")}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("query"), trimmed}}}}); +} + +void SearchHistoryViewModel::clearHistory() +{ + SearchHistoryManager::instance()->clearHistory(currentServerId()); +} + +void SearchHistoryViewModel::removeTerm(const QString &term) +{ + SearchHistoryManager::instance()->removeHistoryTerm(currentServerId(), term); +} + +QStringList SearchHistoryViewModel::completions(const QString &prefix) const +{ + return SearchHistoryManager::instance()->completionSuggestions( + currentServerId(), prefix); +} diff --git a/src/qEmbyApp/quick/searchhistoryviewmodel.h b/src/qEmbyApp/quick/searchhistoryviewmodel.h new file mode 100644 index 0000000..673586b --- /dev/null +++ b/src/qEmbyApp/quick/searchhistoryviewmodel.h @@ -0,0 +1,82 @@ +#ifndef SEARCHHISTORYVIEWMODEL_H +#define SEARCHHISTORYVIEWMODEL_H + +#include +#include +#include +#include + +class QEmbyCore; +class NavigationController; + +// ===== QML-facing wrapper over the SearchHistoryManager C++ singleton ===== +// +// In the Widgets shell the global search box (MainWindow) recorded history and +// drove SearchHistoryPopup + a QCompleter directly against +// SearchHistoryManager. That manager is a plain QObject singleton with no +// Q_PROPERTY/Q_INVOKABLE surface, so the QML shell cannot bind to it. This +// ViewModel is the bridge: it owns the serverId derivation (activeProfile().id, +// mirroring MainWindow::currentSearchServerId), exposes the history entries as +// a bindable model, and turns the manager's plain signals into QML NOTIFYs. +// +// TitleBar.qml binds to `searchHistory`: +// - `entries` → the chip-flow popup model (term/count/lastMs) +// - `enabled` / `autocompleteEnabled` → feature-flag gates +// - submit()/recordSearch()/clearHistory()/removeTerm() → mutating actions +// - completions(prefix) → the autocomplete dropdown source +class SearchHistoryViewModel : public QObject +{ + Q_OBJECT + + // Whether the history feature (and, separately, autocomplete) is enabled. + // Both mirror ConfigStore flags and update live via the manager's signals. + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY(bool autocompleteEnabled READ autocompleteEnabled + NOTIFY autocompleteEnabledChanged) + + // The current server's history, newest-first, as a list of maps: + // { "term": QString, "count": int, "lastMs": qint64 } + // Refreshed whenever the active server or the underlying history changes. + Q_PROPERTY(QVariantList entries READ entries NOTIFY entriesChanged) + +public: + SearchHistoryViewModel(QEmbyCore *core, NavigationController *navigation, + QObject *parent = nullptr); + + bool enabled() const; + bool autocompleteEnabled() const; + QVariantList entries() const { return m_entries; } + + // Record `query` in history (no-op when disabled/blank) then push the + // `search` route. This is the single entry point the search box's + // returnPressed / popup-chip activation calls, mirroring + // HomeView::triggerSearch (record + push) in the Widgets shell. + Q_INVOKABLE void submit(const QString &query); + + // Record without navigating (rarely needed on its own; submit() covers the + // common path). Kept for parity with the manager API. + Q_INVOKABLE void recordSearch(const QString &query); + + // Popup actions, forwarded to the manager for the current server bucket. + Q_INVOKABLE void clearHistory(); + Q_INVOKABLE void removeTerm(const QString &term); + + // Autocomplete source for the current server; returns {} unless + // autocomplete is enabled (the manager enforces this). + Q_INVOKABLE QStringList completions(const QString &prefix) const; + +Q_SIGNALS: + void enabledChanged(); + void autocompleteEnabledChanged(); + void entriesChanged(); + +private: + QString currentServerId() const; + void refreshEntries(); + + QEmbyCore *m_core = nullptr; + NavigationController *m_navigation = nullptr; + QVariantList m_entries; +}; + +#endif // SEARCHHISTORYVIEWMODEL_H diff --git a/src/qEmbyApp/quick/settingsviewmodel.cpp b/src/qEmbyApp/quick/settingsviewmodel.cpp index 621696c..aa34a57 100644 --- a/src/qEmbyApp/quick/settingsviewmodel.cpp +++ b/src/qEmbyApp/quick/settingsviewmodel.cpp @@ -1,33 +1,78 @@ #include "settingsviewmodel.h" +#include "../components/moderntoast.h" +#include "../components/proxysettingsdialog.h" +#include "../utils/dashboardsectionorderutils.h" + #include "config/config_keys.h" #include "config/configstore.h" +#include "services/danmaku/danmakusettings.h" +#include "services/media/mediaservice.h" +#include "services/manager/servermanager.h" + +#include + +#include +#include +#include +#include +#include +#include // ===== SettingsViewModel implementation ===== // Constructor connects ConfigStore::valueChanged → dispatcher that emits // the correct per-group signal based on key prefix. -SettingsViewModel::SettingsViewModel(QObject *parent) - : QObject(parent), m_store(ConfigStore::instance()) +SettingsViewModel::SettingsViewModel(QEmbyCore *core, QWidget *dialogParent, + QObject *parent) + : QObject(parent) + , m_store(ConfigStore::instance()) + , m_core(core) + , m_dialogParent(dialogParent) { connect(m_store, &ConfigStore::valueChanged, this, &SettingsViewModel::onValueChanged); } +QString SettingsViewModel::activeServerId() const +{ + return m_store->get(ConfigKeys::LastSelectedServerId).trimmed(); +} + +QString SettingsViewModel::serverScopedKey(const char *baseKey) const +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty()) + return QString::fromLatin1(baseKey); + return ConfigKeys::forServer(serverId, baseKey); +} + // ── Dispatcher ────────────────────────────────────────────────────── void SettingsViewModel::onValueChanged(const QString &key, const QVariant & /*newValue*/) { - if (key.startsWith("general/")) { + // Global prefixes and server-scoped mirrors (server//library/..., + // server//danmaku/...). + if (key.startsWith(QLatin1String("general/")) || + key.endsWith(QLatin1String("/general/language"))) { emit generalChanged(); - } else if (key.startsWith("network/")) { + } else if (key.startsWith(QLatin1String("network/")) || + key.contains(QLatin1String("/network/"))) { emit networkChanged(); - } else if (key.startsWith("appearance/")) { + } else if (key.startsWith(QLatin1String("appearance/")) || + key.contains(QLatin1String("/appearance/"))) { emit appearanceChanged(); - } else if (key.startsWith("library/") || key.startsWith("cache/")) { + } else if (key.startsWith(QLatin1String("library/")) || + key.startsWith(QLatin1String("cache/")) || + key.contains(QLatin1String("/library/")) || + key.contains(QLatin1String("/cache/"))) { emit libraryChanged(); - } else if (key.startsWith("player/") || key.startsWith("ext_player/") - || key.startsWith("danmaku/")) { + } else if (key.startsWith(QLatin1String("player/")) || + key.startsWith(QLatin1String("ext_player/")) || + key.startsWith(QLatin1String("danmaku/")) || + key.contains(QLatin1String("/player/")) || + key.contains(QLatin1String("/ext_player/")) || + key.contains(QLatin1String("/danmaku/"))) { emit playerChanged(); } } @@ -319,25 +364,35 @@ void SettingsViewModel::setSearchAutocompleteEnabled(bool v) } // ── Library ───────────────────────────────────────────────────────── +// Dashboard reads these via ConfigKeys::forServer(activeServer, ...). +// Fall back to the global key so older installs keep their values. bool SettingsViewModel::showRecommended() const { - return m_store->get(ConfigKeys::ShowRecommended, true); + return m_store->get( + serverScopedKey(ConfigKeys::ShowRecommended), + m_store->get(ConfigKeys::ShowRecommended, true)); } bool SettingsViewModel::showContinueWatching() const { - return m_store->get(ConfigKeys::ShowContinueWatching, true); + return m_store->get( + serverScopedKey(ConfigKeys::ShowContinueWatching), + m_store->get(ConfigKeys::ShowContinueWatching, true)); } bool SettingsViewModel::showLatestAdded() const { - return m_store->get(ConfigKeys::ShowLatestAdded, true); + return m_store->get( + serverScopedKey(ConfigKeys::ShowLatestAdded), + m_store->get(ConfigKeys::ShowLatestAdded, true)); } bool SettingsViewModel::showCompletedWatching() const { - return m_store->get(ConfigKeys::ShowCompletedWatching, false); + return m_store->get( + serverScopedKey(ConfigKeys::ShowCompletedWatching), + m_store->get(ConfigKeys::ShowCompletedWatching, false)); } int SettingsViewModel::continueWatchingRequestLimit() const @@ -362,12 +417,16 @@ int SettingsViewModel::completedWatchingRequestLimit() const bool SettingsViewModel::showMediaLibraries() const { - return m_store->get(ConfigKeys::ShowMediaLibraries, true); + return m_store->get( + serverScopedKey(ConfigKeys::ShowMediaLibraries), + m_store->get(ConfigKeys::ShowMediaLibraries, true)); } bool SettingsViewModel::showEachLibrary() const { - return m_store->get(ConfigKeys::ShowEachLibrary, true); + return m_store->get( + serverScopedKey(ConfigKeys::ShowEachLibrary), + m_store->get(ConfigKeys::ShowEachLibrary, true)); } bool SettingsViewModel::showFavoriteFolders() const @@ -405,24 +464,65 @@ QString SettingsViewModel::imageCacheDuration() const return m_store->get(ConfigKeys::ImageCacheDuration, "7"); } +bool SettingsViewModel::customHomeSectionOrderEnabled() const +{ + return m_store->get( + serverScopedKey(ConfigKeys::CustomHomeSectionOrderEnabled), false); +} + +QString SettingsViewModel::homeSectionTitle(const QString §ionId) const +{ + if (sectionId == QLatin1String(DashboardSectionOrderUtils::ContinueWatchingSectionId)) + return tr("Continue Watching"); + if (sectionId == QLatin1String(DashboardSectionOrderUtils::LatestMediaSectionId)) + return tr("Latest Added"); + if (sectionId == QLatin1String(DashboardSectionOrderUtils::RecommendedSectionId)) + return tr("Recommended"); + if (sectionId == QLatin1String(DashboardSectionOrderUtils::CompletedWatchingSectionId)) + return tr("Completed Watching"); + if (sectionId == QLatin1String(DashboardSectionOrderUtils::AllLibrariesSectionId)) + return tr("Media Libraries"); + if (sectionId == QLatin1String(DashboardSectionOrderUtils::EachLibrarySectionsSectionId)) + return tr("Each Library"); + return sectionId; +} + +QVariantList SettingsViewModel::homeSectionOrder() const +{ + const QStringList order = DashboardSectionOrderUtils::normalizeSectionOrder( + m_store->get( + serverScopedKey(ConfigKeys::HomeSectionOrder), + DashboardSectionOrderUtils::defaultSectionOrder())); + + QVariantList list; + list.reserve(order.size()); + for (const QString §ionId : order) { + QVariantMap row; + row.insert(QStringLiteral("id"), sectionId); + row.insert(QStringLiteral("title"), homeSectionTitle(sectionId)); + list.append(row); + } + return list; +} + void SettingsViewModel::setShowRecommended(bool v) { - m_store->set(ConfigKeys::ShowRecommended, v); + m_store->set(serverScopedKey(ConfigKeys::ShowRecommended), v); } void SettingsViewModel::setShowContinueWatching(bool v) { - m_store->set(ConfigKeys::ShowContinueWatching, v); + m_store->set(serverScopedKey(ConfigKeys::ShowContinueWatching), v); } void SettingsViewModel::setShowLatestAdded(bool v) { - m_store->set(ConfigKeys::ShowLatestAdded, v); + m_store->set(serverScopedKey(ConfigKeys::ShowLatestAdded), v); } void SettingsViewModel::setShowCompletedWatching(bool v) { - m_store->set(ConfigKeys::ShowCompletedWatching, v); + m_store->set(serverScopedKey(ConfigKeys::ShowCompletedWatching), v); } void SettingsViewModel::setContinueWatchingRequestLimit(int v) @@ -447,12 +547,12 @@ void SettingsViewModel::setCompletedWatchingRequestLimit(int v) void SettingsViewModel::setShowMediaLibraries(bool v) { - m_store->set(ConfigKeys::ShowMediaLibraries, v); + m_store->set(serverScopedKey(ConfigKeys::ShowMediaLibraries), v); } void SettingsViewModel::setShowEachLibrary(bool v) { - m_store->set(ConfigKeys::ShowEachLibrary, v); + m_store->set(serverScopedKey(ConfigKeys::ShowEachLibrary), v); } void SettingsViewModel::setShowFavoriteFolders(bool v) @@ -485,6 +585,100 @@ void SettingsViewModel::setDataCacheDuration(const QString &v) m_store->set(ConfigKeys::DataCacheDuration, v); } +void SettingsViewModel::setCustomHomeSectionOrderEnabled(bool v) +{ + m_store->set(serverScopedKey(ConfigKeys::CustomHomeSectionOrderEnabled), v); + // Ensure an order is stored when enabling for the first time. + if (v) { + const QString key = serverScopedKey(ConfigKeys::HomeSectionOrder); + const QStringList existing = m_store->get(key, {}); + if (existing.isEmpty()) { + m_store->set(key, DashboardSectionOrderUtils::defaultSectionOrder()); + } + } +} + +void SettingsViewModel::moveHomeSection(int from, int to) +{ + QStringList order = DashboardSectionOrderUtils::normalizeSectionOrder( + m_store->get( + serverScopedKey(ConfigKeys::HomeSectionOrder), + DashboardSectionOrderUtils::defaultSectionOrder())); + if (from < 0 || to < 0 || from >= order.size() || to >= order.size() || + from == to) { + return; + } + order.move(from, to); + m_store->set(serverScopedKey(ConfigKeys::HomeSectionOrder), + DashboardSectionOrderUtils::normalizeSectionOrder(order)); + if (!customHomeSectionOrderEnabled()) + setCustomHomeSectionOrderEnabled(true); +} + +void SettingsViewModel::resetHomeSectionOrder() +{ + m_store->set(serverScopedKey(ConfigKeys::HomeSectionOrder), + DashboardSectionOrderUtils::defaultSectionOrder()); +} + +void SettingsViewModel::clearRecommendCache() +{ + if (!m_core || !m_core->mediaService()) { + emit toastRequested(tr("Not connected to a server")); + return; + } + m_core->mediaService()->clearRecommendCache(); + emit toastRequested(tr("Recommendation cache cleared")); +} + +void SettingsViewModel::clearImageCache() +{ + // Match MediaService disk cache directory. + const QString cachePath = + QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/qEmby_ImageCache"); + QDir dir(cachePath); + qint64 freedHint = 0; + if (dir.exists()) { + const auto entries = + dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + for (const QFileInfo &info : entries) + freedHint += info.isFile() ? info.size() : 0; + if (!dir.removeRecursively()) { + emit toastRequested(tr("Failed to clear image cache")); + return; + } + QDir().mkpath(cachePath); + } + QPixmapCache::clear(); + Q_UNUSED(freedHint); + emit toastRequested(tr("Image cache cleared")); +} + +void SettingsViewModel::openProxySettings() +{ + if (!m_core || !m_core->serverManager()) { + emit toastRequested(tr("Not connected to a server")); + return; + } + + const QString serverId = activeServerId(); + ProxySettingsDialog *dialog = nullptr; + if (!serverId.isEmpty()) { + dialog = ProxySettingsDialog::createForServer( + m_core->serverManager(), serverId, m_dialogParent); + } else { + dialog = ProxySettingsDialog::createForGlobal(m_dialogParent); + } + if (!dialog) { + emit toastRequested(tr("Unable to open proxy settings")); + return; + } + dialog->setAttribute(Qt::WA_DeleteOnClose, true); + // Modal exec matches LoginViewModel::configureProxy. + dialog->exec(); +} + void SettingsViewModel::setImageCacheDuration(const QString &v) { m_store->set(ConfigKeys::ImageCacheDuration, v); @@ -976,46 +1170,271 @@ void SettingsViewModel::setDanmakuDualSubtitle(bool v) m_store->set(ConfigKeys::PlayerDanmakuDualSubtitle, v); } -// ── Player — danmaku provider ─────────────────────────────────────── +QString SettingsViewModel::danmakuSourceMode() const +{ + return m_store->get( + serverScopedKey(ConfigKeys::DanmakuSourceMode), + m_store->get(ConfigKeys::DanmakuSourceMode, + QStringLiteral("prefer-online"))); +} + +bool SettingsViewModel::danmakuAutoLoad() const +{ + return m_store->get( + serverScopedKey(ConfigKeys::DanmakuAutoLoad), + m_store->get(ConfigKeys::DanmakuAutoLoad, true)); +} + +bool SettingsViewModel::danmakuAutoMatch() const +{ + return m_store->get( + serverScopedKey(ConfigKeys::DanmakuAutoMatch), + m_store->get(ConfigKeys::DanmakuAutoMatch, true)); +} + +bool SettingsViewModel::danmakuWithRelated() const +{ + return m_store->get( + serverScopedKey(ConfigKeys::DanmakuWithRelated), + m_store->get(ConfigKeys::DanmakuWithRelated, true)); +} + +QString SettingsViewModel::danmakuCacheHours() const +{ + const int hours = m_store->get( + serverScopedKey(ConfigKeys::DanmakuCacheHours), + m_store->get(ConfigKeys::DanmakuCacheHours, 24)); + return QString::number(hours); +} + +void SettingsViewModel::setDanmakuSourceMode(const QString &v) +{ + m_store->set(serverScopedKey(ConfigKeys::DanmakuSourceMode), v); +} + +void SettingsViewModel::setDanmakuAutoLoad(bool v) +{ + m_store->set(serverScopedKey(ConfigKeys::DanmakuAutoLoad), v); +} + +void SettingsViewModel::setDanmakuAutoMatch(bool v) +{ + m_store->set(serverScopedKey(ConfigKeys::DanmakuAutoMatch), v); +} + +void SettingsViewModel::setDanmakuWithRelated(bool v) +{ + m_store->set(serverScopedKey(ConfigKeys::DanmakuWithRelated), v); +} + +void SettingsViewModel::setDanmakuCacheHours(const QString &v) +{ + bool ok = false; + const int hours = v.toInt(&ok); + m_store->set(serverScopedKey(ConfigKeys::DanmakuCacheHours), + ok ? hours : 24); +} + +// ── Player — danmaku provider / servers ───────────────────────────── + +void SettingsViewModel::persistDanmakuServers( + const QList &servers, const QString &selectedId) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty()) + return; + DanmakuSettings::saveServers(serverId, servers, selectedId); + emit playerChanged(); +} QString SettingsViewModel::danmakuProvider() const { - return m_store->get(ConfigKeys::DanmakuProvider); + const DanmakuServerDefinition selected = + DanmakuSettings::selectedServer(activeServerId()); + return selected.provider; } QString SettingsViewModel::danmakuProviderBaseUrl() const { - return m_store->get(ConfigKeys::DanmakuProviderBaseUrl); + return DanmakuSettings::selectedServer(activeServerId()).baseUrl; } QString SettingsViewModel::danmakuProviderAppId() const { - return m_store->get(ConfigKeys::DanmakuProviderAppId); + return DanmakuSettings::selectedServer(activeServerId()).appId; } QString SettingsViewModel::danmakuProviderAppSecret() const { - return m_store->get(ConfigKeys::DanmakuProviderAppSecret); + return DanmakuSettings::selectedServer(activeServerId()).appSecret; +} + +QVariantList SettingsViewModel::danmakuServers() const +{ + const QString serverId = activeServerId(); + const QList servers = + DanmakuSettings::loadServers(serverId); + const QString selectedId = DanmakuSettings::selectedServerId(serverId); + + QVariantList list; + list.reserve(servers.size()); + for (const DanmakuServerDefinition &server : servers) { + QVariantMap row; + row.insert(QStringLiteral("id"), server.id); + row.insert(QStringLiteral("name"), server.displayName()); + row.insert(QStringLiteral("baseUrl"), server.baseUrl); + row.insert(QStringLiteral("description"), server.description); + row.insert(QStringLiteral("appId"), server.appId); + row.insert(QStringLiteral("appSecret"), server.appSecret); + row.insert(QStringLiteral("builtIn"), server.builtIn); + row.insert(QStringLiteral("enabled"), server.enabled); + row.insert(QStringLiteral("selected"), server.id == selectedId); + list.append(row); + } + return list; +} + +QString SettingsViewModel::selectedDanmakuServerId() const +{ + return DanmakuSettings::selectedServerId(activeServerId()); } void SettingsViewModel::setDanmakuProvider(const QString &v) { - m_store->set(ConfigKeys::DanmakuProvider, v); + m_store->set(serverScopedKey(ConfigKeys::DanmakuProvider), v); } void SettingsViewModel::setDanmakuProviderBaseUrl(const QString &v) { - m_store->set(ConfigKeys::DanmakuProviderBaseUrl, v); + m_store->set(serverScopedKey(ConfigKeys::DanmakuProviderBaseUrl), v); } void SettingsViewModel::setDanmakuProviderAppId(const QString &v) { - m_store->set(ConfigKeys::DanmakuProviderAppId, v); + m_store->set(serverScopedKey(ConfigKeys::DanmakuProviderAppId), v); } void SettingsViewModel::setDanmakuProviderAppSecret(const QString &v) { - m_store->set(ConfigKeys::DanmakuProviderAppSecret, v); + m_store->set(serverScopedKey(ConfigKeys::DanmakuProviderAppSecret), v); +} + +void SettingsViewModel::setSelectedDanmakuServerId(const QString &id) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty() || id.trimmed().isEmpty()) + return; + QList servers = + DanmakuSettings::loadServers(serverId); + for (DanmakuServerDefinition &server : servers) { + if (server.id == id) { + server.enabled = true; + break; + } + } + persistDanmakuServers(servers, id); +} + +void SettingsViewModel::setDanmakuServerEnabled(const QString &id, bool enabled) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty() || id.trimmed().isEmpty()) + return; + QList servers = + DanmakuSettings::loadServers(serverId); + QString selectedId = DanmakuSettings::selectedServerId(serverId); + for (DanmakuServerDefinition &server : servers) { + if (server.id == id) { + server.enabled = enabled; + break; + } + } + if (!enabled && selectedId == id) + selectedId.clear(); + persistDanmakuServers(servers, selectedId); +} + +void SettingsViewModel::addDanmakuServer(const QString &name, + const QString &baseUrl, + const QString &appId, + const QString &appSecret, + const QString &description) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty() || baseUrl.trimmed().isEmpty()) + return; + + QList servers = + DanmakuSettings::loadServers(serverId); + DanmakuServerDefinition created; + created.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + created.name = name.trimmed(); + created.baseUrl = baseUrl.trimmed(); + created.appId = appId.trimmed(); + created.appSecret = appSecret.trimmed(); + created.description = description.trimmed(); + created.provider = QStringLiteral("dandanplay"); + created.enabled = true; + created.builtIn = false; + if (created.name.isEmpty()) + created.name = created.baseUrl; + servers.append(created); + persistDanmakuServers(servers, created.id); +} + +void SettingsViewModel::updateDanmakuServer(const QString &id, + const QString &name, + const QString &baseUrl, + const QString &appId, + const QString &appSecret, + const QString &description) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty() || id.trimmed().isEmpty()) + return; + + QList servers = + DanmakuSettings::loadServers(serverId); + for (DanmakuServerDefinition &server : servers) { + if (server.id != id) + continue; + if (server.builtIn) { + // Built-in official endpoint only allows enable/select. + break; + } + if (!name.trimmed().isEmpty()) + server.name = name.trimmed(); + if (!baseUrl.trimmed().isEmpty()) + server.baseUrl = baseUrl.trimmed(); + server.appId = appId.trimmed(); + server.appSecret = appSecret.trimmed(); + server.description = description.trimmed(); + break; + } + persistDanmakuServers(servers, + DanmakuSettings::selectedServerId(serverId)); +} + +void SettingsViewModel::removeDanmakuServer(const QString &id) +{ + const QString serverId = activeServerId(); + if (serverId.isEmpty() || id.trimmed().isEmpty()) + return; + + QList servers = + DanmakuSettings::loadServers(serverId); + QString selectedId = DanmakuSettings::selectedServerId(serverId); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).id != id) + continue; + if (servers.at(i).builtIn) + return; + servers.removeAt(i); + break; + } + if (selectedId == id) + selectedId.clear(); + persistDanmakuServers(servers, selectedId); } // ── Player — external ─────────────────────────────────────────────── diff --git a/src/qEmbyApp/quick/settingsviewmodel.h b/src/qEmbyApp/quick/settingsviewmodel.h index ba454de..73280e4 100644 --- a/src/qEmbyApp/quick/settingsviewmodel.h +++ b/src/qEmbyApp/quick/settingsviewmodel.h @@ -3,8 +3,14 @@ #include #include +#include #include +struct DanmakuServerDefinition; +class QEmbyCore; +class QWidget; +class ConfigStore; + // ===== QML-facing ViewModel for every global ConfigStore setting ===== // Groups settings into 5 categories with per-group NOTIFY signals. // QML binds to properties; C++ setters delegate to ConfigStore::instance()->set(). @@ -68,6 +74,9 @@ class SettingsViewModel : public QObject Q_PROPERTY(bool showMediaTooltips READ showMediaTooltips WRITE setShowMediaTooltips NOTIFY libraryChanged) Q_PROPERTY(QString dataCacheDuration READ dataCacheDuration WRITE setDataCacheDuration NOTIFY libraryChanged) Q_PROPERTY(QString imageCacheDuration READ imageCacheDuration WRITE setImageCacheDuration NOTIFY libraryChanged) + Q_PROPERTY(bool customHomeSectionOrderEnabled READ customHomeSectionOrderEnabled WRITE setCustomHomeSectionOrderEnabled NOTIFY libraryChanged) + // [{id, title}, ...] — order used when customHomeSectionOrderEnabled is true + Q_PROPERTY(QVariantList homeSectionOrder READ homeSectionOrder NOTIFY libraryChanged) // ── Player — internal ──────────────────────────────────────────── Q_PROPERTY(QString hwDecoding READ hwDecoding WRITE setHwDecoding NOTIFY playerChanged) @@ -122,12 +131,20 @@ class SettingsViewModel : public QObject Q_PROPERTY(bool danmakuHideBottom READ danmakuHideBottom WRITE setDanmakuHideBottom NOTIFY playerChanged) Q_PROPERTY(QString danmakuBlockedKeywords READ danmakuBlockedKeywords WRITE setDanmakuBlockedKeywords NOTIFY playerChanged) Q_PROPERTY(bool danmakuDualSubtitle READ danmakuDualSubtitle WRITE setDanmakuDualSubtitle NOTIFY playerChanged) + Q_PROPERTY(QString danmakuSourceMode READ danmakuSourceMode WRITE setDanmakuSourceMode NOTIFY playerChanged) + Q_PROPERTY(bool danmakuAutoLoad READ danmakuAutoLoad WRITE setDanmakuAutoLoad NOTIFY playerChanged) + Q_PROPERTY(bool danmakuAutoMatch READ danmakuAutoMatch WRITE setDanmakuAutoMatch NOTIFY playerChanged) + Q_PROPERTY(bool danmakuWithRelated READ danmakuWithRelated WRITE setDanmakuWithRelated NOTIFY playerChanged) + Q_PROPERTY(QString danmakuCacheHours READ danmakuCacheHours WRITE setDanmakuCacheHours NOTIFY playerChanged) // ── Player — danmaku provider ──────────────────────────────────── Q_PROPERTY(QString danmakuProvider READ danmakuProvider WRITE setDanmakuProvider NOTIFY playerChanged) Q_PROPERTY(QString danmakuProviderBaseUrl READ danmakuProviderBaseUrl WRITE setDanmakuProviderBaseUrl NOTIFY playerChanged) Q_PROPERTY(QString danmakuProviderAppId READ danmakuProviderAppId WRITE setDanmakuProviderAppId NOTIFY playerChanged) Q_PROPERTY(QString danmakuProviderAppSecret READ danmakuProviderAppSecret WRITE setDanmakuProviderAppSecret NOTIFY playerChanged) + // [{id,name,baseUrl,description,appId,appSecret,builtIn,enabled,selected}, ...] + Q_PROPERTY(QVariantList danmakuServers READ danmakuServers NOTIFY playerChanged) + Q_PROPERTY(QString selectedDanmakuServerId READ selectedDanmakuServerId WRITE setSelectedDanmakuServerId NOTIFY playerChanged) // ── Player — external ──────────────────────────────────────────── Q_PROPERTY(bool extPlayerEnable READ extPlayerEnable WRITE setExtPlayerEnable NOTIFY playerChanged) @@ -138,7 +155,11 @@ class SettingsViewModel : public QObject Q_PROPERTY(QString extPlayerUrlReplace READ extPlayerUrlReplace WRITE setExtPlayerUrlReplace NOTIFY playerChanged) public: - explicit SettingsViewModel(QObject *parent = nullptr); + // core + dialogParent enable cache/proxy actions from the settings UI. + // Either may be null (properties still work via ConfigStore alone). + explicit SettingsViewModel(QEmbyCore *core = nullptr, + QWidget *dialogParent = nullptr, + QObject *parent = nullptr); // ── General getters ────────────────────────────────────────────── QString language() const; @@ -192,6 +213,8 @@ public: bool showMediaTooltips() const; QString dataCacheDuration() const; QString imageCacheDuration() const; + bool customHomeSectionOrderEnabled() const; + QVariantList homeSectionOrder() const; // ── Player — internal getters ──────────────────────────────────── QString hwDecoding() const; @@ -246,12 +269,19 @@ public: bool danmakuHideBottom() const; QString danmakuBlockedKeywords() const; bool danmakuDualSubtitle() const; + QString danmakuSourceMode() const; + bool danmakuAutoLoad() const; + bool danmakuAutoMatch() const; + bool danmakuWithRelated() const; + QString danmakuCacheHours() const; // ── Player — danmaku provider getters ──────────────────────────── QString danmakuProvider() const; QString danmakuProviderBaseUrl() const; QString danmakuProviderAppId() const; QString danmakuProviderAppSecret() const; + QVariantList danmakuServers() const; + QString selectedDanmakuServerId() const; // ── Player — external getters ──────────────────────────────────── bool extPlayerEnable() const; @@ -314,6 +344,12 @@ public: Q_INVOKABLE void setShowMediaTooltips(bool v); Q_INVOKABLE void setDataCacheDuration(const QString &v); Q_INVOKABLE void setImageCacheDuration(const QString &v); + Q_INVOKABLE void setCustomHomeSectionOrderEnabled(bool v); + Q_INVOKABLE void moveHomeSection(int from, int to); + Q_INVOKABLE void resetHomeSectionOrder(); + Q_INVOKABLE void clearRecommendCache(); + Q_INVOKABLE void clearImageCache(); + Q_INVOKABLE void openProxySettings(); // Player — internal Q_INVOKABLE void setHwDecoding(const QString &v); @@ -368,12 +404,27 @@ public: Q_INVOKABLE void setDanmakuHideBottom(bool v); Q_INVOKABLE void setDanmakuBlockedKeywords(const QString &v); Q_INVOKABLE void setDanmakuDualSubtitle(bool v); + Q_INVOKABLE void setDanmakuSourceMode(const QString &v); + Q_INVOKABLE void setDanmakuAutoLoad(bool v); + Q_INVOKABLE void setDanmakuAutoMatch(bool v); + Q_INVOKABLE void setDanmakuWithRelated(bool v); + Q_INVOKABLE void setDanmakuCacheHours(const QString &v); // Player — danmaku provider Q_INVOKABLE void setDanmakuProvider(const QString &v); Q_INVOKABLE void setDanmakuProviderBaseUrl(const QString &v); Q_INVOKABLE void setDanmakuProviderAppId(const QString &v); Q_INVOKABLE void setDanmakuProviderAppSecret(const QString &v); + Q_INVOKABLE void setSelectedDanmakuServerId(const QString &id); + Q_INVOKABLE void setDanmakuServerEnabled(const QString &id, bool enabled); + Q_INVOKABLE void addDanmakuServer(const QString &name, const QString &baseUrl, + const QString &appId, const QString &appSecret, + const QString &description = QString()); + Q_INVOKABLE void updateDanmakuServer(const QString &id, const QString &name, + const QString &baseUrl, const QString &appId, + const QString &appSecret, + const QString &description = QString()); + Q_INVOKABLE void removeDanmakuServer(const QString &id); // Player — external Q_INVOKABLE void setExtPlayerEnable(bool v); @@ -389,12 +440,21 @@ signals: void appearanceChanged(); void libraryChanged(); void playerChanged(); + void toastRequested(const QString &message); private slots: void onValueChanged(const QString &key, const QVariant &newValue); private: - class ConfigStore *m_store; + QString activeServerId() const; + QString serverScopedKey(const char *baseKey) const; + QString homeSectionTitle(const QString §ionId) const; + void persistDanmakuServers(const QList &servers, + const QString &selectedId); + + ConfigStore *m_store = nullptr; + QEmbyCore *m_core = nullptr; + QWidget *m_dialogParent = nullptr; }; #endif // SETTINGSVIEWMODEL_H diff --git a/src/qEmbyApp/quick/shelllifecyclecontroller.cpp b/src/qEmbyApp/quick/shelllifecyclecontroller.cpp new file mode 100644 index 0000000..dc936c4 --- /dev/null +++ b/src/qEmbyApp/quick/shelllifecyclecontroller.cpp @@ -0,0 +1,160 @@ +#include "shelllifecyclecontroller.h" + +#include "playerviewmodel.h" + +#include "../managers/playbackmanager.h" +#include "../managers/thememanager.h" +#include "../managers/traymanager.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +ShellLifecycleController::ShellLifecycleController(QEmbyCore *core, + QObject *parent) + : QObject(parent), m_core(core) +{ + // Tray integration. The Widgets shell wired the tray in MainWindow's ctor + // (:89-106): show restores + raises the window; quit runs the graceful + // quit sequence. In-shell player pause-on-tray is deferred until the + // embedded player exposes a tray-pause API. + m_trayManager = new TrayManager(this); + connect(m_trayManager, &TrayManager::showMainRequested, this, + [this] + { + if (!m_window) + return; + m_window->showNormal(); + m_window->raise(); + m_window->requestActivate(); + }); + connect(m_trayManager, &TrayManager::quitRequested, this, + &ShellLifecycleController::requestApplicationQuit); + + // Theme: QML shell only writes ConfigStore (TitleBar / SettingsAppearance); + // MainWindow used to bridge that into ThemeManager. Without this, Theme.dark + // never flips and day/night switch appears broken. + initThemeBridge(); +} + +void ShellLifecycleController::initThemeBridge() +{ + auto *store = ConfigStore::instance(); + auto *theme = ThemeManager::instance(); + + const QString savedMode = + store->get(ConfigKeys::ThemeMode, QStringLiteral("system")); + theme->applyThemeMode(savedMode); + + connect(store, &ConfigStore::valueChanged, this, + [theme](const QString &key, const QVariant &newValue) + { + if (key == QLatin1String(ConfigKeys::ThemeMode)) + { + theme->applyThemeMode(newValue.toString()); + return; + } + // Font size is baked into the QSS scale path and into + // QuickTheme::font* via ThemeManager::fontScaleFactor(). + // Re-emit the current theme so both update immediately. + if (key == QLatin1String(ConfigKeys::FontSize)) + theme->setTheme(theme->currentTheme()); + }); +} + +void ShellLifecycleController::setWindow(QQuickWindow *window) +{ + m_window = window; +} + +void ShellLifecycleController::setEmbeddedPlayer(PlayerViewModel *player) +{ + m_embeddedPlayer = player; +} + +void ShellLifecycleController::applyStartupWindowState() +{ + if (!m_window) + return; + // Mirrors MainWindow ctor :701-707: only the startup state (normal vs + // maximized) is honored; per-session geometry is deliberately not persisted. + const QString startup = ConfigStore::instance()->get( + ConfigKeys::StartupWindowState, QStringLiteral("normal")); + if (startup == QLatin1String("maximized")) + m_window->showMaximized(); +} + +bool ShellLifecycleController::handleWindowClose() +{ + // Mirrors MainWindow::closeEvent (:1183-1210). When close-to-tray is on and + // a tray is available, hide to the tray instead of quitting; otherwise start + // the graceful quit sequence and swallow this close (qApp->quit() follows). + const bool useTray = + ConfigStore::instance()->get(ConfigKeys::CloseToTray, false); + + if (!m_realQuit && useTray && QSystemTrayIcon::isSystemTrayAvailable()) + { + if (m_window) + m_window->hide(); + return false; // swallow: stay resident in the tray + } + + if (!m_quitPending) + { + requestApplicationQuit(); + return false; // swallow: the async quit sequence will call qApp->quit() + } + return true; // quit already in flight — let the window close +} + +void ShellLifecycleController::requestApplicationQuit() +{ + // Mirrors MainWindow::requestApplicationQuit (:1036-1051). Idempotent; set + // m_realQuit so a subsequent handleWindowClose accepts instead of hiding. + if (m_quitPending) + return; + m_quitPending = true; + m_realQuit = true; + + bool stoppedPlayback = false; + + // In-shell QML player: deactivate the VM so stopAndReport posts STOP while + // the event loop is still alive. Do this before deleting the engine. + // Use active() (not only hasActiveSession) so a just-started playback that + // has not yet opened the server session still tears down cleanly. + if (m_embeddedPlayer && m_embeddedPlayer->active()) + { + m_embeddedPlayer->setActive(false); + stoppedPlayback = true; + } + + if (PlaybackManager::instance()->stopActivePlaybackForQuit()) + stoppedPlayback = true; + + if (stoppedPlayback) + { + // Defer the final quit briefly so the stop report can flush, exactly as + // the Widgets shell did after PlayerView::stopAndReport. + QTimer::singleShot(500, this, + &ShellLifecycleController::finishApplicationQuit); + return; + } + finishApplicationQuit(); +} + +void ShellLifecycleController::finishApplicationQuit() +{ + // Mirrors MainWindow::finishApplicationQuit (:1068-1074). + if (m_core && m_core->authService()) + m_core->authService()->logout(); + // Queue quit so ApplicationWindow.onClosing / QQuickWindow::closeEvent can + // finish unwinding before the event loop exits. Destroying the QML engine + // still happens only after a.exec() returns (AppShellHost::shutdownUi). + QTimer::singleShot(0, qApp, &QCoreApplication::quit); +} diff --git a/src/qEmbyApp/quick/shelllifecyclecontroller.h b/src/qEmbyApp/quick/shelllifecyclecontroller.h new file mode 100644 index 0000000..6ac8f43 --- /dev/null +++ b/src/qEmbyApp/quick/shelllifecyclecontroller.h @@ -0,0 +1,81 @@ +#ifndef SHELLLIFECYCLECONTROLLER_H +#define SHELLLIFECYCLECONTROLLER_H + +#include + +class QEmbyCore; +class QQuickWindow; +class TrayManager; +class PlayerViewModel; + +// ===== Non-UI shell lifecycle for the full QML shell ===== +// +// In the Widgets shell MainWindow owned a cluster of non-UI lifecycle +// responsibilities that have nothing to do with QWidget painting: +// · tray integration (show/quit from the tray menu), +// · the close-to-tray decision in closeEvent, +// · the quit sequence (stop playback → flush stop report → logout → quit), +// · applying the StartupWindowState (normal/maximized) on window creation, +// · applying ConfigKeys::ThemeMode / FontSize via ThemeManager (so QML +// Theme.dark and stylesheet stay in sync with settings). +// +// AppShellHost drives a top-level QML ApplicationWindow, so those duties move +// here rather than being dropped (plan 步骤 4: "把 mainwindow.cpp 的非 UI 职责 +// 搬到 C++ 控制器"). This controller is engine-agnostic: it holds the +// QQuickWindow and the TrayManager and reproduces MainWindow's +// requestApplicationQuit / stopActivePlaybackForQuit / finishApplicationQuit / +// closeEvent exactly. Quit-time teardown stops the in-shell PlayerViewModel +// (server STOP report) and any independent/external sessions via +// PlaybackManager::stopActivePlaybackForQuit. +// +// AppShell.qml drives the window-close path via onClosing → handleWindowClose(), +// which returns whether the window may actually close. +class ShellLifecycleController : public QObject +{ + Q_OBJECT +public: + explicit ShellLifecycleController(QEmbyCore *core, QObject *parent = nullptr); + + // The QQuickWindow only exists after the engine loads AppShell.qml, but this + // controller must be registered as a context property before load() so QML + // can resolve it. So it is constructed window-less and given the window here + // once resolved. Safe to call once. + void setWindow(QQuickWindow *window); + + // Optional session-scoped embedded player. Injected after registerViewModels + // so quit can tear it down without depending on QML Component destruction + // (event loop may already be dying — see qml-shell-teardown-websocket). + void setEmbeddedPlayer(PlayerViewModel *player); + + // Apply ConfigKeys::StartupWindowState (normal/maximized) to the window. + // Call once after the window is set (mirrors MainWindow ctor :701-707). + void applyStartupWindowState(); + + // Decide what happens when the user closes the shell window. Returns true if + // the window should really close (accept), false if the close should be + // swallowed (ignore) — either because we hid to tray, or because the async + // quit sequence has just been kicked off and qApp->quit() will follow. + // Mirrors MainWindow::closeEvent. Bound from AppShell.qml onClosing. + Q_INVOKABLE bool handleWindowClose(); + + // Begin the graceful quit sequence: stop active playback (deferring the + // final quit briefly so the stop report can flush), then logout + quit. + // Idempotent. Mirrors MainWindow::requestApplicationQuit. + Q_INVOKABLE void requestApplicationQuit(); + +private: + void finishApplicationQuit(); + // Apply saved theme and react to ThemeMode/FontSize config changes. + // Mirrors MainWindow ctor theme wiring (without the QWidget snapshot anim). + void initThemeBridge(); + + QEmbyCore *m_core = nullptr; + QQuickWindow *m_window = nullptr; + TrayManager *m_trayManager = nullptr; + PlayerViewModel *m_embeddedPlayer = nullptr; + + bool m_quitPending = false; + bool m_realQuit = false; +}; + +#endif // SHELLLIFECYCLECONTROLLER_H diff --git a/src/qEmbyApp/quick/sidebarviewmodel.cpp b/src/qEmbyApp/quick/sidebarviewmodel.cpp new file mode 100644 index 0000000..15121ff --- /dev/null +++ b/src/qEmbyApp/quick/sidebarviewmodel.cpp @@ -0,0 +1,273 @@ +#include "sidebarviewmodel.h" + +#include "navigationcontroller.h" + +#include "../components/downloadmanagerdialog.h" +#include "../components/webdavsyncdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +SidebarViewModel::SidebarViewModel(QEmbyCore *core, + NavigationController *navigation, + QWidget *dialogParent, + QObject *parent) + : QObject(parent), m_core(core), m_navigation(navigation), + m_store(ConfigStore::instance()), m_dialogParent(dialogParent) +{ + setObjectName(QStringLiteral("sidebar-viewmodel")); + + // Live layout reactivity: re-emit layoutChanged for any sidebar/animation + // key so SideBar.qml re-lays-out without a restart (mirrors the Widgets + // shell reading ConfigKeys directly + reacting to valueChanged). + if (m_store) + connect(m_store, &ConfigStore::valueChanged, this, + [this](const QString &key, const QVariant &) + { + if (key == ConfigKeys::SidebarPosition || + key == ConfigKeys::SidebarPinned || + key == ConfigKeys::SidebarCustomEnabled || + key == ConfigKeys::SidebarHideSearch || + key == ConfigKeys::SidebarHideHome || + key == ConfigKeys::SidebarHideFavorites || + key == ConfigKeys::UiAnimations) + emit layoutChanged(); + }); + + // Identity + library list both follow the active server. + if (m_core && m_core->serverManager()) + connect(m_core->serverManager(), &ServerManager::activeServerChanged, + this, + [this] + { + emit profileChanged(); + reloadLibraries(); + }); + + // Authoritative "session became live / changed" signal. Fired by both + // enterAuthenticatedSession() (login OR restore) and leaveAuthenticatedSession(), + // so hooking it — rather than depending on activeServerChanged timing or the + // one login-completion hook — guarantees identity + library list reload on + // every entry into an authenticated session. Mirrors MediaRouteController. + if (m_navigation) + connect(m_navigation, &NavigationController::sessionRoutesCleared, this, + [this] { refresh(); }); + + reloadLibraries(); +} + +// ── Layout ─────────────────────────────────────────────────────────── +QString SidebarViewModel::position() const +{ + return m_store ? m_store->get(ConfigKeys::SidebarPosition, + QStringLiteral("left")) + : QStringLiteral("left"); +} + +bool SidebarViewModel::pinned() const +{ + return m_store ? m_store->get(ConfigKeys::SidebarPinned, true) : true; +} + +bool SidebarViewModel::searchVisible() const +{ + if (!m_store || !m_store->get(ConfigKeys::SidebarCustomEnabled, false)) + return true; + return !m_store->get(ConfigKeys::SidebarHideSearch, false); +} + +bool SidebarViewModel::homeVisible() const +{ + if (!m_store || !m_store->get(ConfigKeys::SidebarCustomEnabled, false)) + return true; + return !m_store->get(ConfigKeys::SidebarHideHome, false); +} + +bool SidebarViewModel::favoritesVisible() const +{ + if (!m_store || !m_store->get(ConfigKeys::SidebarCustomEnabled, false)) + return true; + return !m_store->get(ConfigKeys::SidebarHideFavorites, false); +} + +bool SidebarViewModel::reduceMotion() const +{ + // ConfigKeys::UiAnimations == true means "reduce animations" (snap). + return m_store ? m_store->get(ConfigKeys::UiAnimations, false) : false; +} + +// ── Identity ───────────────────────────────────────────────────────── +QString SidebarViewModel::serverName() const +{ + if (!m_core || !m_core->serverManager()) + return {}; + const auto profile = m_core->serverManager()->activeProfile(); + return profile.name.isEmpty() ? tr("My Server") : profile.name; +} + +QString SidebarViewModel::serverAddress() const +{ + if (!m_core || !m_core->serverManager()) + return {}; + return m_core->serverManager()->activeProfile().url; +} + +QString SidebarViewModel::serverIconSource() const +{ + if (!m_core || !m_core->serverManager()) + return QStringLiteral("qrc:/svg/emby.svg"); + + const auto profile = m_core->serverManager()->activeProfile(); + if (profile.type == ServerProfile::Jellyfin) + return QStringLiteral("qrc:/svg/jellyfin.svg"); + if (!profile.iconBase64.isEmpty()) + // Emby server icons are delivered as base64 image bytes; wrap as a data + // URI so QML Image can decode them (matches HomeView's loadFromData). + return QStringLiteral("data:image/png;base64,") + profile.iconBase64; + return QStringLiteral("qrc:/svg/emby.svg"); +} + +QString SidebarViewModel::userName() const +{ + if (!m_core || !m_core->serverManager()) + return {}; + return m_core->serverManager()->activeProfile().userName; +} + +bool SidebarViewModel::isAdmin() const +{ + if (!m_core || !m_core->serverManager()) + return false; + return m_core->serverManager()->activeProfile().isAdmin; +} + +bool SidebarViewModel::canDownloadMedia() const +{ + if (!m_core || !m_core->serverManager()) + return false; + return m_core->serverManager()->activeProfile().canDownloadMedia; +} + +// ── Library list ───────────────────────────────────────────────────── +QString SidebarViewModel::emojiForCollectionType(const QString &collectionType) +{ + // Mirrors the icon prefix HomeView applied to sidebar library items + // (homeview.cpp:1724-1732). Trailing space kept so QML can concatenate. + if (collectionType == QLatin1String("movies")) + return QStringLiteral("🎬 "); + if (collectionType == QLatin1String("tvshows")) + return QStringLiteral("📺 "); + if (collectionType == QLatin1String("music")) + return QStringLiteral("🎵 "); + if (collectionType == QLatin1String("homevideos") || + collectionType == QLatin1String("photos")) + return QStringLiteral("🎞️ "); + return QStringLiteral("📁 "); +} + +void SidebarViewModel::reloadLibraries() +{ + // Guard on a valid, logged-in profile. getUserViews() calls + // ensureValidProfile() which THROWS when no client/profile is active; firing + // the coroutine pre-login would raise an unhandled exception AND poison the + // MediaService cache with an empty result under transient profile keys. When + // logged out, just clear the list. + const bool ready = m_core && m_core->mediaService() && + m_core->serverManager() && + m_core->serverManager()->activeProfile().isValid(); + if (!ready) + { + if (!m_libraries.isEmpty()) + { + m_libraries.clear(); + emit librariesChanged(); + } + return; + } + // Fire-and-forget coroutine; QCoro::Task starts eagerly on call. + loadLibrariesTask(); +} + +void SidebarViewModel::cloudSync() +{ + // Mirrors LoginViewModel::cloudSync / HomeView::openCloudSyncDialog: a + // modal Widget dialog anchored to the shell's hidden dialog anchor. The + // WebdavProfileStore is created lazily and reused across opens. + if (!m_dialogParent || !m_core || !m_core->serverManager()) + return; + if (!m_webdavStore) + { + m_webdavStore = new WebdavProfileStore(this); + m_webdavStore->load(); + } + WebdavSyncDialog dialog(m_webdavStore, m_core->serverManager(), + m_dialogParent); + dialog.exec(); +} + +void SidebarViewModel::openDownloads() +{ + // Mirrors HomeView's m_btnDownloads handler. Gated in QML by + // canDownloadMedia, but guard here too since it is Q_INVOKABLE. + if (!m_dialogParent || !m_core) + return; + DownloadManagerDialog::showManager(m_core, m_dialogParent); +} + +void SidebarViewModel::logout() +{ + // Session teardown. In the Widgets shell the footer Logout button emitted + // logoutRequested → MainWindow::navigateToLogin, which called + // clearAuthenticatedSession() (NavigationController::leaveAuthenticatedSession) + // then authService()->logout(). Reproduce both here: leave to the login + // route first (so the UI is already off authenticated pages), then clear + // the stored token. + if (m_navigation) + m_navigation->leaveAuthenticatedSession(); + if (m_core && m_core->authService()) + m_core->authService()->logout(); +} + +QCoro::Task SidebarViewModel::loadLibrariesTask() +{ + const QList views = co_await m_core->mediaService()->getUserViews(); + + QVariantList rows; + rows.reserve(views.size()); + for (const auto &view : views) + rows.append(QVariantMap{ + {QStringLiteral("id"), view.id}, + {QStringLiteral("name"), view.name}, + {QStringLiteral("emoji"), emojiForCollectionType(view.collectionType)}, + }); + + m_libraries = std::move(rows); + emit librariesChanged(); + co_return; +} + +void SidebarViewModel::refresh() +{ + emit profileChanged(); + reloadLibraries(); +} + +void SidebarViewModel::openLibrary(const QString &libraryId, const QString &name) +{ + if (!m_navigation || libraryId.isEmpty()) + return; + // Same route shape as HomeView::openLibraryRoute: title = clean name (no + // emoji), params carry libraryId + title. + m_navigation->push(QVariantMap{ + {QStringLiteral("name"), QStringLiteral("library")}, + {QStringLiteral("title"), name}, + {QStringLiteral("params"), + QVariantMap{{QStringLiteral("libraryId"), libraryId}, + {QStringLiteral("title"), name}}}}); +} diff --git a/src/qEmbyApp/quick/sidebarviewmodel.h b/src/qEmbyApp/quick/sidebarviewmodel.h new file mode 100644 index 0000000..d23c62f --- /dev/null +++ b/src/qEmbyApp/quick/sidebarviewmodel.h @@ -0,0 +1,129 @@ +#ifndef SIDEBARVIEWMODEL_H +#define SIDEBARVIEWMODEL_H + +#include +#include +#include + +#include + +class QEmbyCore; +class NavigationController; +class ConfigStore; +class QWidget; +class WebdavProfileStore; + +// ===== QML-facing ViewModel for the shell sidebar (SideBar.qml) ===== +// +// In the Widgets shell HomeView built the sidebar imperatively (server header, +// nav buttons, dynamic library list, user row, footer) and read the sidebar +// ConfigStore keys directly. SideBar.qml is the QML reproduction; this VM is +// its data source and owns three concerns: +// +// 1. Config-driven layout state — position (left/right), pinned (docked vs +// floating), and the custom hide flags — mirrored from ConfigStore with +// live valueChanged reactivity so the sidebar re-lays-out on settings +// changes without a restart. +// 2. Profile-driven identity — server name/address/icon and user name plus +// the two capability gates (isAdmin → Manage, canDownloadMedia → +// Downloads) that the footer/user-row honor. +// 3. The dynamic MEDIA library list, loaded from mediaService()->getUserViews() +// with the collectionType→emoji prefix mapping the Widgets list used. +// +// Route pushes stay in QML (SideBar.qml calls navigation.push directly for +// home/favorites/library/settings/manage) except library rows, which go through +// openLibrary() here so the emoji-stripped title and libraryId params match +// HomeView::openLibraryRoute exactly. +class SidebarViewModel : public QObject +{ + Q_OBJECT + + // ── Layout (ConfigStore-backed, live) ──────────────────────────── + // position: "left" | "right" (default "left"). + Q_PROPERTY(QString position READ position NOTIFY layoutChanged) + Q_PROPERTY(bool pinned READ pinned NOTIFY layoutChanged) + // Effective per-item visibility already folds in customEnabled: when the + // custom toggle is off, all three are visible regardless of the hide flags + // (matching HomeView::applySidebarCustomVisibility). + Q_PROPERTY(bool searchVisible READ searchVisible NOTIFY layoutChanged) + Q_PROPERTY(bool homeVisible READ homeVisible NOTIFY layoutChanged) + Q_PROPERTY(bool favoritesVisible READ favoritesVisible NOTIFY layoutChanged) + // Reduce-motion: ConfigKeys::UiAnimations == true means "snap, no slide". + Q_PROPERTY(bool reduceMotion READ reduceMotion NOTIFY layoutChanged) + + // ── Identity (profile-backed) ──────────────────────────────────── + Q_PROPERTY(QString serverName READ serverName NOTIFY profileChanged) + Q_PROPERTY(QString serverAddress READ serverAddress NOTIFY profileChanged) + // Data URI (or empty) for the server icon; falls back in QML to emby.svg. + Q_PROPERTY(QString serverIconSource READ serverIconSource NOTIFY profileChanged) + Q_PROPERTY(QString userName READ userName NOTIFY profileChanged) + Q_PROPERTY(bool isAdmin READ isAdmin NOTIFY profileChanged) + Q_PROPERTY(bool canDownloadMedia READ canDownloadMedia NOTIFY profileChanged) + + // ── MEDIA library list ─────────────────────────────────────────── + // List of maps: { "id": QString, "name": QString, "emoji": QString }. + Q_PROPERTY(QVariantList libraries READ libraries NOTIFY librariesChanged) + +public: + // dialogParent anchors the Widget dialogs the footer utility buttons still + // open (WebDAV sync, Downloads manager) — the QML shell supplies its hidden + // dialog anchor. May be null (dialogs are then simply skipped). + SidebarViewModel(QEmbyCore *core, NavigationController *navigation, + QWidget *dialogParent = nullptr, QObject *parent = nullptr); + + QString position() const; + bool pinned() const; + bool searchVisible() const; + bool homeVisible() const; + bool favoritesVisible() const; + bool reduceMotion() const; + + QString serverName() const; + QString serverAddress() const; + QString serverIconSource() const; + QString userName() const; + bool isAdmin() const; + bool canDownloadMedia() const; + + QVariantList libraries() const { return m_libraries; } + + // Push the `library` route for a row id, deriving the title the same way + // HomeView::openLibraryRoute did (clean name, libraryId + title params). + Q_INVOKABLE void openLibrary(const QString &libraryId, const QString &name); + + // Re-pull the library list; call on entering the authenticated session. + Q_INVOKABLE void reloadLibraries(); + + // ── Footer utility actions (were HomeView sidebar buttons) ─────── + // Open the WebDAV cloud-sync dialog (mirrors HomeView::openCloudSyncDialog / + // LoginViewModel::cloudSync). No-op without a dialogParent. + Q_INVOKABLE void cloudSync(); + // Open the downloads manager dialog (mirrors HomeView's m_btnDownloads → + // DownloadManagerDialog::showManager). Gated in QML by canDownloadMedia. + Q_INVOKABLE void openDownloads(); + // Tear down the authenticated session: leave to the login route and clear + // the stored token (mirrors HomeView::logoutRequested → MainWindow logout). + Q_INVOKABLE void logout(); + + // Full refresh: re-emit identity + reload the library list. Called when the + // active profile changes out-of-band (e.g. right after login completes). + void refresh(); + +Q_SIGNALS: + void layoutChanged(); + void profileChanged(); + void librariesChanged(); + +private: + static QString emojiForCollectionType(const QString &collectionType); + QCoro::Task loadLibrariesTask(); + + QEmbyCore *m_core = nullptr; + NavigationController *m_navigation = nullptr; + ConfigStore *m_store = nullptr; + QWidget *m_dialogParent = nullptr; + WebdavProfileStore *m_webdavStore = nullptr; + QVariantList m_libraries; +}; + +#endif // SIDEBARVIEWMODEL_H diff --git a/src/qEmbyApp/resources/i18n/qEmby_fr_FR.ts b/src/qEmbyApp/resources/i18n/qEmby_fr_FR.ts index a7e169e..6609017 100644 --- a/src/qEmbyApp/resources/i18n/qEmby_fr_FR.ts +++ b/src/qEmbyApp/resources/i18n/qEmby_fr_FR.ts @@ -1,770 +1,1568 @@ - - MainWindow - - Press Back again to logout - Appuyez à nouveau sur Retour pour vous déconnecter - - - Refreshing Home... - Actualisation Accueil... - - - Refreshing Favorites... - Actualisation Favoris... - - - Search... - Recherche... - - - Search - Recherche - - - - - LoginView - - Login - Connexion - - - Select Server - Sélectionner un serveur - - - Connect to Server - Se connecter au serveur - - - Server Address (e.g. 192.168.1.1 or domain) - Adresse du serveur (ex: 192.168.1.1 ou domaine) - - - Server URL (e.g. example.com, example.com/emby or https://example.com) - URL du serveur (ex: example.com, example.com/emby ou https://example.com) - - - Server URL - Serveur URL - - - Server Address - Adresse du serveur - - - Server Address (e.g. example.com/emby or 192.168.1.1/jellyfin) - Adresse du serveur(ex: example.com/emby ou 192.168.1.1/jellyfin) - - - Port (e.g. 8096) - Port (ex: 8096) - - - Port - Port - - - Ignore SSL certificate verification - Ignorer la vérification du certificat SSL - - - Use this only for trusted self-signed or private CA HTTPS servers. - N'utilisez cette option que pour les serveurs HTTPS auto-signés ou gérés par une autorité de certification privée de confiance. - - - Only for trusted self-signed or private CA servers. - Uniquement pour les serveurs de confiance auto-signés ou gérés par une autorité de certification privée. - - - Username - Nom d'utilisateur - - - Password - Mot de passe - - - Show Password - Afficher le mot de passe - - - Cancel - Annuler - - - Edit - Modifier - - - Delete - Supprimer - - - Add New Server - Ajouter un nouveau serveur - - - Add New Server - Ajouter un nouveau serveur - - - Connect to another server - Se connecter à un autre serveur - - - Save & Login - Enregistrer et se connecter - - - Remove Server - Supprimer le serveur - - - Are you sure you want to remove this server? -This action cannot be undone. - Êtes-vous sûr de vouloir supprimer ce serveur ? -Cette action ne peut pas être annulée. - - - Remove - Supprimer - - - Server address and username cannot be empty. - Adresse du serveur et nom utilisateur ne peuvent pas être laissés vides. - - - Server URL and username cannot be empty. - URL du serveur et nom utilisateur ne peuvent pas être laissés vides. - - - Please enter a valid server URL. - Veuillez saisir une URL de serveur valide. - - - Please enter a valid server address. - Veuillez saisir une adresse de serveur valide. - - - Please enter a valid port number. - Veuillez saisir un numéro de port valide. - - - Server URL must use http:// or https://. - L'URL du serveur doit utiliser http:// ou https://. - - - Connecting... - Connexion en cours... - - - Connection Failed - Échec de la connexion - - - Failed to connect or session expired. Please log in again. -Error: - Échec de la connexion ou session expirée. Veuillez vous reconnecter. -Erreur : - - - - - NetworkManager - - TLS/SSL 证书错误: %1 - Erreur de certificat TLS/SSL : %1 - - - 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 - Si serveur HTTPS auto-signé de confiance, activez option Ignorer la validation du certificat SSL. - - - - - HomeView - - Search... - Rechercher... - - - Search - Rechercher - - - Home - Accueil - - - Favorites - Favoris - - - MEDIA - MEDIA - - - Settings - Paramètres - - - Manage - Gérer - - - Downloads - Téléchargements - - - Logout - Déconnexion - - - My Server - Mon serveur - - - Administrator - Administrateur - - - User - Utilisateur - - - Refreshing Home... - Actualisation Accueil... - - - Refreshing Favorites... - Actualisation Favoris... - - - This feature is under development - Cette fonctionnalité est en cours de développement - - - Server Management - Gestion des serveurs - - - - - LibraryView - - All - Tout - - - Movies - Films - - - Shows - Séries - - - Music - Musique - - - Videos - Vidéos - - - Recent - Récents - - - Playlists - Listes de lecture - - - Lists - Listes - - - Collections - Collections - - - Favorites - Favoris - - - Folders - Dossiers - - - Title - Titre - - - Date Added - Date d'ajout - - - Date Played - Date de lecture - - - Runtime - Durée - - - Premiere Date - Date de diffusion - - - 0 Items - 0 article - - - Loading... - Chargement en cours... - - - %1 Items - %1 articles - - - %1 / %2 Items - %1 / %2 articles - - - Toggle View Mode - Changer de mode d'affichage - - - Error Loading Items - Erreur lors du chargement des éléments - - - - - DetailActionWidget - - ▶ Resume - ▶ Reprendre - - - ▶ Play - ▶ Jouer - - - ↺ Play from Beginning - ↺ Lire depuis le début - - - ▶ Resume %1 - ▶ Reprendre %1 - - - ↺ Restart %1 - ↺ Redémarrer %1 - - - ▶ Play %1 - ▶ Jouer %1 - - - %1 remaining - %1 restant - - - Version %1 - Version %1 - - - Unknown Video - Vidéo inconnue - - - 🚫 No Subtitles - 🚫 Sans sous-titres - - - Disable subtitle display - Désactiver l'affichage des sous-titres - - - Audio Track - Piste audio - - - Subtitle - Sous-titre - - - Forced - Forcé - - - External - Externe - - - SDH - Sous-titres pour les sourds et malentendants - - - Standard Subtitle - Sous-titre standard - - - %1 hr %2 min - %1 H %2 Mn - - - %1 min - %1 min - - - - - DetailBottomInfoWidget - - Tags - Étiquettes - - - Studios - Studios - - - Media Info - Info Media - - - Added %1 - Ajouté %1 - - - Video - Vidéo - - - Audio - Audio - - - Subtitle - Sous-titre - - - Title - Titre - - - Codec - Codec - - - Language - Langue - - - Codec Tag - Balise de codec - - - Profile - Profil - - - Level - Niveau - - - Resolution - Résolution - - - Aspect Ratio - Format d'image - - - Interlaced - Entrelacé - - - Framerate - Fréquence d'images - - - Bitrate - Débit binaire - - - Bit Depth - Nombre de bits - - - Pixel Format - Format de pixels - - - Ref Frames - Références - - - Layout - Mise en page - - - Channels - Chaînes - - - Sample Rate - Fréquence d'échantillonnage - - - Default - Par défaut - - - Yes - Oui - - - Index - Index - - - Forced - Forcé - - - Hearing Impaired - Personnes malentendantes - - - External - Externe - - - Text Stream - Flux de texte - - - Ext. Stream Supported - Prise en charge des flux externes - - - External URL - URL Externe - - - Delivery Method - Mode de livraison - - - Protocol - Protocole - - - Ext. Video Type - Type de vidéo externe - - - Ext. Video SubType - Sous-type de vidéo externe - - - Size - Taille - - - Path - Chemin - - - Delivery URL - URL de livraison - - - External Links - Liens externes - - - - - SplitPlayerButton - - Play with %1 - Jouer avec %1 - - - - - MediaActionMenu - - Play - Jouer - - - Play with %1 - Jouer avec %1 - - - View Details - Voir les détails - - - Download - Télécharger - + AddToPlaylistDialog + Add to Playlist Ajouter à la liste de lecture - Remove from Playlist - Supprimer de la liste de lecture + + Select a playlist + Sélectionnez une liste de lecture - Add to Favorites - Ajouter aux Favoris + + Search playlists... + Rechercher des listes de lecture... - Remove from Favorites - Supprimer des Favoris + + + New Playlist + Nouvelle liste de lecture - Mark as Played - Marquer comme vu + + Cancel + Annuler - Mark as Unplayed - Marquer comme non vu + + + Add + Ajouter - Remove from Continue Watching - Retirer de la liste « À regarder » + Loading... + Chargement en cours... - Edit Metadata - Modifier les métadonnées + No playlists found + Aucune liste de lecture trouvée - Edit Images - Modifier les images + + Failed to load playlists: %1 + Échec du chargement des listes de lecture: %1 - Delete Playlist - Supprimer la liste de lecture + Create Playlist + Créer une liste de lecture - Identify - Identifier + Playlist name + Nom de la liste de lecture - Refresh Metadata - Actualiser les métadonnées + + + Enter playlist name + Entrez le nom de la liste de lecture - Remove Media - Supprimer Media + + Create + Créer + + + + Playlist created + Liste de lecture créée + + + + Failed to create playlist: %1 + Échec de la création de la liste de lecture: %1 + + + + AddUserDialog + + + Add User + Ajouter un utilisateur + + + + Name + Nom + + + + This will be the name displayed for the user on the server. + Ce sera le nom qui s'affichera pour l'utilisateur sur le serveur. + + + + Enter user name + Entrez votre nom d'utilisateur + + + + Copy from user + Copier depuis l'utilisateur + + + + + Do not copy + Ne pas copier + + + + Optionally copy settings from an existing user. + Vous pouvez également copier les paramètres d'un utilisateur existant. + + + + Allow user to manage server + Permettre à l'utilisateur de gérer le serveur + + + + Copy Options + Options de copie + + + + User policy + Politique d'utilisation + + + + User configuration + Configuration de l'utilisateur + + + + Choose which settings to copy from the selected user. + Choisissez les paramètres à copier depuis l'utilisateur sélectionné. + + + + Library Access + Accès à la bibliothèque + + + + Allow access to all libraries + Autoriser l'accès à toutes les bibliothèques + + + + Choose which libraries this user can access. + Sélectionnez les bibliothèques auxquelles cet utilisateur a accès. + + + + Channel Access + Accès aux chaînes + + + + Allow access to all channels + Autoriser l'accès à toutes les chaînes + + + + Choose which channels this user can access. + Sélectionnez les chaînes auxquelles cet utilisateur a accès. + + + + Create + Créer + + + + Cancel + Annuler + + + + User name cannot be empty + Le nom d'utilisateur ne peut pas être vide + + + + User "%1" created + L'utilisateur “%1” a été créé + + + + User "%1" created, but failed to apply permissions: %2 + L'utilisateur “%1” a été créé, mais l'application des autorisations a échoué : %2 + + + + Failed to create user: %1 + Échec de la création de l'utilisateur: %1 + + + + AdminCollectionsPage + + + + Playlists + Listes de lecture + + + + %1 playlists · %2 selected + + + + + %1 playlists + + + + + Refreshing… + + + + + Refresh + Actualiser + + + + + Create playlist + Créer une liste de lecture + + + + Clear selection + + + + + Select all + + + + + Delete selected + + + + + Rename + Renommer + + + + + + Delete + Supprimer + + + + Loading playlists + + + + + Could not load playlists + + + + + No playlists found + Aucune liste de lecture trouvée + + + + Create a playlist to organize media for quick access. + + + + + Saving changes + + + + + Create Playlist + Créer une liste de lecture + + + + Create + Créer + + + + + Playlist name + Nom de la liste de lecture + + + + Rename Playlist + Renommer la liste de lecture + + + + Save + Enregistrer + + + + Delete Playlist + Supprimer la liste de lecture + + + + + This action cannot be undone + + + + + Are you sure you want to delete playlist “%1”? This action cannot be undone. + + + + + Delete Selected + Supprimer la sélection + + + + Delete %1 selected playlists? This action cannot be undone. + + + + + AdminLibrariesModel + + + No media folders + + + + + AdminLibrariesPage + + + + Libraries + Bibliothèques + + + + Refresh + Actualiser + + + + Scan all + + + + + + Add library + + + + + Clear selection + + + + + Select all + + + + + Delete selected + + + + + %1 libraries · %2 selected + + + + + %1 libraries + + + + + Scanning %1% + + + + + Cancel + Annuler + + + + Edit + Modifier + + + + Images + Images + + + + Scan + Analyser + + + + Metadata + + + + + Move up + Monter + + + + Move down + Descendre + + + + + Delete library + + + + + Loading libraries + + + + + Fetching media libraries from the server… + + + + + Could not load libraries + + + + + No media libraries found + Aucune bibliothèque multimédia trouvée + + + + Applying changes + + + + + Please wait… + + + + + + Delete + Supprimer + + + + Remove “%1” from the server? Media files will not be deleted. + + + + + Delete selected libraries + Supprimer les bibliothèques sélectionnées + + + + Remove %1 selected libraries from the server? Media files will not be deleted. + + + + + AdminLibrariesViewModel + + + Failed to load libraries: %1 + Échec du chargement des bibliothèques: %1 + + + + Library created successfully + La bibliothèque a été créée avec succès + + + + Failed to create library: %1 + Échec de la création de la bibliothèque: %1 + + + + Library updated successfully + La bibliothèque a été mise à jour avec succès + + + + Failed to update library: %1 + Échec de la mise à jour de la bibliothèque:%1 + + + + Library "%1" deleted + La bibliothèque "%1" a été supprimée + + + + Failed to delete library: %1 + Échec de la suppression de la bibliothèque: %1 + + + + Deleted %n library(s) + + %n bibliothèque(s) supprimée(s) + + + + + + Deleted %1 library(s), then failed: %2 + Suppression de %1 bibliothèque(s), puis échec: %2 + + + + Refreshing metadata for "%1"... + Actualisation des métadonnées pour “%1”... + + + + Scanning "%1"... + + + + + + Scan failed: %1 + + + + + Full library scan started + Analyse complète de la bibliothèque en cours + + + + Scan cancelled + Analyse annulée + + + + Cancel failed: %1 + Échec de l'annulation: %1 + + + + Library order updated + Commande de la bibliothèque mise à jour + + + + Failed to save order: %1 + Échec de l'enregistrement de la commande: %1 + + + + AdminLogsPage + + + Logs + Journaux + + + + Refreshing… + + + + + Refresh + Actualiser + + + + Refresh log files + + + + + Save… + + + + + Save %1 + + + + + No log files found + Aucun fichier journal trouvé + + + + The server did not report any downloadable logs. + + + + + No log file selected + Aucun fichier journal n'a été sélectionné + + + + Saving… + + + + + Save as… + + + + + Log contents + + + + + Loading log + + + + + Could not load log + + + + + Try again + + + + + Select a log file + + + + + Choose a file above to inspect its contents. + + + + + Loading logs + + + + + Fetching available server log files… + + + + + Could not load logs + + + + + AdminTasksPage + + + Tasks + Tâches + + + + Refreshing… + + + + + Refresh + Actualiser + + + + Refresh tasks + + + + + All Tasks + Toutes les tâches + + + + Scheduled + Planifié + + + + Running + + + + + Manual + Manuel + + + + Running Now + En cours + + + + Tasks that are currently executing. Progress refreshes automatically. + Tâches en cours d'exécution. La progression est mise à jour automatiquement. + + + + Scheduled Tasks + Tâches planifiées + + + + Tasks with one or more configured triggers. + Tâches comportant un ou plusieurs déclencheurs configurés. + + + + Manual Tasks + Tâches manuelles + + + + Tasks that only run when started manually. + Tâches qui ne s'exécutent que lorsqu'elles sont lancées manuellement. + + + + Hidden or built-in tasks reported by the server. + Tâches masquées ou intégrées signalées par le serveur. + + + + System + Système + + + + Edit + Modifier + + + + Edit %1 + + + + + Working… + + + + + Stop + Stop + + + + Run + + + + + Stop %1 + + + + + Run %1 + + + + + Schedule: %1 + Programme:%1 + + + + %1 · %2% + %1 · %2% + + + + Loading tasks + + + + + Fetching scheduled tasks from the server… + + + + + Could not load tasks + + + + + No tasks found + Aucune tâche trouvée + + + + The server did not report any scheduled tasks. + + + + + AdminTranscodingPage + + + + None + Aucun + + + + + + Auto + Auto + + + + Ultra fast + + + + + Super fast + + + + + Very fast + + + + + Faster + Plus rapide + + + + Fast + Rapide + + + + Medium + Moyen + + + + Slow + Lent + + + + Slower + Plus lent + + + + Very slow + + + + + Disabled + Désactivé + + + + Hardware + Matériel + + + + Software + Logiciel + + + + Hardware + Software + Matériel + Logiciel + + + + Transcoding + Transcodage + + + + Refreshing… + + + + + Refresh + Actualiser + + + + Reset + + + + + Saving… + + + + + Save + Enregistrer + + + + Try again + + + + + + Hardware acceleration + + + + + Hardware acceleration type + + + + + VAAPI device + + + + + QSV device + + + + + Hardware decoding codecs + + + + + Prefer system native decoder + + + + + Enhanced NVDEC + NVDEC amélioré + + + + Enable hardware encoding + + + + + Intel low power H.264 + + + + + Intel low power HEVC + + + + + Allow HEVC encoding + + + + + Allow AV1 encoding + + + + + Hardware acceleration mode + + + + + Yes + Oui + + + + No + Non + + + + Advanced + Avancé + + + + Advanced mode exposes the server codec configuration below. + + + + + Codecs + + + + + Info + + + + + Settings + Paramètres + + + + Paths and threads + + + + + Encoding threads + + + + + Extraction threads + + + + + Transcoding temporary path + + + + + FFmpeg path + + + + + Fallback font path + + + + + Enable fallback font + + + + + Maximum muxing queue size + + + + + Quality and processing + + + + + Encoder preset + + + + + H.264 CRF + H.264 CRF + + + + H.265 CRF + H.265 CRF + + + + Audio downmix boost + + + + + Enable audio VBR + + + + + Deinterlace method + + + + + Double-rate deinterlacing + + + + + Enable subtitle extraction + + + + + Tone mapping + + + + + Tone mapping mode + + + + + Enable tone mapping + + + + + Enable VPP tone mapping + + + + + Enable VideoToolbox tone mapping + + + + + Algorithm + Algorithme + + + + Mode + Mode + + + + Range + Gamme + + + + Desaturation + Désaturation + + + + Peak + Pointe + + + + Parameter + Paramètre + + + + VPP brightness + + + + + VPP contrast + + + + + Throttling and segments + + + + + Enable throttling + + + + + Throttle delay (seconds) + + + + + Throttle buffer size + + + + + Throttle hysteresis + + + + + Delete old segments + + + + + Segment retention (seconds) + + + + + Loading transcoding settings + + + + + Transcoding settings unavailable + + + + + No transcoding configuration is available for this server. + + + + + Discard changes? + + + + + Discard + + + + + Cancel + Annuler + + + + Restore all fields to the last values loaded from the server? + + + + + Browse… + + + + + AdminUsersPage + + + Users + Utilisateurs + + + + 1 user + + + + + %1 users + + + + + Refreshing… + + + + + Refresh + Actualiser + + + + Refresh users + + + + + Add user + Ajouter un utilisateur + + + + Try again + + + + + Edit user %1 + + + + + Admin + Admin + + + + Disabled + Désactivé + + + + You + + + + + Last login + + + + + Last active + + + + + Password + Mot de passe + + + + Set + + + + + Not set + + + + + You cannot disable or delete yourself + + + + + Disable %1 + + + + + Enable %1 + + + + + Working… + + + + + + Delete + Supprimer + + + + Delete user %1 + + + + + No users found + Aucun utilisateur trouvé + + + + Add a user to grant access to this server. + + + + + Loading users… + + + + + Unable to load users + + + + + Delete user + Supprimer un utilisateur + + + + Delete user %1 permanently + + + + + Cancel + Annuler + + + + Are you sure you want to delete user "%1"? This action cannot be undone. + Êtes-vous sûr de vouloir supprimer l'utilisateur "%1"? Cette action ne peut pas être annulée. - BaseView + Added to Favorites Ajouté aux favoris + Removed from Favorites Supprimé des favoris + Failed to update favorite status Échec de la mise à jour du statut Favoris + Marked as Played Marquer comme vu + Marked as Unplayed Marquer comme non vu + Removed from Continue Watching Retiré de Continuer à regarder + Removed from current playlist Retiré de la playlist actuelle + + + Operation failed - Échec de l'opération + Échec de l'opération - Added to playlist "%1" + + Added to playlist "%1" Ajouté à la liste de lecture “%1” + Failed to add to playlist: %1 - Échec de l'ajout à la liste de lecture : %1 + Échec de l'ajout à la liste de lecture : %1 + Failed to remove from playlist: %1 Échec de la suppression de la liste de lecture:%1 + + + + + This action requires administrator privileges - Cette opération nécessite des droits d'administrateur + Cette opération nécessite des droits d'administrateur + Identify is not supported for this item type La fonction Identifier non prise en charge pour ce type + Duplicate Add Ajouter une copie + %1 is already in playlist %2. Add it again? “%1”Ce titre figure déjà dans la playlist“%2”. -Voulez-vous l'ajouter à nouveau ? +Voulez-vous l'ajouter à nouveau ? "%1" is already in playlist "%2". Add it again? - “%1”Ce titre figure déjà dans la playlist“%2”. + “%1”Ce titre figure déjà dans la playlist“%2”. -Voulez-vous l'ajouter à nouveau ? +Voulez-vous l'ajouter à nouveau ? + + Cancel Annuler + Add Again Ajouter à nouveau + current playlist liste de lecture actuelle + Delete Playlist Supprimer la liste de lecture + Are you sure you want to delete playlist "%1"? This action cannot be undone. @@ -773,34 +1571,53 @@ This action cannot be undone. Cette action ne peut pas être annulée. + Delete Supprimer + "%1" deleted "%1" supprimé + Delete failed: %1 Échec de la suppression:%1 + + Metadata saved + Métadonnées enregistrées + + + + Failed to update metadata: %1 + + + + + this item cet article + Refreshing metadata for "%1"... Actualisation des métadonnées pour “%1”... + Metadata refresh failed: %1 Actualisation des métadonnées pour: %1 + Remove Media Supprimer le Media + Are you sure you want to remove "%1" from the server? This action cannot be undone. @@ -809,1172 +1626,5968 @@ This action cannot be undone. Cette action ne peut pas être annulée. + Remove Supprimer + "%1" removed “%1” supprimé + Failed to remove media: %1 Échec de la suppression du media:%1 - - MediaIdentifyDialog + CategoryPage - Unknown Title - Titre inconnu + + Date Added + Date d'ajout - Provider IDs: %1 - Fournisseur ID:%1 + + Date Played + - Album Artist: %1 - Artiste de l'album:%1 + + Title + Titre - Artists: %1 - Artistes:%1 + + Runtime + Durée - Identify - Identifier + + Premiere Date + Date de diffusion - this item - cet article + + Refreshing… + - Search remote metadata matches for "%1" and apply the correct result. - Rechercher les correspondances de métadonnées distantes pour “%1” et appliquer le résultat approprié. + + Refresh + Actualiser - Enter title or keyword - Entrez un titre ou un mot-clé + + Descending + - IMDb Id (optional) - IMDb Id(facultatif) + + Ascending + - MovieDb Id (optional) - MovieDb Id(facultatif) + + Poster view + - TheTVDB Id (optional) - TheTVDB Id(facultatif) + + Wide view + - Any - Tout + + + No items in this category + - Search - Rechercher + + + Try refreshing or choosing another category. + - Enter a title or provider ID to search. - Saisissez un titre ou un identifiant de fournisseur pour lancer la recherche. - - - Cancel - Annuler - - - Apply Match - Appliquer la correspondance - - - Preview unavailable - Aperçu indisponible - - - Loading preview... - Chargement de l'aperçu... - - - Searching... - Recherche en cours... - - - No matches found - Aucun résultat trouvé - - - Found %1 matches - %1 résultats trouvés - - - Search failed - La recherche a échoué - - - Failed to search metadata: %1 - Échec de la recherche dans les métadonnées:%1 - - - Applying match... - Applique correspondance... - - - Metadata updated - Métadonnées mises à jour - - - Apply failed - L'application a échoué - - - Failed to apply match: %1 - Échec de l'application de la correspondance:%1 + + Loading more… + - - MediaImageEditDialog + CategoryView - Primary - Primaire + Title + Titre - Backdrop - Contexte + Date Added + Date d'ajout - Banner - Bannière + Date Played + Date jouée - Thumb - Miniature + Runtime + Durée - Logo - Logo + Premiere Date + Date de diffusion - Art - Art + 0 Items + 0 Articles - Disc - Disque + Loading... + Chargement en cours... - Box - Boîte + %1 Items + %1 Articles - Box Rear - Arrière de la boîte + Toggle View Mode + Changer de mode d'affichage - Menu - Menu + Error Loading Items + Erreur lors du chargement des éléments - Screenshot - Capture d'écran + Refresh Recommendations + Actualiser les recommandations + + + + CategoryViewModel + + + 0 Items + - Chapter - Chapitre + + Loading... + Chargement en cours... - Profile - Profil + + + Unable to load items + - No image - Pas d'image + + + Error Loading Items + Erreur lors du chargement des éléments - %1 image(s) available - %1 image(s) disponible(s) + + %1 Items + + + + + CollectionCard + + + Rename + Renommer - %1 %2 - %1 %2 - - - %1 image(s) - %1 image(s) - - - %1 × %2 - %1 × %2 - - - Index %1 - Index %1 - - - Image Files (%1);;All Files (*) - Fichiers image (%1);;Tous les fichiers (*) - - - Edit Images - Modifier les images - - - this item - cet article - - - Manage image types for "%1". Changes are applied immediately. - Gérer les types d'images pour “%1”. Les modifications sont appliquées immédiatement. - - - Loading image information... - Chargement des informations sur l'image... - - - Image Types - Types d'images - - - Drop an image file or image URL here to replace it. - Faites glisser un fichier image ou son URL ici pour le remplacer. - - - Choose Image - Choisissez une image - - - Image URL - URL de l'image - - - Download - Télécharger - - - Close - Fermer - - - Remove Image - Supprimer l'image - - - Upload Image - Importer une image - - - Preview unavailable - Aperçu indisponible - - - Loaded images for %1 type(s) - Images chargées pour %1 type(s) - - - No images found yet - Aucune image trouvée pour l'instant - - - Failed to load images - Impossible de charger les images - - - Failed to load images: %1 - Impossible de charger les images:%1 - - - No image for this type - Aucune image pour ce type - - - Loading preview... - Chargement de l'aperçu... - - - Select Image - Sélectionner une image - - - Enter Image URL - Saisissez URL de l'image - - - Paste an image address for the %1 image of "%2". - Collez l'adresse de l'image %1 de “%2”. - - - https://example.com/image.jpg - https://example.com/image.jpg - - - Save Image - Enregistrer l'image - - - Downloading image... - Téléchargement de l'image... - - - Download failed - Échec du téléchargement - - - Failed to download image: %1 - Échec du téléchargement de l'image:%1 - - - Unknown error - Erreur inconnue - - - Save failed - Échec de l'enregistrement - - - Failed to save image: %1 - Échec enregistrement de l'image:%1 - - - Image downloaded - Image téléchargée - - - Invalid image URL - URL d'image non valide - - - Dropped data does not contain a supported image - Les données transférées ne contiennent pas d'image prise en charge - - - Failed to read image file - Échec de la lecture du fichier image - - - Uploading image... - Téléchargement d'une image... - - - Image updated - Image mise à jour - - - Upload failed - Échec du téléversement - - - Failed to upload image: %1 - Échec du téléchargement de l'image:%1 - - - Are you sure you want to remove the %1 image from "%2"? - Êtes-vous sûr de vouloir supprimer l'image %1 de “%2” ? - - - Remove + + Delete Supprimer - Cancel - Annuler - - - Removing image... - Supprimer l'image... - - - Image removed - Image supprimée - - - Remove failed - Supprimer les éléments ayant échoué - - - Failed to remove image: %1 - Échec de la suppression de l'image:%1 - - - No image uploaded for this type. - Aucune image n'a été téléchargée pour ce type. - - - No image uploaded for this type. -Choose a file, paste an image URL, or drag one onto the preview. - Aucune image n'a été téléchargée pour ce type. -Sélectionnez un fichier, collez URL d'une image ou faites-en glisser une. - - - Replace Image - Remplacer l'image - - - - - MediaService - - Invalid image URL - URL d'image non valide - - - Downloaded data is not a valid image - Les données téléchargées ne constituent pas une image valide - - - - - DetailView - - Loading... - Chargement en cours... - - - Error Loading Item - Erreur lors du chargement de l'élément - - - Click to copy number - Cliquez pour copier le numéro - - - Number copied: %1 - Nombre de copies:%1 - - - Seasons - Saisons - - - Cast & Crew - Distribution et équipe - - - Additional Parts - Pièces supplémentaires + + Playlist + Liste de lecture + Collection Collection - More Like This - Autres articles similaires + + item + élément - Episodes - Épisodes - - - Ep # - Ép. n° - - - 1-%1 - 1-%1 - - - Jump to episode number - Saisissez un numéro d'épisode puis validez - - - Jump to episode number (1-%1) - Saisissez un numéro d'épisode puis validez (1-%1) - - - Episode %1 not found - Épisode %1 introuvable - - - ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>More</a> - ... <a href=“more” style=“color:#3B82F6; text-decoration:none; font-weight:bold;”>En savoir plus</a> - - - %1 hr %2 min - %1 H %2 Mn - - - %1 min - %1 min - - - Actor - Acteur - - - Director - Directeur - - - Writer - Auteur - - - Producer - Producteur - - - Composer - Compositeur - - - Conductor - Chef d'orchestre - - - GuestStar - Invité vedette - - - Editor - Rédacteur - - - Lyricist - Parolier + + items + éléments - - DashboardView + CollectionsListModel + + + %n item(s) + + + + + + + + CollectionsViewModel + + + Failed to load playlists: %1 + Échec du chargement des listes de lecture: %1 + + + + Playlist created + Liste de lecture créée + + + + Failed to create playlist: %1 + + + + + Renamed successfully + Renommé avec succès + + + + Rename failed: %1 + Échec du changement de nom: %1 + + + + Deleted %n playlist(s) + + %n liste(s) de lecture supprimée(s) + + + + + + Deleted %1 playlist(s), then failed: %2 + Suppression de %1 liste(s) de lecture, puis échec : %2 + + + + Playlist order updated + Ordre de la liste de lecture mis à jour + + + + Failed to save playlist order: %1 + Échec de l'enregistrement de l'ordre de la liste de lecture : %1 + + + + ContextMenuUtils + + + Undo + Annuler + + + + Redo + Refaire + + + + Cut + Couper + + + + Copy + Copier + + + + Paste + Coller + + + + Delete + Supprimer + + + + Select All + Tout sélectionner + + + + DandanplayProvider + + + DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings. + L'API ouverte DandanPlay nécessite désormais un identifiant d'application et une clé secrète. Configurez-les dans les paramètres de Danmaku Server. + + + + DanmakuServerDialog + + + + + + + + Danmaku Servers + Serveurs Danmaku + + + + Manage multiple danmaku servers here. Click a server card to edit it and choose the active server used for matching and loading danmaku. + Gérez ici plusieurs serveurs Danmaku. Cliquez sur la fiche d'un serveur pour la modifier et sélectionner le serveur actif utilisé pour la mise en correspondance et le chargement de Danmaku. + + + Current Server + Serveur actuel + + + + Servers + Serveurs + + + + Each item shows the danmaku server name, address, app credentials, and description. The built-in official DandanPlay server supports anime only. + Chaque élément affiche le nom du serveur danmaku, son adresse, les identifiants de l'application et une description. Le serveur officiel DandanPlay intégré ne prend en charge que les anime. + + + + Add + Ajouter + + + + Remove + Supprimer + + + + Name + Nom + + + + e.g., Official DandanPlay + ex, DandanPlay officiel + + + + Server URL + URL du serveur + + + + Edit Selected Server + Modifier le serveur sélectionné + + + + 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. + Mettez à jour le nom, l'adresse et la description du serveur, ainsi que les identifiants de l'application pour le serveur danmaku sélectionné. Les points de terminaison officiels de DandanPlay nécessitent un ID d'application et une clé secrète, et ne prennent en charge que les anime. + + + + Description + Description + + + + e.g., Supports anime only + ex, ne prend en charge que les anime + + + + App ID + App ID + - Continue Watching - Continuer à regarder + + App Secret + App Secret + + + + + Required for official DandanPlay + Obligatoire pour l'application officielle DandanPlay + + + + + Cancel + Annuler + + + + Save + Enregistrer + + + + Danmaku Server %1 + Serveur Danmaku %1 + + + + Remove Danmaku Server + Supprimer le serveur Danmaku + + + + Remove "%1" from the danmaku server list? + Supprimer "%1" de la liste des serveurs danmaku ? + + + + + At least one danmaku server is required. + Il faut au moins un serveur Danmaku. + + + At least one enabled danmaku server is required. + Au moins un serveur Danmaku activé est requis. + + + + + + + + OK + OK + + + + Every danmaku server must have a name. + Tout serveur de danmaku doit avoir un nom. + + + + Every danmaku server must have a server URL. + Tout serveur Danmaku doit disposer d'une URL de serveur. + + + + Server URL must be a valid http or https address. + L'URL du serveur doit être une adresse http ou https valide. + + + + DanmakuServerListItemWidget + + + + Remove Server + Supprimer le serveur + + + At least one server is required. + Au moins un serveur est nécessaire. + + + + App ID: %1 + App ID:%1 - Latest Media - Derniers Medias + + Anime only + Uniquement les Anime - Recommended - Recommandé + + App Secret: %1 + App Secret:%1 + + + DashboardPage - Recommended Movies - Films recommandés + + Loading your home page… + - All Libraries - Toutes les bibliothèques + + Unable to load the home page + - See All > - Tout afficher > + + Continue Watching + Continuer à regarder - Scanning "%1"... - Analyse “%1”... + + Latest Media + Derniers Medias - Scan failed: %1 - Échec de l'analysation:%1 + + Recommended + Recommandé - Metadata saved - Métadonnées enregistrées + + Completed Watching + - Failed to update metadata: %1 - Échec de la mise à jour des métadonnées:%1 + + All Libraries + Toutes les bibliothèques - DashboardSectionOrderWidget + Drag the items below to change the order of home screen sections. Left to right matches top to bottom on the home screen. - Faites glisser les éléments ci-dessous pour modifier l'ordre des sections de l'écran d'accueil. L'ordre de gauche à droite correspond à celui de haut en bas sur l'écran d'accueil. + Faites glisser les éléments ci-dessous pour modifier l'ordre des sections de l'écran d'accueil. L'ordre de gauche à droite correspond à celui de haut en bas sur l'écran d'accueil. + Continue Watching Continuer à regarder + Latest Media Derniers Medias + Recommended Recommandé + + Completed Watching + + + + All Libraries Toutes les bibliothèques + Each Library Sections Chaque section de la bibliothèque %1. %2 - %1. %2 + %1. %2 - - FavoritesView + DashboardView - Favorite Movies - Mes films préférés + Continue Watching + Continuer à regarder - Favorite Shows - Séries préférées + Latest Media + Derniers Medias - Favorite Collections - Collections préférées + Recommended + Recommandé - Favorite Playlists - Listes de lecture préférées + Recommended Movies + Films recommandés - Favorite People - Mes personnes préférées - - - Favorite Folders - Dossiers favoris + All Libraries + Toutes les bibliothèques See All > - Tout afficher > + Tout afficher > + + + + + This action requires administrator privileges + Cette opération nécessite des droits d'administrateur + + + + this item + cet article + + + + Refreshing metadata for "%1"... + Actualisation des métadonnées pour “%1”... + + + + Scanning "%1"... + Analyse “%1”... + + + + Metadata refresh failed: %1 + + + + + Scan failed: %1 + Échec de l'analysation:%1 + + + Metadata saved + Métadonnées enregistrées + + + Failed to update metadata: %1 + Échec de la mise à jour des métadonnées:%1 - - SearchView + DashboardViewModel - Search Results - Résultats de la recherche + + Direct Play + Lecture directe - All - Tout + + Direct Stream + Diffusion en direct - Movies - Films + + Transcode + Transcodage - Shows - Séries + + Failed to load: %1 + Échec du chargement: %1 - Collections - Collections + + Restart Server + Redémarrer le serveur - People - Personnes + + Are you sure you want to restart the server? All active sessions will be terminated. + Êtes-vous sûr de vouloir redémarrer le serveur ? Toutes les sessions actives seront interrompues. - Toggle View Mode - Changer de mode d'affichage + + Restart + Redémarrer - 0 Results - 0 Résultats + + + Cancel + Annuler - Search: "%1" - Rechercher: "%1" + + Server restart command sent + Commande de redémarrage du serveur envoyée - Searching... - Recherche... + + Restart failed: %1 + Échec du redémarrage: %1 - %1 Results - %1 Résultats + + Shutdown Server + Arrêter le serveur + + + + Are you sure you want to shut down the server? This will stop all services and disconnect all clients. + Êtes-vous sûr de vouloir arrêter le serveur ? Cela mettra fin à tous les services et déconnectera tous les clients. + + + + Shutdown + Arrêt + + + + Server shutdown command sent + Commande d'arrêt du serveur envoyée + + + + Shutdown failed: %1 + L'arrêt a échoué: %1 - - SearchHistoryPopup + DetailActionWidget - Search History - Historique des recherches + + ▶ Resume + ▶ Reprendre - Recent - Récent + + + + + ▶ Play + ▶ Jouer - Count - Compte + + ↺ Play from Beginning + ↺ Lire depuis le début - Show More - Afficher plus + + ▶ Resume %1 + ▶ Reprendre %1 - Show Less - Afficher moins + + ↺ Restart %1 + ↺ Redémarrer %1 - Sort by recent searches - Trier par recherches récentes + + ▶ Play %1 + ▶ Jouer %1 - Sort by most searched - Trier par les plus recherchés + + No Subtitles + - Clear search history - Effacer l'historique de recherche + + + %1 remaining + %1 restant - Confirm clear all history - Confirmer la suppression de l'historique + + Version %1 + Version %1 - - - - CategoryView - Title - Titre + + Unknown Video + Vidéo inconnue - Date Added - Date d'ajout + 🚫 No Subtitles + 🚫 Sans sous-titres - Date Played - Date jouée - - - Runtime - Durée - - - Premiere Date - Date de diffusion - - - 0 Items - 0 Articles - - - Loading... - Chargement en cours... - - - %1 Items - %1 Articles - - - Toggle View Mode - Changer de mode d'affichage - - - Error Loading Items - Erreur lors du chargement des éléments - - - Refresh Recommendations - Actualiser les recommandations - - - - - PlayerView - - Continue Watching - Continuer à regarder - - - Episodes - Épisodes - - - No episodes found. - Aucun épisode trouvé. - - - No active media found. - Aucun média actif n'a été trouvé. - - - Network Speed - Vitesse du réseau - - - Network Speed Enabled - Vitesse du réseau activée - - - Network Speed Disabled - Vitesse du réseau désactivée - - - Statistics - Statistiques - - - File - Fichier - - - Session - Session - - - Item ID - Élément ID - - - Media Source ID - Source média ID - - - Play Session ID - Session de jeu ID - - - Cache - Cache - - - Playback - Lecture - - - Refresh Rate - Fréquence de rafraîchissement - - - Dropped Frames - Images perdues - - - Output - Sortie - - - Decoder - Décodeur - - - Color - Couleur - - - Channels - Chaînes - - - Sample Rate - Fréquence d'échantillonnage - - - Device - Appareil - - - Subtitle - Sous-titre - - - Speed - Vitesse - - - A-V - A-V - - - Software (CPU) - Logiciels (CPU) - - - %1-bit - %1-bit - - - AO %1 - AO %1 - - - %1 ch - %1 ch - - - Chapter - Chapitre - - - Size - Taille - - - Video - Vidéo - - - Audio - Audio - - - Frame Rate - Fréquence d'images - - - Resolution - Résolution - - - Format - Format - - - Bitrate - Débit binaire - - - STRM Direct Play - STRM Lecture directe - - - STRM Direct Play: ON (Reloading...) - STRM Lecture directe: activée (Rechargement...) - - - STRM Direct Play: OFF (Reloading...) - Lecture directe STRM : désactivée (Rechargement...) - - - Speed: %1X - Vitesse: %1X - - - Disable Audio - Désactiver le son - - - Track %1 - Piste %1 - - - Audio: %1 - Audio: %1 - - - No Audio Tracks - Aucune piste audio - - - Audio Disabled - Audio désactivé - - - Disable Subtitles - Désactiver les sous-titres - - - Subtitle %1 - Sous-titre %1 - - - Subtitle: %1 - Sous-titre: %1 - - - Subtitles Disabled - Sous-titres désactivés - - - External Sub %1 - Sous-titres externes %1 - - - Forward %1s - Avancer de %1 s - - - Rewind %1s - Reculer de %1 s - - - Scale: Fit - Échelle : Ajustée - - - Scale: Crop - Échelle : Recadrage - - - Scale: Stretch - Échelle : étirement - - - Scale: Original - Échelle : originale - - - Muted - En sourdine - - - Volume: %1% - Volume: %1% - - - Back - Retour - - - Minimize - Réduire - - - Maximize - Agrandir - - - Close - Fermer - - - Rewind - Revenir en arrière - - - Play/Pause - Lecture/Pause - - - Forward - Suivant - - - Mute/Unmute - Activer/désactiver le son - - - Playback Speed - Vitesse de lecture + + Disable subtitle display + Désactiver l'affichage des sous-titres + Audio Track Piste audio - Subtitles - Sous-titres + + Subtitle + Sous-titre - Settings - Paramètres + + Forced + Forcé - Aspect Ratio - Format d'image + + External + Externe - Fullscreen - Plein écran + + SDH + Sous-titres pour les sourds et malentendants + + + Standard Subtitle + Sous-titre standard + + + + %1 hr %2 min + %1 H %2 Mn + + + + %1 min + %1 min - - SettingsView + DetailBottomInfoWidget - Settings - Paramètres + + Tags + Étiquettes - General - Général + + Studios + Studios - Appearance - Apparence + + Media Info + Info Media - Player - Lecteur + + Added %1 + Ajouté %1 - Library - Bibliothèque + + Video + Vidéo - About - À propos - - - - - PageGeneral - - General - Général + + Audio + Audio - System - Système + + Subtitle + Sous-titre - English - Anglais + + Title + Titre - French - Français + + Codec + Codec + + + Language Langue - App interface language (requires restart) - Langue interface de l'application (nécessite un redémarrage) + + + Codec Tag + Balise de codec + + + Profile + Profil + + + + Level + Niveau + + + + Resolution + Résolution + + + + Aspect Ratio + Format d'image + + + + Interlaced + Entrelacé + + + + Framerate + Fréquence d'images + + + + + Bitrate + Débit binaire + + + + Bit Depth + Nombre de bits + + + + Pixel Format + Format de pixels + + + + Ref Frames + Références + + + + Layout + Mise en page + + + + Channels + Chaînes + + + + Sample Rate + Fréquence d'échantillonnage + + + + + Default + Par défaut + + + + + + + + + + + + Yes + Oui + + + + Index + Index + + + + Forced + Forcé + + + + Hearing Impaired + Personnes malentendantes + + + + External + Externe + + + + Text Stream + Flux de texte + + + + Ext. Stream Supported + Prise en charge des flux externes + + + + External URL + URL Externe + + + + Delivery Method + Mode de livraison + + + + Protocol + Protocole + + + + Ext. Video Type + Type de vidéo externe + + + + Ext. Video SubType + Sous-type de vidéo externe + + + + Size + Taille + + + + Path + Chemin + + + + Delivery URL + URL de livraison + + + + External Links + Liens externes + + + + DetailPage + + + Resume + + + + + Play + Jouer + + + + Play from beginning + + + + + Favorited + + + + + Favorite + + + + + Mark unplayed + + + + + Mark played + + + + + More + + + + + Version + Version + + + + Seasons + Saisons + + + + Episodes + Épisodes + + + + Cast & Crew + Distribution et équipe + + + + Additional Parts + Pièces supplémentaires + + + + Collection + Collection + + + + More Like This + Autres articles similaires + + + + Loading details + + + + + Unable to load details + + + + + DetailView + + Loading... + Chargement en cours... + + + Error Loading Item + Erreur lors du chargement de l'élément + + + Click to copy number + Cliquez pour copier le numéro + + + Number copied: %1 + Nombre de copies:%1 + + + Seasons + Saisons + + + Cast & Crew + Distribution et équipe + + + Additional Parts + Pièces supplémentaires + + + Collection + Collection + + + More Like This + Autres articles similaires + + + Episodes + Épisodes + + + Ep # + Ép. n° + + + 1-%1 + 1-%1 + + + Jump to episode number + Saisissez un numéro d'épisode puis validez + + + Jump to episode number (1-%1) + Saisissez un numéro d'épisode puis validez (1-%1) + + + Episode %1 not found + Épisode %1 introuvable + + + ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>More</a> + ... <a href=“more” style=“color:#3B82F6; text-decoration:none; font-weight:bold;”>En savoir plus</a> + + + %1 hr %2 min + %1 H %2 Mn + + + %1 min + %1 min + + + Actor + Acteur + + + Director + Directeur + + + Writer + Auteur + + + Producer + Producteur + + + Composer + Compositeur + + + Conductor + Chef d'orchestre + + + GuestStar + Invité vedette + + + Editor + Rédacteur + + + Lyricist + Parolier + + + + Unable to prepare playback + + + + + DetailViewModel + + + Version %1 + Version %1 + + + + Media service is unavailable + + + + + Unable to load this item + + + + + DownloadManager + + + Downloaded Media + Fichiers multimédias téléchargés + + + + + Failed to write download file + Échec de l'écriture du fichier téléchargé + + + + + + Downloading + Téléchargement + + + + Completed + Terminé + + + + Download completed + Téléchargement terminé + + + + Download failed + Échec du téléchargement + + + + + Failed to prepare download: %1 + Échec de la préparation du téléchargement: %1 + + + + This media is not available for download + Ce fichier n'est pas disponible au téléchargement + + + + Failed to resolve download address + Impossible de résoudre l'adresse de téléchargement + + + + Failed to create download directory + Impossible de créer le répertoire de téléchargement + + + + + Failed to open download file + Impossible d'ouvrir le fichier téléchargé + + + + Download started + Le téléchargement a commencé + + + + + + + Pausing + Mise en pause + + + + Paused + En pause + + + + + + + + + + Cancelling + Annulation + + + + + Cancelled + Annulé + + + + + Download cancelled + Téléchargement annulé + + + + Download paused + Téléchargement en pause + + + + Download resumed + Reprise du téléchargement + + + + Download removed + Téléchargement supprimé + + + + Switch back to the original server before resuming this download + Revenez au serveur d'origine avant de reprendre ce téléchargement + + + + The original media source is no longer available + La source d'origine n'est plus disponible + + + + DownloadManagerDialog + + + + Unknown + Inconnu + + + + B + B + + + + KB + KB + + + + MB + MB + + + + GB + GB + + + + TB + TB + + + %1% (%2 / %3) + %1%(%2 / %3) + + + Downloading %1% (%2 / %3) + Téléchargement %1%(%2 / %3) + + + %1 downloaded + %1 téléchargé + + + Downloading %1 + Téléchargement %1 + + + + %1 %2% (%3 / %4) + %1 %2%(%3 / %4) + + + + %1 %2 + %1 %2 + + + Pausing + Mise en pause + + + + Paused %1% (%2 / %3) + %1%(%2 / %3)en pause + + + + Paused %1 + %1 en pause + + + + Paused + En pause + + + + Waiting... + En attente... + + + + -- + -- + + + Downloads + Téléchargements + + + + Download Manager + Gestionnaire de téléchargement + + + Total %1 Active %2 Completed %3 Failed %4 + Total %1 En cours %2 Terminé %3 Échec %4 + + + + %1 records, %2 active, %3 completed, %4 failed + %1 enregistrements, %2 actifs, %3 terminés, %4 ayant échoué + + + No download records + Aucun enregistrement de téléchargement + + + + No records yet + Il n'y a pas encore d'enregistrements + + + Download tasks will appear here after you start them. + Les tâches de téléchargement s'afficheront ici une fois que vous les aurez lancées. + + + + Start a download and the record will appear here. + Lancez le téléchargement et le fichier apparaîtra ici. + + + + No. + Non. + + + Active + Actif + + + + Completed + Terminé + + + + Failed + Échec + + + Download Directory + Répertoire de téléchargement + + + Download Location + Emplacement de téléchargement + + + + Saved To + Enregistré dans + + + Choose where new downloads are saved. + Choisissez l'emplacement où enregistrer les nouveaux fichiers téléchargés. + + + + + Open Directory + Ouvrir un répertoire + + + + Set Directory + Définir le répertoire + + + Current Downloads + Téléchargements en cours + + + Stopping a task removes the unfinished file immediately. + L'interruption d'une tâche entraîne la suppression immédiate du fichier inachevé.。 + + + No active downloads + Aucun téléchargement en cours + + + + Media + Média + + + + Size + Taille + + + + Progress + Progression + + + + Started + Lancé + + + Status + Statut + + + Action + Action + + + Download History + Historique des téléchargements + + + Select a record to open the media, jump to its folder, or remove it. + Sélectionnez un élément pour ouvrir le fichier multimédia, accéder à son dossier ou le supprimer. + + + No download history + Aucun historique de téléchargement + + + Result + Résultat + + + + Finished + Terminé + + + No download record selected + Aucun enregistrement de téléchargement n'a été sélectionné + + + + + + Open Media + Ouvrir média + + + + Pause Download + Mettre en pause le téléchargement + + + + Resume Download + Reprendre le téléchargement + + + + + + Open Folder + Ouvrir le dossier + + + + + + + + Delete Record + Supprimer l'enregistrement + + + + Stop + Stop + + + + + + Stop Download + Arrêter le téléchargement + + + + Stop and remove the unfinished file + Arrêtez et supprimez le fichier inachevé + + + + Select Download Directory + Sélectionner le répertoire de téléchargement + + + + + The download folder is no longer available. + Le dossier de téléchargement n'est plus disponible. + + + + + The downloaded media file is no longer available. + Le fichier multimédia téléchargé n'est plus disponible. + + + + Also Delete File + Supprimer également le fichier + + + Also remove the local file when deleting a history record + Supprimez également le fichier local lors de la suppression d'un enregistrement de l'historique + + + + + Delete download record for "%1"? + Souhaitez-vous supprimer l'historique de téléchargement pour "%1"? + + + Delete download record for "%1" and remove the local file? + Supprimer l'historique de téléchargement pour "%1" et supprimer le fichier local ? + + + + Cancel active download for "%1" and remove the unfinished file? + Annuler le téléchargement en cours de "%1" et supprimer le fichier inachevé ? + + + + + + Delete + Supprimer + + + + + + + Cancel + Annuler + + + Delete File + Supprimer le fichier + + + Delete the downloaded file too? + Supprimer également le fichier téléchargé ? + + + Keep File + Conserver le fichier + + + Server: %1 +Status: %2 +Added: %3 +Finished: %4 +Size: %5 + Serveur: %1 +État: %2 +Ajouté: %3 +Terminé: %4 +Taille: %5 + + + Saved To: %1 + Enregistré dans: %1 + + + + EmbyCodecInfoDialog + + + Max Bitrate: + Débit maximal: + + + + Frame Sizes: + Tailles d'images: + + + + Frame Rates: + Fréquences d'images: + + + + Max Instances: + Nombre maximal d'instances: + + + + Color Formats: + Formats de couleur: + + + + Profile + Profil + + + + Max Level + Niveau maximum + + + + Max Bitrate + + + + + Bit Depths + Nombre de bits + + + + Resolutions + Résolutions + + + + max %1x%2 + max %1x%2 + + + + %1fps...%2fps + %1fps...%2fps + + + + max %1fps + max %1fps + + + + %1fps + %1fps + + + + EmbyCodecParametersDialog + + + Codec Settings + Paramètres du codec + + + + + Loading codec parameters... + Chargement des paramètres du codec... + + + + Basic Parameters + Paramètres de base + + + + Advanced Parameters + Paramètres avancés + + + + Cancel + Annuler + + + + Reset to Defaults + Réinitialiser les paramètres par défaut + + + + + Save + Enregistrer + + + + No editable parameters available for this codec. + Aucun paramètre modifiable n'est disponible pour ce codec. + + + + Saving... + Enregistrement en cours... + + + + + Failed to load codec parameters + Échec du chargement des paramètres du codec + + + + Failed to load codec parameters: %1 + Échec du chargement des paramètres du codec:%1 + + + + Codec parameters saved + Paramètres du codec enregistrés + + + + Failed to save codec parameters: %1 + Échec de l'enregistrement des paramètres du codec:%1 + + + + ErrorState + + + Something went wrong + + + + + Try again + + + + + FavoritesPage + + + Favorites + Favoris + + + + Refreshing… + + + + + Refresh + Actualiser + + + + Try again + + + + + Favorite Movies + Mes films préférés + + + + Favorite Shows + Séries préférées + + + + Favorite Collections + Collections préférées + + + + Favorite Playlists + Listes de lecture préférées + + + + Favorite People + Mes personnes préférées + + + + Favorite Folders + Dossiers favoris + + + + Loading favorites… + + + + + Unable to load favorites + + + + + No favorites yet + + + + + Items you mark as favorites will appear here. + + + + + FavoritesView + + Favorite Movies + Mes films préférés + + + Favorite Shows + Séries préférées + + + Favorite Collections + Collections préférées + + + Favorite Playlists + Listes de lecture préférées + + + Favorite People + Mes personnes préférées + + + Favorite Folders + Dossiers favoris + + + See All > + Tout afficher > + + + + FavoritesViewModel + + + movies + + + + + shows + + + + + collections + + + + + playlists + + + + + people + + + + + folders + + + + + Some favorites could not be loaded: %1 + + + + + Favorite Movies + Mes films préférés + + + + Favorite Shows + Séries préférées + + + + Favorite Collections + Collections préférées + + + + Favorite Playlists + Listes de lecture préférées + + + + Favorite People + Mes personnes préférées + + + + Favorite Folders + Dossiers favoris + + + + FetcherRowWidget + + + Move up + Monter + + + + Move down + Descendre + + + + HomeQuickRouterView + + + + This action requires administrator privileges + Cette opération nécessite des droits d'administrateur + + + + this item + cet article + + + + Refreshing metadata for "%1"... + Actualisation des métadonnées pour “%1”... + + + + Scanning "%1"... + + + + + Metadata refresh failed: %1 + + + + + Scan failed: %1 + + + + + HomeView + + + Search... + Rechercher... + + + + Search + Rechercher + + + + Home + Accueil + + + + Favorites + Favoris + + + + MEDIA + MEDIA + + + + + Settings + Paramètres + + + + Manage + Gérer + + + + Downloads + Téléchargements + + + + Cloud Sync (WebDAV) + + + + + Logout + Déconnexion + + + + My Server + Mon serveur + + + + Administrator + Administrateur + + + + User + Utilisateur + + + + Refreshing Home... + Actualisation Accueil... + + + + Refreshing Favorites... + Actualisation Favoris... + + + This feature is under development + Cette fonctionnalité est en cours de développement + + + + Server Management + Gestion des serveurs + + + + LibraryActionMenu + + Edit Metadata + Modifier les métadonnées + + + + Edit Images + Modifier les images + + + + Refresh Metadata + Actualiser les métadonnées + + + + Scan Library Files + Analyser les fichiers de la bibliothèque + + + + LibraryCard + + + Edit + Modifier + + + + Scan + Analyser + + + + Delete + Supprimer + + + + path + chemin + + + + paths + chemins + + + + LibraryCreateDialog + + + Create Library + Créer une bibliothèque + + + + + Cancel + Annuler + + + + Create + Créer + + + + Enter library name... + Entrez le nom de la bibliothèque... + + + + 🎬 Movies + 🎬 Films + + + + 📺 TV Shows + 📺 Séries + + + + 🎵 Music + 🎵 Musique + + + + 🎞️ Home Videos + 🎞️ Vidéos familiales + + + + 📚 Books + 📚 Livres + + + + 🎤 Music Videos + 🎤 Vidéos musicales + + + + 📷 Photos + 📷 Photos + + + + 📦 Mixed + 📦 Mixte + + + + Media Paths: + Chemins d'accès aux médias: + + + + Library Settings + Paramètres de la bibliothèque + + + + Enable real-time monitoring + Activer la surveillance en temps réel + + + + + Automatically detect file changes in library folders + Détecter automatiquement les modifications apportées aux fichiers dans les dossiers de la bibliothèque + + + + Prefer embedded titles over filenames + Préférez les titres intégrés aux noms de fichiers + + + + + Use titles stored in media file metadata instead of filenames + Utiliser les titres enregistrés dans les métadonnées des fichiers multimédias plutôt que les noms de fichiers + + + + Preferred metadata language: + Langage de métadonnées préféré : + + + + + + Server Default + Paramètres par défaut du serveur + + + + + English + Anglais + + + + + Chinese (Simplified) + Chinois (simplifié) + + + + + Chinese (Traditional) + Chinois (traditionnel) + + + + + Chinese (Hong Kong) + Chinois (Hong Kong) + + + + + Japanese + Japonais + + + + + Korean + Coréen + + + + + French + Français + + + + + German + Allemand + + + + + Spanish + Espagnol + + + + + Russian + Russe + + + + + Portuguese + Portugais + + + + + Italian + Italien + + + + Preferred country/region: + Pays/région préféré(e) : + + + + United States + États-Unis + + + + China + Chine + + + + Taiwan + Taïwan + + + + Hong Kong + Hong Kong + + + + Japan + Japon + + + + South Korea + Corée du Sud + + + + United Kingdom + Royaume-Uni + + + + France + France + + + + Germany + Allemagne + + + + Every 14 days + + + + + Preferred image language: + Langue de l'image souhaitée : + + + + Save metadata as Nfo + Enregistrer les métadonnées au format Nfo + + + + + Save metadata in NFO format alongside media files + Enregistrer les métadonnées au format NFO avec les fichiers multimédias + + + + Nfo metadata reader + Lecteur de métadonnées Nfo + + + + + Sort preferred local metadata sources by priority. The first file found will be read. + Triez les sources de métadonnées locales préférées par ordre de priorité. Le premier fichier trouvé sera lu. + + + + Allow adult metadata + Autoriser les métadonnées pour adultes + + + + + Allow matching adult titles when searching for metadata on the internet + Autoriser la recherche de titres pour adultes lors de la recherche de métadonnées sur Internet + + + + Import collection info from metadata downloaders + Importer les informations relatives aux collections à partir d'outils de téléchargement de métadonnées + + + + + Automatically create collections based on metadata + Créer automatiquement des collections à partir des métadonnées + + + + Automatically group into collections + Regrouper automatiquement dans des collections + + + + + Automatically group movies into collections when enough related items exist + Regrouper automatiquement les films dans des collections lorsqu'il y a suffisamment d'éléments associés + + + + Minimum collection size: + Taille minimale de la collection: + + + + Automatic metadata refresh: + Actualisation automatique des métadonnées: + + + + Placeholder metadata refresh: + Actualisation des métadonnées des espaces réservés: + + + + + + Never + Jamais + + + + As a scheduled task + Sous forme de tâche planifiée + + + + On library scan and scheduled task + Lors de l'analyse de la bibliothèque et des tâches planifiées + + + + Every 2 days + Tous les 2 jours + + + + Every 3 days + Tous les 3 jours + + + + Every 7 days + Tous les 7 jours + + + + + Every 30 days + Tous les 30 jours + + + + + Every 60 days + Tous les 60 jours + + + + + Every 90 days + Tous les 90 jours + + + + + Metadata fetchers: + Outils de récupération de métadonnées: + + + + Series metadata fetchers: + Outils de récupération des métadonnées de séries: + + + + Season metadata fetchers: + Outils de récupération des métadonnées des saisons: + + + + Episode metadata fetchers: + Outils de récupération des métadonnées des épisodes: + + + + Images + Images + + + + Enable photos + Activer les photos + + + + + Allow photo items to be included in this library + Autoriser l'ajout d'éléments photo à cette bibliothèque + + + + Import playlists + Importer des listes de lecture + + + + + Import playlist files that are found in the selected folders + Importer les fichiers de listes de lecture qui se trouvent dans les dossiers sélectionnés + + + + Exclude from global search + Exclure de la recherche globale + + + + + Hide items in this library from global search results + Masquer les éléments de cette bibliothèque dans les résultats de recherche généraux + + + + Merge top folders in folder view + Fusionner les dossiers supérieurs dans l'affichage des dossiers + + + + + Flatten the top-level folders when browsing in folder view + Aplatir les dossiers de niveau supérieur lors de la navigation en mode arborescence + + + + Music folder structure: + Structure des dossiers de musique: + + + + Other or unstructured + Autres ou non structurés + + + + Perfect artist/album/track + Artiste/album/titre idéal + + + + Perfect album/track + Album/titre idéal + + + + Save artwork into media folders + Enregistrer les illustrations dans des dossiers multimédias + + + + Save local metadata + Enregistrer les métadonnées locales + + + + + Store downloaded metadata and images alongside media files + Enregistrer les métadonnées et les images téléchargées avec les fichiers multimédias + + + + + Store metadata images alongside media files + Enregistrer les images de métadonnées avec les fichiers multimédias + + + + Cache images in server data + Mettre les images en cache dans les données du serveur + + + + + Store generated and downloaded images in the server data folder + Enregistrer les images générées et téléchargées dans le dossier de données du serveur + + + + Save metadata as hidden files + Enregistrer les métadonnées sous forme de fichiers cachés + + + + + Mark locally saved metadata files as hidden on supported servers + Marquer les fichiers de métadonnées enregistrés localement comme masqués sur les serveurs pris en charge + + + + Download images in advance + Télécharger les images à l'avance + + + + + Download all available images during library scan + Télécharger toutes les images disponibles lors de l'analyse de la bibliothèque + + + + Extract chapter images + Extraire les images des chapitres + + + + + Generate thumbnail images for video chapters + Générer des vignettes pour les chapitres vidéo + + + + Generate video preview thumbnails: + Générer des vignettes d'aperçu vidéo: + + + + Thumbnail interval: + Intervalle entre les vignettes: + + + + 10 seconds + 10 secondes + + + + Chapter markers + Marqueurs de chapitre + + + + Save preview thumbnails into media folders + Enregistrer les vignettes d'aperçu dans les dossiers multimédias + + + + + Store generated video preview thumbnail sets alongside media files + Enregistrer les séries de vignettes d'aperçu vidéo générées avec les fichiers multimédias + + + + Generate chapters for videos + Créer des chapitres pour les vidéos + + + + + Create chapter markers automatically for supported videos + Créer automatiquement des marqueurs de chapitre pour les vidéos prises en charge + + + + Generated chapter interval: + Intervalle entre les chapitres généré: + + + + %1 minutes + %1 minutes + + + + Generate intro video markers: + Générer des marqueurs pour la vidéo d'introduction: + + + + + Image fetchers: + Outils de récupération d'images: + + + + Series image fetchers: + Récupérateurs d'images par série: + + + + Season image fetchers: + Récupérateurs d'images de saison: + + + + Episode image fetchers: + Outils de récupération d'images d'épisodes: + + + + Subtitles + Sous-titres + + + + Subtitle downloaders: + Logiciels de téléchargement de sous-titres: + + + + Require hash match for subtitle downloads + Exiger la correspondance du hachage pour le téléchargement des sous-titres + + + + + Only download subtitles when the provider reports a matching file hash + Ne téléchargez les sous-titres que lorsque le fournisseur indique que le hachage du fichier correspond + + + + Search only for forced subtitles + Rechercher uniquement les sous-titres forcés + + + + + Restrict automatic subtitle downloads to forced subtitle tracks + Limiter le téléchargement automatique des sous-titres aux pistes de sous-titres forcés + + + + Preferred subtitle language: + Langue préférée pour les sous-titres: + + + + Save subtitles into media folders + Enregistrer les sous-titres dans des dossiers multimédias + + + + + Download subtitles and save them alongside media files + Télécharger les sous-titres et les enregistrer avec les fichiers multimédias + + + + Skip if audio track matches download language + Ignorer si la piste audio correspond à la langue du téléchargement + + + + + Uncheck to ensure all videos have subtitles regardless of audio language + Décochez cette case pour vous assurer que toutes les vidéos disposent de sous-titres, quelle que soit la langue de l'audio + + + + Skip if video has embedded subtitles + Ignorer si la vidéo comporte des sous-titres intégrés + + + + + Keeping text versions of subtitles will improve delivery efficiency and reduce likelihood of video transcoding + La conservation de versions textuelles des sous-titres permettra d'améliorer l'efficacité de la diffusion et de réduire le risque de transcodage vidéo + + + + Stop downloading subtitles older than: + Ne téléchargez plus les sous-titres datant d'avant: + + + + + No Limit + Sans limite + + + + + 14 days + 14 jours + + + + + 30 days + 30 jours + + + + + 60 days + 60 jours + + + + + 90 days + 90 jours + + + + + 120 days + 120 jours + + + + + 180 days + 180 jours + + + + Lyrics + Paroles + + + + Lyrics fetchers: + Moteurs de recherche de paroles: + + + + Preferred lyrics language: + Langue préférée pour les paroles: + + + + Stop downloading lyrics older than: + Ne plus télécharger les paroles datant d'avant: + + + + Save lyrics into media folders + Enregistrer les paroles dans des dossiers multimédias + + + + + Store downloaded lyrics files alongside media files + Enregistrer les fichiers de paroles téléchargés avec les fichiers multimédias + + + + File Organization + Organisation des fichiers + + + + Ignore sample files larger than: + Ignorer les fichiers d'exemple dont la taille dépasse: + + + + MB + MB + + + + Enable Plex .ignore compatibility + Activer la compatibilité avec les fichiers .ignore de Plex + + + + + Honor Plex .ignore files and folders while scanning + Prendre en compte les fichiers et dossiers .ignore lors de l'analyse + + + + Automatically group series + Regrouper automatiquement les séries + + + + + Try to group episodes and seasons into series automatically + Essayez de regrouper automatiquement les épisodes et les saisons en séries + + + + Enable multi-part items + Activer les articles en plusieurs parties + + + + + Recognize split video files as a single media item + Reconnaître les fichiers vidéo fractionnés comme un seul élément multimédia + + + + Multi-version item detection: + Détection des éléments à versions multiples: + + + + Both files and metadata + Les fichiers et les métadonnées + + + + Files only + Fichiers uniquement + + + + Metadata only + Métadonnées uniquement + + + + None + Aucun + + + + Playback + Lecture + + + + Minimum resume percentage: + Pourcentage minimum requis: + + + + If stopped before this point, the item will be marked as unplayed + Si vous arrêtez la lecture avant ce point, le titre sera marqué comme non écouté + + + + Maximum resume percentage: + Pourcentage maximal de reprise: + + + + If stopped after this point, the item will be marked as played + Si vous arrêtez la lecture après ce point, le titre sera marqué comme ayant été écouté + + + + Minimum resume duration (seconds): + Durée minimale de la reprise (en secondes): + + + + s + s + + + + Items shorter than this duration cannot be resumed + Les éléments dont la durée est inférieure à celle-ci ne peuvent pas être repris + + + + No Media Paths + Aucun chemin d'accès aux médias + + + + No media paths have been added. The library will be created without any content folders. + +Do you want to continue? + Aucun chemin d'accès aux fichiers multimédias n'a été ajouté. La bibliothèque sera créée sans aucun dossier de contenu. + +Souhaitez-vous continuer? + + + + Continue + Continuer + + + + LibraryEditDialog + + + Edit Library — "%1" + + + + + Save + Enregistrer + + + + LibraryMetadataEditDialog + + + Edit Metadata + Modifier les métadonnées + + + + Basic Information + Information de base + + + + Classification + Classification + + + + Ratings & Release + Notes et versions + + + + External IDs + IDs externes + + + + Music Metadata + Métadonnées musicales + + + + Additional Details + Informations complémentaires + + + + Metadata Settings + Paramètres des métadonnées + + + + Field Locks + Verrous de champ + + + + Path + Chemin + + + + + Name + Nom + + + + + Original Title + Titre original + + + + + Sort Name + Trier par nom + + + + + Tagline + Slogan + + + + + Genres + Genres + + + + + Tags + Mots-clés + + + + + Studios + Studios + + + + Date Added + Date d'ajout + + + + + Production Year + Année de production + + + + + Premiere Date + Date de diffusion + + + + + End Date + Date de fin + + + + + Status + Statut + + + + + Runtime (Minutes) + Durée (en minutes) + + + + + Official Rating + Classement officiel + + + + + Custom Rating + Évaluation personnalisée + + + + + Community Rating + Évaluation par la communauté + + + + + Critic Rating + Note des critiques + + + + IMDb Id + IMDb Id + + + + MovieDb Id + MovieDb Id + + + + TheTVDB Id + TheTVDB Id + + + + + Album + Album + + + + + Album Artist + Artiste de l'album + + + + + Artists + Artistes + + + + + Composers + Compositeurs + + + + + Channel Number + Numéro de chaîne + + + + + Place of Birth + Lieu de naissance + + + + + Parent Index Number + Numéro d'index du parent + + + + + Index Number + Numéro d'index + + + + Preferred Metadata Language + Langage de métadonnées privilégié + + + + Metadata Country Code + Code pays des métadonnées + + + + + Overview + Aperçu + + + + Lock metadata + Verrouiller les métadonnées + + + + + + + + Comma separated values + Valeurs séparées par des virgules + + + + + yyyy-MM-dd + yyyy-MM-dd + + + yyyy-MM-ddTHH:mm:ss + yyyy-MM-ddTHH:mm:ss + + + + yyyy-MM-dd HH:mm:ss + yyyy-MM-dd HH:mm:ss + + + + Minutes + Minutes + + + + 0-10 + 0-10 + + + + 0-100 + 0-100 + + + + Continuing / Ended + En cours / Terminé + + + + en-US + en-US + + + + US + US + + + + Cancel + Annuler + + + + Save + Enregistrer + + + + LibraryPage + + + Date Added + Date d'ajout + + + + Date Played + + + + + Title + Titre + + + + Runtime + Durée + + + + Premiere Date + Date de diffusion + + + + Recent + + + + + Playlists + Listes de lecture + + + + Collections + Collections + + + + Favorites + Favoris + + + + Folders + Dossiers + + + + ♥ Favorite + + + + + ♡ Favorite + + + + + Refresh + Actualiser + + + + Descending + + + + + Ascending + + + + + Poster + + + + + Tile + + + + + List + + + + + + + This library is empty + + + + + + + Try another filter or refresh the library. + + + + + Loading more… + + + + + Load more + + + + + LibraryView + + All + Tout + + + Movies + Films + + + Shows + Séries + + + Music + Musique + + + Videos + Vidéos + + + Recent + Récents + + + Playlists + Listes de lecture + + + Lists + Listes + + + Collections + Collections + + + Favorites + Favoris + + + Folders + Dossiers + + + Title + Titre + + + Date Added + Date d'ajout + + + Date Played + Date de lecture + + + Runtime + Durée + + + Premiere Date + Date de diffusion + + + 0 Items + 0 article + + + Loading... + Chargement en cours... + + + %1 Items + %1 articles + + + %1 / %2 Items + %1 / %2 articles + + + Toggle View Mode + Changer de mode d'affichage + + + Error Loading Items + Erreur lors du chargement des éléments + + + + LibraryViewModel + + + 0 Items + + + + + + + All + Tout + + + + Movies + Films + + + + Shows + Séries + + + + Music + Musique + + + + Videos + Vidéos + + + + %1 / %2 Items + %1 / %2 articles + + + + %1 Items + + + + + + Loading... + Chargement en cours... + + + + Unable to load library items + + + + + Error Loading Items + Erreur lors du chargement des éléments + + + + LicensesDialog + + + Open Source Licenses + + + + + LoadingState + + + Loading + + + + + LoginPage + + + Connect to Server + Se connecter au serveur + + + + Select Server + Sélectionner un serveur + + + + Enter your server details to continue + + + + + Choose where you want to watch + + + + + + Add New Server + + + + + Edit + Modifier + + + + Remove + Supprimer + + + + Server Address (example.com/emby) + + + + + Port + Port + + + + Proxy… + + + + + Ignore SSL certificate verification + Ignorer la vérification du certificat SSL + + + + Username + Nom d'utilisateur + + + + Password + Mot de passe + + + + Hide + + + + + Show + + + + + Cancel + Annuler + + + + Connecting… + + + + + Save & Login + Enregistrer et se connecter + + + + Login + Connexion + + + + Cloud Sync + + + + + LoginView + + Login + Connexion + + + Select Server + Sélectionner un serveur + + + Connect to Server + Se connecter au serveur + + + Server Address (e.g. 192.168.1.1 or domain) + Adresse du serveur (ex: 192.168.1.1 ou domaine) + + + Server URL (e.g. example.com, example.com/emby or https://example.com) + URL du serveur (ex: example.com, example.com/emby ou https://example.com) + + + Server URL + Serveur URL + + + Server Address + Adresse du serveur + + + Server Address (e.g. example.com/emby or 192.168.1.1/jellyfin) + Adresse du serveur(ex: example.com/emby ou 192.168.1.1/jellyfin) + + + Port (e.g. 8096) + Port (ex: 8096) + + + Port + Port + + + Ignore SSL certificate verification + Ignorer la vérification du certificat SSL + + + Use this only for trusted self-signed or private CA HTTPS servers. + N'utilisez cette option que pour les serveurs HTTPS auto-signés ou gérés par une autorité de certification privée de confiance. + + + Only for trusted self-signed or private CA servers. + Uniquement pour les serveurs de confiance auto-signés ou gérés par une autorité de certification privée. + + + Username + Nom d'utilisateur + + + Password + Mot de passe + + + Show Password + Afficher le mot de passe + + + Cancel + Annuler + + + Edit + Modifier + + + Delete + Supprimer + + + Add New Server + Ajouter un nouveau serveur + + + Add New Server + Ajouter un nouveau serveur + + + Connect to another server + Se connecter à un autre serveur + + + Save & Login + Enregistrer et se connecter + + + Remove Server + Supprimer le serveur + + + Are you sure you want to remove this server? +This action cannot be undone. + Êtes-vous sûr de vouloir supprimer ce serveur ? +Cette action ne peut pas être annulée. + + + Remove + Supprimer + + + Server address and username cannot be empty. + Adresse du serveur et nom utilisateur ne peuvent pas être laissés vides. + + + Server URL and username cannot be empty. + URL du serveur et nom utilisateur ne peuvent pas être laissés vides. + + + Please enter a valid server URL. + Veuillez saisir une URL de serveur valide. + + + Please enter a valid server address. + Veuillez saisir une adresse de serveur valide. + + + Please enter a valid port number. + Veuillez saisir un numéro de port valide. + + + Server URL must use http:// or https://. + L'URL du serveur doit utiliser http:// ou https://. + + + Connecting... + Connexion en cours... + + + Connection Failed + Échec de la connexion + + + Failed to connect or session expired. Please log in again. +Error: + Échec de la connexion ou session expirée. Veuillez vous reconnecter. +Erreur : + + + + qEmby + + + + + LoginViewModel + + + Remove Server + Supprimer le serveur + + + + Are you sure you want to remove this server? +This action cannot be undone. + Êtes-vous sûr de vouloir supprimer ce serveur ? +Cette action ne peut pas être annulée. + + + + Remove + Supprimer + + + + Cancel + Annuler + + + + Please enter a valid server address. + Veuillez saisir une adresse de serveur valide. + + + + Please enter a valid HTTP or HTTPS server address. + + + + + Please enter a valid port number. + Veuillez saisir un numéro de port valide. + + + + Failed to connect or session expired. Please log in again. +Error: %1 + + + + + Server address and username cannot be empty. + Adresse du serveur et nom utilisateur ne peuvent pas être laissés vides. + + + + Using global proxy + + + + + No proxy + + + + + System proxy + + + + + Custom (incomplete) + + + + + LogsViewModel + + + Choose a log file from the list to inspect the latest server output. + Sélectionnez un fichier journal dans la liste pour consulter les dernières informations générées par le serveur. + + + + Modified %1 + %1 modifié + + + + Save Log File + Enregistrer le fichier journal + + + + Text Files (*.txt);;All Files (*) + Fichiers texte (*.txt) ;; Tous les fichiers (*) + + + + No files + Aucun fichier + + + + %n file(s) + + %n fichier(s) + + + + + + Failed to load log files: %1 + Échec du chargement des fichiers journaux: %1 + + + + Failed to load: %1 + Échec du chargement: %1 + + + + Log file "%1" saved + Fichier journal “%1” enregistré + + + + Failed to save log file: %1 + Échec de l'enregistrement du fichier journal: %1 + + + + MainWindow + + + Press Back again to logout + Appuyez à nouveau sur Retour pour vous déconnecter + + + + Refreshing Home... + Actualisation Accueil... + + + + Refreshing Favorites... + Actualisation Favoris... + + + + Search... + Recherche... + + + + Search + Recherche + + + + ManageDashboardPage + + + Dashboard + Tableau de bord + + + + Server Information + Informations sur le serveur + + + + Server Name + Nom du serveur + + + + Version + Version + + + + Operating System + Système d'exploitation + + + + Address + Adresse + + + + Now Playing + En cours de lecture + + + + Nothing is playing + Rien n'est en cours de lecture + + + + Active Sessions + Sessions actives + + + + No active sessions + Aucune session active + + + + Activity Log + Journal d'activité + + + + No activity records + Aucun enregistrement d'activité + + + + ManagePage + + + Management + Gestion + + + + Dashboard + Tableau de bord + + + + Libraries + Bibliothèques + + + + Lists + Listes + + + + Transcoding + Transcodage + + + + Users + Utilisateurs + + + + Tasks + Tâches + + + + Logs + Journaux + + + + ManageRouteController + + + Select Folder + + + + + Select File + + + + + ManageView + + + Management + Gestion + + + + Dashboard + Tableau de bord + + + + Libraries + Bibliothèques + + + + Lists + Listes + + + + Users + Utilisateurs + + + + Tasks + Tâches + + + + Logs + Journaux + + + + Transcoding + Transcodage + + + Lists + Listes + + + Transcoding + Transcodage + + + Users + Utilisateurs + + + Tasks + Tâches + + + Logs + Journaux + + + + MediaActionMenu + + + Play + Jouer + + + + Play with %1 + Jouer avec %1 + + + + View Details + Voir les détails + + + + Download + Télécharger + + + + Add to Playlist + Ajouter à la liste de lecture + + + + Remove from Playlist + Supprimer de la liste de lecture + + + + Add to Favorites + Ajouter aux Favoris + + + + Remove from Favorites + Supprimer des Favoris + + + + Mark as Played + Marquer comme vu + + + + Mark as Unplayed + Marquer comme non vu + + + + Remove from Continue Watching + Retirer de la liste « À regarder » + + + + Edit Metadata + Modifier les métadonnées + + + + Edit Images + Modifier les images + + + + Delete Playlist + Supprimer la liste de lecture + + + + Identify + Identifier + + + + Refresh Metadata + Actualiser les métadonnées + + + + Remove Media + Supprimer Media + + + + MediaCard + + + Media + Média + + + + Play %1 + + + + + Remove %1 from favorites + + + + + Add %1 to favorites + + + + + More actions for %1 + + + + + MediaCardDelegate + + + + No Image + Pas d'image + + + + %1 min + %1 min + + + + MediaGrid + + + No media + + + + + Loading media… + + + + + Unable to load media + + + + + MediaIdentifyDialog + + + Unknown Title + Titre inconnu + + + + Provider IDs: %1 + Fournisseur ID:%1 + + + + Album Artist: %1 + Artiste de l'album:%1 + + + + Artists: %1 + Artistes:%1 + + + + Identify + Identifier + + + + this item + cet article + + + + Search remote metadata matches for "%1" and apply the correct result. + Rechercher les correspondances de métadonnées distantes pour “%1” et appliquer le résultat approprié. + + + + Enter title or keyword + Entrez un titre ou un mot-clé + + + + IMDb Id (optional) + IMDb Id(facultatif) + + + + MovieDb Id (optional) + MovieDb Id(facultatif) + + + + TheTVDB Id (optional) + TheTVDB Id(facultatif) + + + + Any + Tout + + + + Search + Rechercher + + + + + Enter a title or provider ID to search. + Saisissez un titre ou un identifiant de fournisseur pour lancer la recherche. + + + + Cancel + Annuler + + + + Apply Match + Appliquer la correspondance + + + + + + + + + + Preview unavailable + Aperçu indisponible + + + + + Loading preview... + Chargement de l'aperçu... + + + + Searching... + Recherche en cours... + + + + No matches found + Aucun résultat trouvé + + + + Found %1 matches + %1 résultats trouvés + + + + Search failed + La recherche a échoué + + + + Failed to search metadata: %1 + Échec de la recherche dans les métadonnées:%1 + + + + Applying match... + Applique correspondance... + + + + Metadata updated + Métadonnées mises à jour + + + + Apply failed + L'application a échoué + + + + Failed to apply match: %1 + Échec de l'application de la correspondance:%1 + + + + MediaImageEditDialog + + + Primary + Primaire + + + + Backdrop + Contexte + + + + Banner + Bannière + + + + Thumb + Miniature + + + + Logo + Logo + + + + Art + Art + + + + Disc + Disque + + + + Box + Boîte + + + + Box Rear + Arrière de la boîte + + + + Menu + Menu + + + + Screenshot + Capture d'écran + + + + Chapter + Chapitre + + + + Profile + Profil + + + + No image + Pas d'image + + + + %1 image(s) available + %1 image(s) disponible(s) + + + + %1 %2 + %1 %2 + + + + %1 image(s) + %1 image(s) + + + + %1 × %2 + %1 × %2 + + + + Index %1 + Index %1 + + + + Image Files (%1);;All Files (*) + Fichiers image (%1);;Tous les fichiers (*) + + + + Edit Images + Modifier les images + + + + + + this item + cet article + + + + Manage image types for "%1". Changes are applied immediately. + Gérer les types d'images pour “%1”. Les modifications sont appliquées immédiatement. + + + + + Loading image information... + Chargement des informations sur l'image... + + + + Image Types + Types d'images + + + + Drop an image file or image URL here to replace it. + Faites glisser un fichier image ou son URL ici pour le remplacer. + + + + Choose Image + Choisissez une image + + + + Image URL + URL de l'image + + + + Download + Télécharger + + + Close + Fermer + + + + + Remove Image + Supprimer l'image + + + Upload Image + Importer une image + + + + + + + + Preview unavailable + Aperçu indisponible + + + + Loaded images for %1 type(s) + Images chargées pour %1 type(s) + + + + No images found yet + Aucune image trouvée pour l'instant + + + + Failed to load images + Impossible de charger les images + + + + Failed to load images: %1 + Impossible de charger les images:%1 + + + + No image for this type + Aucune image pour ce type + + + + + Loading preview... + Chargement de l'aperçu... + + + + Select Image + Sélectionner une image + + + + Enter Image URL + Saisissez URL de l'image + + + + Paste an image address for the %1 image of "%2". + Collez l'adresse de l'image %1 de “%2”. + + + + https://example.com/image.jpg + https://example.com/image.jpg + + + + Save Image + Enregistrer l'image + + + + + Downloading image... + Téléchargement de l'image... + + + + + Download failed + Échec du téléchargement + + + + + Failed to download image: %1 + Échec du téléchargement de l'image:%1 + + + + Unknown error + Erreur inconnue + + + + Save failed + Échec de l'enregistrement + + + + Failed to save image: %1 + Échec enregistrement de l'image:%1 + + + + + Image downloaded + Image téléchargée + + + + Invalid image URL + URL d'image non valide + + + + Dropped data does not contain a supported image + Les données transférées ne contiennent pas d'image prise en charge + + + + + + + Failed to read image file + Échec de la lecture du fichier image + + + + Uploading image... + Téléchargement d'une image... + + + + Image updated + Image mise à jour + + + + Upload failed + Échec du téléversement + + + + Failed to upload image: %1 + Échec du téléchargement de l'image:%1 + + + + Are you sure you want to remove the %1 image from "%2"? + Êtes-vous sûr de vouloir supprimer l'image %1 de “%2” ? + + + + Remove + Supprimer + + + + Cancel + Annuler + + + + Removing image... + Supprimer l'image... + + + + Image removed + Image supprimée + + + + Remove failed + Supprimer les éléments ayant échoué + + + + Failed to remove image: %1 + Échec de la suppression de l'image:%1 + + + No image uploaded for this type. + Aucune image n'a été téléchargée pour ce type. + + + + No image uploaded for this type. +Choose a file, paste an image URL, or drag one onto the preview. + Aucune image n'a été téléchargée pour ce type. +Sélectionnez un fichier, collez URL d'une image ou faites-en glisser une. + + + + Replace Image + Remplacer l'image + + + + MediaItemUtils + + + %1 - %2 - %3 + %1 - %2 - %3 + + + + + %1 - %2 + %1 - %2 + + + + MediaList + + + No media + + + + + Loading media… + + + + + Unable to load media + + + + + MediaListModel + + + Season %1 + Saison %1 + + + + %1 min + %1 min + + + + %1 Episodes + %1 Épisodes + + + + MediaSection + + + See all + + + + + MediaService + + Invalid image URL + URL d'image non valide + + + Downloaded data is not a valid image + Les données téléchargées ne constituent pas une image valide + + + + ModernMessageBox + + + Cancel + Annuler + + + + ModernTagInput + + + Type & Enter... + + + + + Clear all + + + + + Presets + + + + + Search... + + + + + MpvController + + + Failed to create MPV instance. + Échec de la création de l'instance MPV. + + + + Failed to initialize MPV context. + Échec de l'initialisation du contexte MPV. + + + + MpvSpikeHarness + + + MpvItem spike + + + + + MpvSpikePage + + + Error: + + + + + position: %1 / %2 paused: %3 + + + + + Load + + + + + Play + Jouer + + + + Pause + + + + + Seek +30s + + + + + Stop + Stop + + + + file path or URL + + + + + MpvWidget + + + + 当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。 + + + + + OpenGL rendering initialization failed. + Échec de l'initialisation du rendu OpenGL. + + + + NetworkManager + + TLS/SSL 证书错误: %1 + Erreur de certificat TLS/SSL : %1 + + + 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 + Si serveur HTTPS auto-signé de confiance, activez option Ignorer la validation du certificat SSL. + + + + NowPlayingCard + + + Direct Play + Lecture directe + + + + Direct Stream + Diffusion en direct + + + + Transcode + Transcodage + + + + OverviewDialog + + + 作品简介 + + + + + PageAbout + + + About + À propos + + + + qEmby + + + + + Version %1 + Version %1 + + + + © 2026 AlanHJ. All rights reserved. + + + + + GitHub Repository + GitHub Dépôt + + + + Report an Issue + + + + + This project is open-source software built with Qt 6 and modern C++. +Special thanks to the Emby and Jellyfin communities for their fantastic APIs. + + + + + Open Source Licenses + + + + + Acknowledgements + Remerciements + + + + People who help qEmby reach more users. + Les personnes qui aident qEmby à toucher plus d'utilisateurs. + + + + French translation + Traduction française + + + + PageAppearance + + + Appearance + Apparence + + + + System Default + Paramètres par défaut du système + + + + Light Mode + Mode clair + + + + Dark Mode + Mode sombre + + + + Theme Mode + Mode thème + + + + Switch application theme (takes effect immediately) + Changer le thème de l'application (la modification prend effet immédiatement) + + + UI Animations + Animations de l'interface utilisateur + + + Enable smooth page transitions and hover effects + Activer les transitions fluides entre les pages et les effets au survol + + + + Small + Petit + + + + Medium + Moyen + + + + Large + Grand + + + + Font Size + Taille de la police + + + + Adjust the overall font size of the interface + Régler la taille globale de la police de l'interface + + + + Left + Gauche + + + + Right + Droite + + + + Sidebar Position + Position de la barre latérale + + + + Place the navigation sidebar on the left or right side + Placez la barre latérale à gauche ou à droite + + + + Pin Sidebar + Épingler la barre latérale + + + + Keep the sidebar always visible and integrated into the main layout + Faire en sorte que la barre latérale reste toujours visible et intégrée à la mise en page principale + + + + Custom Sidebar + + + + + Customize which items appear in the navigation sidebar + + + + + Hide Search Bar + + + + + Hide Home Item + + + + + Hide Favorites Item + + + + + Normal Window + Fenêtre standard + + + + Maximized + Agrandi + + + + Startup Window State + État de la fenêtre au démarrage + + + + Choose window state when the application launches + Choisissez l'état de la fenêtre au lancement de l'application + + + + Reduce Animations + Réduire les animations + + + + Reduce page transitions and hover effects for better performance + Réduisez les transitions entre les pages et les effets au survol pour améliorer les performances + + + + Snapshot Navigation + Navigation par aperçus + + + + Use static screenshots during page transitions for smoother animation on complex views + Utilisez des captures d'écran statiques lors des transitions entre les pages pour obtenir des animations plus fluides sur les vues complexes + + + + Enable Search History + Activer l'historique de recherche + + + + Save recent searches locally for quick reuse + Enregistrer les recherches récentes localement pour pouvoir les réutiliser rapidement + + + + Enable Search Autocomplete + Activer la saisie semi-automatique + + + + + Clear Search History + Effacer l'historique de recherche + + + + Clear All + Tout effacer + + + + Are you sure you want to clear search history for all servers? + +This action cannot be undone. + Êtes-vous sûr de vouloir effacer l'historique de recherche pour tous les serveurs ? + +Cette action ne peut pas être annulée. + + + + Clear + Effacer + + + + Search history cleared + Historique de recherche effacé + + + + Cancel + Annuler + + + Save recent searches locally and offer autocomplete in search boxes + Enregistrer les recherches récentes localement et proposer la saisie semi-automatique dans les champs de recherche + + + + PageCollections + + Lists + Listes + + + Refresh + Actualiser + + + Create playlist + Créer une liste de lecture + + + Select all playlists + Sélectionner toutes les listes de lecture + + + Clear playlist selection + Effacer la sélection de la liste de lecture + + + Delete selected playlists + Supprimer les listes de lecture sélectionnées + + + New Playlist + Nouvelle liste de lecture + + + Select All + Tout sélectionner + + + Clear Selection + Effacer la sélection + + + Delete Selected + Supprimer la sélection + + + Playlists + Listes de lecture + + + No items found + Aucun élément trouvé + + + No playlists found + Aucune liste de lecture trouvée + + + Loading... + Chargement en cours... + + + No collections found + Aucune collection trouvée + + + %n playlist(s) + + %n liste(s) de lecture + + + + + %1 · %2 + %1 · %2 + + + %n selected + + %n sélectionné(s) + + + + + %n collection(s) + + %n collection(s) + + + + + Failed to load: %1 + Échec du chargement: %1 + + + Create Playlist + Créer une liste de lecture + + + Playlist name + Nom de la liste de lecture + + + Enter playlist name + Entrez le nom de la liste de lecture + + + Create + Créer + + + Playlist created + Liste de lecture créée + + + Failed to create playlist: %1 + Échec de la création de la liste de lecture:%1 + + + Rename + Renommer + + + Rename Playlist + Renommer la liste de lecture + + + New name: + Nouveau nom: + + + Save + Enregistrer + + + Renamed successfully + Renommé avec succès + + + Rename failed: %1 + Échec du changement de nom: %1 + + + Delete + Supprimer + + + Delete Playlist + Supprimer la liste de lecture + + + Are you sure you want to delete %n selected playlist(s)? + +This action cannot be undone. + + Êtes-vous sûr de vouloir supprimer %n liste(s) de lecture sélectionnée(s) ? + +Cette action ne peut pas être annulée. + + + + + Are you sure you want to delete playlist "%1"? + +This action cannot be undone. + Êtes-vous sûr de vouloir supprimer la liste de lecture “%1” ? + +Cette action ne peut pas être annulée. + + + Cancel + Annuler + + + "%1" deleted + "%1" supprimé + + + Delete failed: %1 + Échec de la suppression: %1 + + + Deleted %1 playlist(s), then failed: %2 + Suppression de %1 liste(s) de lecture, puis échec : %2 + + + Failed to delete selected playlists: %1 + Échec de la suppression des listes de lecture sélectionnées: %1 + + + Playlist order updated + Ordre de la liste de lecture mis à jour + + + Failed to save playlist order: %1 + Échec de l'enregistrement de l'ordre de la liste de lecture : %1 + + + Deleted %n playlist(s) + + %n liste(s) de lecture supprimée(s) + + + + + + PageDashboard + + + Dashboard + Tableau de bord + + + + Server Information + Informations sur le serveur + + + + Refresh + Actualiser + + + + + Restart Server + Redémarrer le serveur + + + + + Shutdown Server + Arrêter le serveur + + + + Server Name + Nom du serveur + + + + Version + Version + + + + Operating System + Système d'exploitation + + + Architecture + Architecture + + + + Address + Adresse + + + + Now Playing + En cours de lecture + + + + Nothing is playing + Rien n'est en cours de lecture + + + + Active Sessions + Sessions actives + + + + + No active sessions + Aucune session active + + + + Activity Log + Journal d'activité + + + + No activity records + Aucun enregistrement d'activité + + + + Failed to load: %1 + Échec du chargement: %1 + + + + Are you sure you want to restart the server? All active sessions will be terminated. + Êtes-vous sûr de vouloir redémarrer le serveur ? Toutes les sessions actives seront interrompues. + + + + Restart + Redémarrer + + + + + Cancel + Annuler + + + + Server restart command sent + Commande de redémarrage du serveur envoyée + + + + Restart failed: %1 + Échec du redémarrage: %1 + + + + Are you sure you want to shut down the server? This will stop all services and disconnect all clients. + Êtes-vous sûr de vouloir arrêter le serveur ? Cela mettra fin à tous les services et déconnectera tous les clients. + + + + Shutdown + Arrêt + + + + Server shutdown command sent + Commande d'arrêt du serveur envoyée + + + + Shutdown failed: %1 + L'arrêt a échoué: %1 + + + + PageGeneral + + + General + Général + + + + System + Système + + + + 简体中文 + + + + + English + Anglais + + + + French + Français + + + + Language + Langue + + + + App interface language (requires restart) + Langue interface de l'application (nécessite un redémarrage) + + + Remember Last Server Mémoriser le dernier serveur + Automatically connect to the last used server on startup Se connecter automatiquement au dernier serveur utilisé au démarrage + Close to Tray - Fermer dans la barre d'état + Fermer dans la barre d'état + Minimize to system tray instead of exiting the application - Réduire dans la barre d'état système au lieu de quitter l'application + Réduire dans la barre d'état système au lieu de quitter l'application + + Configure... + + + + + Network Proxy + + + + + Proxy for the current server (global or server-specific) + + + + + no proxy + + + + + system proxy + + + + + custom (incomplete) + + + + + %1 %2:%3 + + + + + Proxy for the current server — using global: %1 + + + + + Proxy for the current server — currently: %1 + + + + + Default proxy for all servers — currently: %1 + + + + Enable Logging Activer la journalisation + Record debug information to a log file for troubleshooting Enregistrer les informations de débogage dans un fichier journal à des fins de dépannage + Log path: %1 - Chemin d'accès au fichier journal: %1 + Chemin d'accès au fichier journal: %1 + Size: %1 Taille: %1 + Open Log Ouvrir le journal + + Clear Log Effacer le journal + Are you sure you want to clear all log files? This will remove the current log and rotated backups. This action cannot be undone. @@ -1983,5286 +7596,4054 @@ This will remove the current log and rotated backups. This action cannot be undo Cette opération supprimera le fichier journal actuel ainsi que les sauvegardes de rotation. Cette action ne peut pas être annulée. + Clear Effacer + Cancel Annuler + Log files cleared Fichiers journaux effacés + Open Location - Ouvrir l'endroit + Ouvrir l'endroit - - - PageAppearance - - Appearance - Apparence - - - System Default - Paramètres par défaut du système - - - Light Mode - Mode clair - - - Dark Mode - Mode sombre - - - Theme Mode - Mode thème - - - Switch application theme (takes effect immediately) - Changer le thème de l'application (la modification prend effet immédiatement) - - - UI Animations - Animations de l'interface utilisateur - - - Enable smooth page transitions and hover effects - Activer les transitions fluides entre les pages et les effets au survol - - - - - PagePlayer - - Select GPU acceleration API for video rendering - Sélectionner l'API d'accélération GPU pour le rendu vidéo - - - GPU Next (Recommended) - GPU Next (Recommandé) - - - GPU (Legacy) - GPU (Legacy) - - - Rendering backend used by the player engine - Moteur de rendu utilisé par le lecteur - - - - - PageLibrary - - Library - Bibliothèque - - - Library Display - Présentation de la bibliothèque - - - Show Continue Watching - Afficher Continuer à regarder - - - Display unfinished media on the home screen - Afficher les fichiers multimédias inachevés sur l'écran d'accueil - - - Show Latest Added - Afficher les derniers ajouts - - - Display recently added media on the home screen - Afficher les fichiers médias récemment ajoutés sur l'écran d'accueil - - - Show Recommended - Afficher les recommandations - - - Display recommended media on the home screen - Afficher les médias recommandés sur l'écran d'accueil - - - Show Media Libraries - Afficher les bibliothèques Médias - - - Display media libraries on the home screen - Afficher les bibliothèques Médias sur l'écran d'accueil - - - Show Each Library - Afficher chaque bibliothèque - - - Display each library section individually on the home screen - Afficher chaque section de la bibliothèque séparément sur l'écran d'accueil - - - Adaptive Images - Images adaptatives - - - When a specific image type is missing, automatically use another available type as fallback - Lorsqu'un type d'image spécifique est manquant, utiliser automatiquement un autre type disponible à la place - - - Show Media Tooltips - Afficher les infobulles des médias - - - Show media details in tooltips when hovering over media cards - Afficher les détails des médias dans des infobulles lorsque l'on survole les fiches médias - - - Show Favorite Folders - Afficher les dossiers favoris - - - Display favorite folders in the favorites page - Afficher les dossiers favoris sur la page des favoris - - - Poster Wall - Mur d'affiches - - - List View - Affichage sous forme de liste - - - Default Library View - Affichage par défaut de la bibliothèque - - - Choose how media items are presented by default - Choisissez la manière dont les éléments multimédias s'affichent par défaut - - - Low - Faible - - - Medium - Moyen - - - High - Élevé - - - Original - Original - - - Image Quality - Qualité d'image - - - Set image download quality (higher uses more bandwidth) - Définir la qualité de téléchargement des images (une qualité supérieure consomme plus de bande passante) - - - Shimmer Loading Animation - Animation de chargement scintillante - - - Show shimmer skeleton animation while loading dashboard content - Afficher une animation de squelette scintillant pendant le chargement du contenu du tableau de bord - - - Custom Home Section Order - Ordre des sections de l'accueil - - - Override the default order of the main home screen sections - Modifier l'ordre par défaut des sections de l'écran d'accueil - - - Cache Management - Gestion du cache - - - 6 Hours - 6 Heures - - - 12 Hours - 12 Heures - - - 1 Day - 1 Jour - - - 3 Days - 3 Jours - - - 7 Days - 7 Jours - - - 14 Days - 14 Jours - - - 30 Days - 30 Jours - - - Recommendation Cache Duration - Durée de la mémoire cache des recommandations - - - Set how long to cache recommended content before refreshing - Définir la durée de mise en cache du contenu recommandé avant actualisation - - - Clear - Effacer - - - Clear Recommendation Cache - Vider le cache des recommandations - - - Clear cached recommendations and refresh on next visit - Effacer les recommandations mises en cache et actualiser la page lors de la prochaine visite - - - Image Cache Duration - Durée de conservation du cache d'images - - - How long to keep downloaded images in local cache - Combien de temps faut-il conserver les images téléchargées dans le cache local - - - Clear Image Cache - Vider le cache des images - - - Remove all cached images from disk - Supprimer toutes les images mises en cache du disque - - - Recommendation cache cleared - Cache des recommandations effacé - - - Image cache cleared - Cache d'images vidé - - - - - PageAbout - - About - À propos - - - Version %1 - Version %1 - - - GitHub Repository - GitHub Dépôt - - - Acknowledgements - Remerciements - - - People who help qEmby reach more users. - Les personnes qui aident qEmby à toucher plus d'utilisateurs. - - - French translation - Traduction française - - - - - SeasonView - - Episode Number - Numéro de l'épisode - - - Title - Titre - - - Premiere Date - Date de diffusion - - - Runtime - Durée - - - Loading... - Chargement en cours... - - - Unknown Series - Série inconnue - - - Error Loading Episodes - Erreur lors du chargement des épisodes - - - ▶ Play - ▶ Jouer - - - ▶ Continue E%1 - ▶ Continuer E%1 - - - 0 Episodes - 0 Épisodes - - - %1 Episodes - %1 Épisodes - - - - - MediaCardDelegate - - No Image - Pas d'image - - - - - MpvWidget - - OpenGL rendering initialization failed. - Échec de l'initialisation du rendu OpenGL. - - - - - MpvController - - Failed to create MPV instance. - Échec de la création de l'instance MPV. - - - Failed to initialize MPV context. - Échec de l'initialisation du contexte MPV. - - - - - PageAppearance - - Small - Petit - - - Medium - Moyen - - - Large - Grand - - - Font Size - Taille de la police - - - Adjust the overall font size of the interface - Régler la taille globale de la police de l'interface - - - Left - Gauche - - - Right - Droite - - - Sidebar Position - Position de la barre latérale - - - Place the navigation sidebar on the left or right side - Placez la barre latérale à gauche ou à droite - - - Pin Sidebar - Épingler la barre latérale - - - Keep the sidebar always visible and integrated into the main layout - Faire en sorte que la barre latérale reste toujours visible et intégrée à la mise en page principale - - - Normal Window - Fenêtre standard - - - Maximized - Agrandi - - - Startup Window State - État de la fenêtre au démarrage - - - Choose window state when the application launches - Choisissez l'état de la fenêtre au lancement de l'application - - - Reduce Animations - Réduire les animations - - - Reduce page transitions and hover effects for better performance - Réduisez les transitions entre les pages et les effets au survol pour améliorer les performances - - - Snapshot Navigation - Navigation par aperçus - - - Use static screenshots during page transitions for smoother animation on complex views - Utilisez des captures d'écran statiques lors des transitions entre les pages pour obtenir des animations plus fluides sur les vues complexes - - - Enable Search History - Activer l'historique de recherche - - - Save recent searches locally for quick reuse - Enregistrer les recherches récentes localement pour pouvoir les réutiliser rapidement - - - Enable Search Autocomplete - Activer la saisie semi-automatique - - - Clear Search History - Effacer l'historique de recherche - - - Clear All - Tout effacer - - - Are you sure you want to clear search history for all servers? - -This action cannot be undone. - Êtes-vous sûr de vouloir effacer l'historique de recherche pour tous les serveurs ? - -Cette action ne peut pas être annulée. - - - Clear - Effacer - - - Search history cleared - Historique de recherche effacé - - - Cancel - Annuler - - - Save recent searches locally and offer autocomplete in search boxes - Enregistrer les recherches récentes localement et proposer la saisie semi-automatique dans les champs de recherche - - - - - PagePlayer - - Player - Lecteur - - - Auto - Auto - - - VAAPI - VAAPI - - - VDPAU - VDPAU - - - CUDA (NVIDIA) - CUDA (NVIDIA) - - - None (Software) - Aucun (Logiciel) - - - Hardware Decoding - Décodage matériel - - - Select GPU acceleration method for video decoding - Sélectionner la méthode d'accélération GPU pour le décodage vidéo - - - libmpv (Embedded) - libmpv (Intégré) - - - GPU Next - GPU Next - - - GPU - GPU - - - Video Output Driver - Pilote de sortie vidéo - - - Rendering backend for embedded player (Render API mode) - Rendu pour le lecteur intégré (mode API de rendu) - - - Audio - Audio - - - Display Resample (Default) - Rééchantillonnage de l'affichage (par défaut) - - - Display VSync - Afficher VSync - - - Video Sync Mode - Mode de synchronisation vidéo - - - Method for synchronizing video frames to display - Procédé de synchronisation d'images vidéo en vue de leur affichage - - - Chinese - Chinois - - - Chinese (Hong Kong) - Chinois (Hong Kong) - - - Chinese (Macau) - Chinois (Macao) - - - Chinese (Taiwan) - Chinois (Taïwan) - - - English - Anglais - - - Japanese - Japonais - - - Korean - Coréen - - - French - Français - - - German - Allemand - - - Spanish - Espagnol - - - Russian - Russe - - - Preferred Audio Language - Langue audio préférée - - - Rules are matched in order; choose presets or type custom keywords and press Enter - Les règles sont traitées dans l'ordre ; sélectionnez des préréglages ou saisissez des mots-clés personnalisés, puis appuyez sur Entrée - - - Automatically select audio track matching this language - Sélectionner automatiquement la piste audio correspondant à cette langue - - - None - Aucun - - - Preferred Subtitle Language - Langue préférée pour les sous-titres - - - Automatically select subtitle track matching this language - Sélectionner automatiquement la piste de sous-titres correspondant à cette langue - - - Original - Original - - - 4K - 4K - - - 1080p - 1080p - - - 720p - 720p - - - 480p - 480p - - - Remux - Remux - - - Bitrate High to Low - Débit binaire : de élevé à faible - - - Bitrate Low to High - Débit binaire : de faible à élevé - - - File Size Large to Small - Taille des fichiers : de la plus grande à la plus petite - - - File Size Small to Large - Taille des fichiers : de la plus petite à la plus grande - - - Date New to Old - Par date (du plus récent au plus ancien) - - - Date Old to New - Par date (du plus ancien au plus récent) - - - Preferred Video Version - Version vidéo recommandée - - - Choose keyword or sort rules in order; type custom keywords and press Enter - Sélectionnez les mots-clés ou les critères de tri dans l'ordre ; saisissez des mots-clés personnalisés et appuyez sur Entrée - - - 3 Seconds - 3 Secondes - - - 5 Seconds - 5 Secondes - - - 10 Seconds - 10 Secondes - - - 15 Seconds - 15 Secondes - - - 30 Seconds - 30 Secondes - - - Seek Step - Rechercher une étape - - - Number of seconds to skip when pressing forward or rewind - Nombre de secondes à sauter lorsque l'on appuie sur les boutons d'avance rapide ou de retour rapide - - - Long Press Left/Right - Appui long à gauche/à droite - - - Enable long press actions for left/right keys during playback - Activer les actions par appui long sur les touches gauche/droite pendant la lecture - - - Long Press Trigger Time - Durée d'enclenchement par pression longue - - - Set how long left/right keys must be held before the long press action starts - Définissez la durée pendant laquelle les touches gauche et droite doivent être maintenues enfoncées avant que l'action de pression longue ne se déclenche - - - Long Press Mode - Mode appui long - - - Choose whether holding left/right keys performs time jump seek or temporary speed playback - Choisissez si le fait de maintenir les touches gauche/droite enfoncées déclenche un saut temporel ou une lecture à vitesse variable - - - Mouse Edge Long Press - Appui long sur le bouton latéral de la souris - - - Hold the left or right side of the playback area for speed rewind or fast forward - Appuyez longuement sur le côté gauche ou droit de la zone de lecture pour rembobiner ou avancer rapidement - - - Time Jump Playback - Lecture avec saut temporel - - - Speed Playback - Lecture accélérée - - - 300 ms - 300 ms - - - 500 ms - 500 ms - - - 800 ms - 800 ms - - - 1000 ms - 1000 ms - - - Long Press Seek - Appui long pour rechercher - - - Hold forward/rewind buttons to continuously seek with acceleration - Maintenez les boutons d'avance rapide et de retour rapide enfoncés pour effectuer une recherche continue avec accélération - - - Independent Player Window - Fenêtre de lecteur indépendant - - - Open video in a separate detached window - Ouvrir la vidéo dans une fenêtre séparée - - - Volume Normalization - Normalisation du volume - - - Automatically adjust volume to prevent sudden loud noises - Régler automatiquement le volume pour éviter les bruits soudains et forts - - - Use Custom mpv.conf - Utiliser un fichier mpv.conf personnalisé - - - Load settings from an external mpv.conf configuration file - Charger les paramètres à partir d'un fichier de configuration mpv.conf externe - - - Config path: %1 - Chemin d'accès au fichier de configuration: %1 - - - Open File - Ouvrir un fichier - - - External Player - Lecteur externe - - - Enable External Player - Activer le lecteur externe - - - Supported players on this platform: %1 - Lecteurs pris en charge sur cette plateforme: %1 - - - Custom... - Personnalisé... - - - Re-detect installed players - Réidentifier les lecteurs installés - - - Select External Player - Sélectionner un lecteur externe - - - Executable Files (*.exe) - Fichiers exécutables (*.exe) - - - All Files (*) - Tous les fichiers (*) - - - Arguments - Arguments - - - e.g., --fullscreen --volume=100 - ex, --fullscreen --volume=100 - - - - - PlaybackManager - - External player not found, falling back to internal player - Le lecteur externe est introuvable ; on passe au lecteur interne - - - Failed to launch external player - Impossible de lancer le lecteur externe - - - Launching %1... - Lancement %1... - - - - - PagePlayer - - Quick Play with External Player - Lecture rapide avec un lecteur externe - - - Use external player for quick play actions on cards and menus - Utilisez un lecteur externe pour les actions de lecture rapide sur les cartes et dans les menus - - - Direct Stream - Diffusion en direct - - - Bypass server proxy and stream directly from source. May not work on external networks - Contourner le serveur proxy et diffuser directement depuis la source. Cela peut ne pas fonctionner sur les réseaux externes - - - URL Path Replacement one rule per line: source => target - Remplacement de chemins d'accès URL : une règle par ligne : source => cible - - - e.g., http://192.168.2.1:19798/path/ => W:/mount/ - ex., http://192.168.2.1:19798/path/ => W:/mount/ - - - - - - PagePlayer - - Click to Pause - Cliquez pour mettre en pause - - - Single click on the video area to toggle play/pause - Cliquez une fois sur la zone de la vidéo pour activer ou désactiver la lecture/pause - - - Auto Skip Intro - Sauter automatiquement l'intro - - - Automatically skip the intro sequence when playback starts - Passer automatiquement la séquence d'introduction au début de la lecture - - - Auto Skip Outro - Sauter automatiquement l'outro - - - Automatically skip the ending credits during playback - Passer automatiquement le générique de fin pendant la lecture - - - - ManageView - - Management - Gestion - - - Dashboard - Tableau de bord - - - Libraries - Bibliothèques - - - Lists - Listes - - - Users - Utilisateurs - - - Tasks - Tâches - - - Logs - Journaux - - - Transcoding - Transcodage - - - Lists - Listes - - - Transcoding - Transcodage - - - Users - Utilisateurs - - - Tasks - Tâches - - - Logs - Journaux - - - - - PageTranscoding - - Transcoding - Transcodage - - - Refresh - Actualiser - - - Save - Enregistrer - - - Loading... - Chargement en cours... - - - Server - Serveur - - - Acceleration - Accélération - - - Tone Mapping - Mappage des tons - - - Temp Path - Chemin temporaire - - - General - Général - - - Paths, threads and core transcoding switches. - Chemins d'accès, threads et commutateurs de transcodage principaux. - - - Temporary Path - Chemin temporaire - - - Encoding Threads - Threads encodage - - - Auto - Auto - - - Enable Fallback Font - Activer la police de secours - - - Fallback Font Path - Chemin d'accès à la police de secours - - - Audio VBR - Audio VBR - - - FFmpeg Path - Chemin d'accès à FFmpeg - - - Extraction Threads - Threads d'extraction - - - Subtitle Extraction - Extraction des sous-titres - - - Quality & Cleanup - Qualité et nettoyage - - - Quality, buffering and segment retention. - Qualité, mise en mémoire tampon et conservation des segments. - - - H.264 CRF - H.264 CRF - - - Downmix Boost - Amplification du downmix - - - Enable Throttling - Activer la limitation - - - H.265 CRF - H.265 CRF - - - None - Aucun - - - Dave750 - Dave750 - - - Nightmode - Mode nuit - - - NightmodeDialogue - Mode nuit - Dialogue - - - Dolby Pro Logic - Dolby Pro Logic - - - Dolby Pro Logic II - Dolby Pro Logic II - - - RFC7845 - RFC7845 - - - AC-4 - AC-4 - - - Stereo Downmix - Mixage stéréo - - - Very Slow - Très lent - - - Slower - Plus lent - - - Slow - Lent - - - Medium - Moyen - - - Fast - Rapide - - - Faster - Plus rapide - - - Very Fast - Très rapide - - - Super Fast - Super rapide - - - Ultra Fast - Ultra Fast - - - Encoder Preset - Préréglage de l'encodeur - - - Max Muxing Queue - File d'attente maximale - - - Throttle Delay - Retard d'accélération - - - Enable Segment Deletion - Activer la suppression des segments - - - Segment Keep Seconds - Conserver les secondes du segment - - - YADIF - YADIF - - - BWDIF - BWDIF - - - Bob - Bob - - - Deinterlace Method - Méthode de désentrelacement - - - Double-Rate Deinterlace - Désentrelacement à double débit - - - Throttle Buffer - Tampon d'accélérateur - - - Throttle Hysteresis - Hystérésis de l'accélérateur - - - Throttling Method - Méthode de limitation - - - Hardware Acceleration - Accélération matérielle - - - Devices, decode paths and hardware encoder toggles. - Périphériques, chemins de décodage et commutateurs d'encodeur matériel. - - - Enable Hardware Encoding - Activer l'encodage matériel - - - QSV - QSV - - - NVENC - NVENC - - - AMF - AMF - - - VAAPI - VAAPI - - - VideoToolbox - VideoToolbox - - - V4L2M2M - V4L2M2M - - - RKMPP - RKMPP - - - VAAPI Device - VAAPI Périphérique - - - QSV Device - QSV Périphérique - - - Hardware Decoding Codecs - Codecs de décodage matériel - - - Prefer Native Decoder - Privilégier le décodeur natif - - - Enhanced NVDEC - NVDEC amélioré - - - Intel Low Power H.264 - Intel H.264 à faible consommation - - - Intel Low Power HEVC - Intel HEVC à faible consommation - - - Allow HEVC Encode - Autoriser l'encodage HEVC - - - Allow AV1 Encode - Autoriser l'encodage AV1 - - - Codec Paths - Chemins d'accès aux codecs - - - HDR conversion and tone mapping controls. - Commandes de conversion HDR et de mappage des tons. - - - Enable Tonemapping - Activer le mappage de tons - - - Enable VPP Tonemapping - Activer le mappage de tons VPP - - - Enable VideoToolbox Tonemapping - Activer le mappage de tons VideoToolbox - - - Algorithm - Algorithme - - - BT.2390 - BT.2390 - - - Hable - Hable - - - Reinhard - Reinhard - - - Mobius - Mobius - - - Clip - Clip - - - Linear - Linear - - - Gamma - Gamma - - - Mode - Mode - - - MAX - MAX - - - RGB - RGB - - - LUM - LUM - - - ITP - ITP - - - Range - Gamme - - - TV - TV - - - PC - PC - - - Desaturation - Désaturation - - - Peak - Pointe - - - Parameter - Paramètre - - - VPP Brightness - VPP Luminosité - - - VPP Contrast - VPP Contraste - - - Hardware Tone Mapping - Mappage de tons matériel - - - Software Tone Mapping - Mappage de tons logiciel - - - Advanced - Avancé - - - Codec-level and extraction-specific options. - Options au niveau du codec et spécifiques à l'extraction. - - - 10-bit HEVC Decode - 10-bit HEVC Décodage - - - 10-bit VP9 Decode - 10-bit VP9 Décodage - - - 10-bit HEVC Rext - 10-bit HEVC Rext - - - 12-bit HEVC Rext - 12-bit HEVC Rext - - - Keyframe Extensions - Extensions d'images clés - - - No transcoding configuration available - Aucune configuration de transcodage n'est disponible - - - Refresh failed - Échec de l'actualisation - - - Failed to load transcoding settings - Échec du chargement des paramètres de transcodage - - - Failed to load transcoding settings: %1 - Échec du chargement des paramètres de transcodage:%1 - - - Saving... - Enregistrement en cours... - - - Transcoding settings saved - Paramètres de transcodage enregistrés - - - Failed to save transcoding settings: %1 - Échec de l'enregistrement des paramètres de transcodage:%1 - - - Jellyfin - Jellyfin - - - Emby - Emby - - - Unknown - Inconnu - - - Disabled - Désactivé - - - Enabled - Activé - - - Mode %1 - Mode %1 - - - VPP - VPP - - - Mixed - Mixte - - - Hardware - Matériel - - - Software - Logiciel - - - Default - Par défaut - - - Custom - Personnalisé - - - %1 · %n visible setting(s) - - %1 · %n paramètre(s) visible(s) - - - - %n codec path(s) - - %n chemin(s) d'accès au(x) codec(s) - - - - +%1 more - +%1 de plus - - - Decoder - Décodeur - - - Core transcoding switches used by the current server. - Commutateurs de transcodage principaux utilisés par le serveur actuel. - - - Transcoding Temp Path - Chemin d'accès temporaire pour le transcodage - - - Enable Subtitle Extraction - Activer l'extraction des sous-titres - - - Downmix Audio Boost - Amplification audio du mixage - - - Hardware transcoding mode, decoder preference and codec support. - Mode de transcodage matériel, préférences du décodeur et prise en charge des codecs. - - - Hardware Acceleration Type - Type d'accélération matérielle - - - Prefer System Native Decoder - Privilégier le décodeur natif du système - - - Allow HEVC Encoding - Autoriser l'encodage HEVC - - - Allow AV1 Encoding - Autoriser l'encodage AV1 - - - No - Non - - - Yes - Oui - - - Preferred Hardware Decoder - Décodeur matériel recommandé - - - Preferred Hardware Encoder - Encodeur matériel recommandé - - - HDR conversion and tone mapping behavior. - Comportement de la conversion HDR et du tone mapping. - - - Hardware + Software - Matériel + Logiciel - - - Automatic - Automatique - - - System Native - Natif au système - - - 10-bit Decoding - 10-bit Décodage - - - AV1 - AV1 - - - By Segment Request - Par demande de segment - - - Codec Information - Informations sur le codec - - - Drag to Reorder - Faites glisser pour réorganiser - - - Custom Order - Commande sur mesure - - - Encoding Formats - Formats d'encodage - - - H264 - H264 - - - HEVC - HEVC - - - HEVC 10-bit - HEVC 10-bit - - - HEVC RExt 12-bit - HEVC RExt 12-bit - - - HEVC RExt 8/10-bit - HEVC RExt 8/10-bit - - - HEVC RExt Decoding - HEVC RExt Décodage - - - Hardware Decoding - Décodage matériel - - - Hardware Encoding - Encodage matériel - - - MPEG1 - MPEG1 - - - MPEG2 - MPEG2 - - - MPEG4 - MPEG4 - - - Metadata Keyframe Extensions - Extensions des images clés de métadonnées - - - Move Down - Descendre - - - Move Up - Monter - - - OK - OK - - - Paths and Threads - Chemins et Threads - - - Preferred Hardware Decoders - Décodeurs matériels recommandés - - - Preferred Hardware Encoders - Encodeurs matériels préférés - - - Quality and Processing - Qualité et transformation - - - Software Encoders - Encodeurs logiciels - - - Settings - Paramètres - - - Throttling and Segments - Limitation et segments - - - VC1 - VC1 - - - VP8 - VP8 - - - VP9 - VP9 - - - VP9 10-bit - VP9 10-bit - - - VPP Tone Mapping - VPP Mappage des tons - - - - - EmbyCodecInfoDialog - - Max Bitrate: - Débit maximal: - - - Frame Sizes: - Tailles d'images: - - - Frame Rates: - Fréquences d'images: - - - Max Instances: - Nombre maximal d'instances: - - - Color Formats: - Formats de couleur: - - - Profile - Profil - - - Max Level - Niveau maximum - - - Bit Depths - Nombre de bits - - - Resolutions - Résolutions - - - max %1x%2 - max %1x%2 - - - %1fps...%2fps - %1fps...%2fps - - - max %1fps - max %1fps - - - %1fps - %1fps - - - - - EmbyCodecParametersDialog - - Codec Settings - Paramètres du codec - - - Loading codec parameters... - Chargement des paramètres du codec... - - - Basic Parameters - Paramètres de base - - - Advanced Parameters - Paramètres avancés - - - Cancel - Annuler - - - Reset to Defaults - Réinitialiser les paramètres par défaut - - - Save - Enregistrer - - - No editable parameters available for this codec. - Aucun paramètre modifiable n'est disponible pour ce codec. - - - Saving... - Enregistrement en cours... - - - Failed to load codec parameters - Échec du chargement des paramètres du codec - - - Failed to load codec parameters: %1 - Échec du chargement des paramètres du codec:%1 - - - Codec parameters saved - Paramètres du codec enregistrés - - - Failed to save codec parameters: %1 - Échec de l'enregistrement des paramètres du codec:%1 - - - - - PageDashboard - - Dashboard - Tableau de bord - - - Server Information - Informations sur le serveur - - - Refresh - Actualiser - - - Restart Server - Redémarrer le serveur - - - Shutdown Server - Arrêter le serveur - - - Server Name - Nom du serveur - - - Version - Version - - - Operating System - Système d'exploitation - - - Architecture - Architecture - - - Address - Adresse - - - Now Playing - En cours de lecture - - - Nothing is playing - Rien n'est en cours de lecture - - - Active Sessions - Sessions actives - - - No active sessions - Aucune session active - - - Activity Log - Journal d'activité - - - No activity records - Aucun enregistrement d'activité - - - Failed to load: %1 - Échec du chargement: %1 - - - Are you sure you want to restart the server? All active sessions will be terminated. - Êtes-vous sûr de vouloir redémarrer le serveur ? Toutes les sessions actives seront interrompues. - - - Restart - Redémarrer - - - Cancel - Annuler - - - Server restart command sent - Commande de redémarrage du serveur envoyée - - - Restart failed: %1 - Échec du redémarrage: %1 - - - Are you sure you want to shut down the server? This will stop all services and disconnect all clients. - Êtes-vous sûr de vouloir arrêter le serveur ? Cela mettra fin à tous les services et déconnectera tous les clients. - - - Shutdown - Arrêt - - - Server shutdown command sent - Commande d'arrêt du serveur envoyée - - - Shutdown failed: %1 - L'arrêt a échoué: %1 - - - PageLibraries Libraries - Bibliothèques + Bibliothèques Refresh library list - Actualiser la liste des bibliothèques + Actualiser la liste des bibliothèques Scan all libraries - Analyser toutes les bibliothèques + Analyser toutes les bibliothèques Create new library - Créer une nouvelle bibliothèque + Créer une nouvelle bibliothèque Cancel scan - Annuler l'analyse + Annuler l'analyse No media libraries found - Aucune bibliothèque multimédia trouvée + Aucune bibliothèque multimédia trouvée Loading... - Chargement en cours... + Chargement en cours... 0 libraries - 0 bibliothèques + 0 bibliothèques %n library(s) - + %n bibliothèque(s) + Library created successfully - La bibliothèque a été créée avec succès + La bibliothèque a été créée avec succès Failed to create library: %1 - Échec de la création de la bibliothèque: %1 + Échec de la création de la bibliothèque: %1 Library updated successfully - La bibliothèque a été mise à jour avec succès + La bibliothèque a été mise à jour avec succès Failed to update library: %1 - Échec de la mise à jour de la bibliothèque:%1 + Échec de la mise à jour de la bibliothèque:%1 Metadata saved - Métadonnées enregistrées + Métadonnées enregistrées Failed to update metadata: %1 - Échec de la mise à jour des métadonnées: %1 + Échec de la mise à jour des métadonnées: %1 Delete Library - Supprimer la bibliothèque + Supprimer la bibliothèque Are you sure you want to delete the library "%1"? This will remove the library from the server, but will not delete the actual media files. - Êtes-vous sûr de vouloir supprimer la bibliothèque "%1" ? + Êtes-vous sûr de vouloir supprimer la bibliothèque "%1" ? Cette opération supprimera la bibliothèque du serveur, mais ne supprimera pas les fichiers multimédias eux-mêmes. Delete - Supprimer + Supprimer Cancel - Annuler + Annuler Library "%1" deleted - La bibliothèque "%1" a été supprimée + La bibliothèque "%1" a été supprimée Failed to delete library: %1 - Échec de la suppression de la bibliothèque: %1 + Échec de la suppression de la bibliothèque: %1 Scanning "%1"... - Analyse "%1"... + Analyse "%1"... Refreshing metadata for "%1"... - Actualisation des métadonnées pour “%1”... + Actualisation des métadonnées pour “%1”... Metadata refresh failed: %1 - Échec de l'actualisation des métadonnées: %1 + Échec de l'actualisation des métadonnées: %1 Scan failed: %1 - Échec de l'analyse: %1 + Échec de l'analyse: %1 Full library scan started - Analyse complète de la bibliothèque en cours + Analyse complète de la bibliothèque en cours Scan cancelled - Analyse annulée + Analyse annulée Cancel failed: %1 - Échec de l'annulation: %1 + Échec de l'annulation: %1 Library order updated - Commande de la bibliothèque mise à jour + Commande de la bibliothèque mise à jour Failed to save order: %1 - Échec de l'enregistrement de la commande: %1 + Échec de l'enregistrement de la commande: %1 Failed to load libraries: %1 - Échec du chargement des bibliothèques: %1 + Échec du chargement des bibliothèques: %1 Select all libraries - Sélectionner toutes les bibliothèques + Sélectionner toutes les bibliothèques Clear library selection - Effacer la sélection de la bibliothèque + Effacer la sélection de la bibliothèque Delete selected libraries - Supprimer les bibliothèques sélectionnées + Supprimer les bibliothèques sélectionnées Delete Selected - Supprimer la sélection + Supprimer la sélection %n selected - + %n sélectionné + %1 · %2 - %1 · %2 + %1 · %2 Are you sure you want to delete %n selected library(s)? This will remove the libraries from the server, but will not delete the actual media files. - + Êtes-vous sûr de vouloir supprimer %n bibliothèque(s) sélectionnée(s) ? Cette opération supprimera les bibliothèques du serveur, mais ne supprimera pas les fichiers multimédias eux-mêmes. + 确定要删除 %1 个已选媒体库吗? 此操作会从服务器移除媒体库,但不会删除实际媒体文件。 - Voulez-vous vraiment supprimer la bibliothèque multimédia %1 sélectionnée ? + Voulez-vous vraiment supprimer la bibliothèque multimédia %1 sélectionnée ? Cette opération supprimera la bibliothèque multimédia du serveur, mais ne supprimera pas les fichiers multimédias eux-mêmes. 删除已选 - Supprimer la sélection + Supprimer la sélection Deleted %1 library(s), then failed: %2 - Suppression de %1 bibliothèque(s), puis échec: %2 + Suppression de %1 bibliothèque(s), puis échec: %2 Failed to delete selected libraries: %1 - Échec de la suppression des bibliothèques sélectionnées: %1 + Échec de la suppression des bibliothèques sélectionnées: %1 Deleted %n library(s) - + %n bibliothèque(s) supprimée(s) + - - NowPlayingCard + PageLibrary - Direct Play - Lecture directe + + Library + Bibliothèque - Direct Stream - Diffusion en direct + Library Display + Présentation de la bibliothèque - Transcode - Transcodage + + Show Continue Watching + Afficher Continuer à regarder + + + + Display unfinished media on the home screen + Afficher les fichiers multimédias inachevés sur l'écran d'accueil + + + + Show Latest Added + Afficher les derniers ajouts + + + + Display recently added media on the home screen + Afficher les fichiers médias récemment ajoutés sur l'écran d'accueil + + + + Show Recommended + Afficher les recommandations + + + + Display recommended media on the home screen + Afficher les médias recommandés sur l'écran d'accueil + + + + Show Media Libraries + Afficher les bibliothèques Médias + + + + Display media libraries on the home screen + Afficher les bibliothèques Médias sur l'écran d'accueil + + + + Show Each Library + Afficher chaque bibliothèque + + + + Display each library section individually on the home screen + Afficher chaque section de la bibliothèque séparément sur l'écran d'accueil + + + + Show Completed Watching + + + + + Display completed media on the home screen + + + + + Continue Watching Request Limit + + + + + + + + Full list request count. 0 means unlimited; home sections use the same value up to 50. + + + + + + + + Unlimited + Illimité + + + + Latest Media Request Limit + + + + + Recommended Request Limit + + + + + Completed Watching Request Limit + + + + + Adaptive Images + Images adaptatives + + + + When a specific image type is missing, automatically use another available type as fallback + Lorsqu'un type d'image spécifique est manquant, utiliser automatiquement un autre type disponible à la place + + + + Show Media Tooltips + Afficher les infobulles des médias + + + + Show media details in tooltips when hovering over media cards + Afficher les détails des médias dans des infobulles lorsque l'on survole les fiches médias + + + + Show Favorite Folders + Afficher les dossiers favoris + + + + Display favorite folders in the favorites page + Afficher les dossiers favoris sur la page des favoris + + + + Poster Wall + Mur d'affiches + + + + List View + Affichage sous forme de liste + + + + Default Library View + Affichage par défaut de la bibliothèque + + + + Choose how media items are presented by default + Choisissez la manière dont les éléments multimédias s'affichent par défaut + + + + Low + Faible + + + + Medium + Moyen + + + + High + Élevé + + + + Original + Original + + + + Image Quality + Qualité d'image + + + + Set image download quality (higher uses more bandwidth) + Définir la qualité de téléchargement des images (une qualité supérieure consomme plus de bande passante) + + + + Shimmer Loading Animation + Animation de chargement scintillante + + + + Show shimmer skeleton animation while loading dashboard content + Afficher une animation de squelette scintillant pendant le chargement du contenu du tableau de bord + + + + Custom Home Section Order + Ordre des sections de l'accueil + + + + Override the default order of the main home screen sections + Modifier l'ordre par défaut des sections de l'écran d'accueil + + + + Cache Management + Gestion du cache + + + + 6 Hours + 6 Heures + + + + 12 Hours + 12 Heures + + + + + 1 Day + 1 Jour + + + + + 3 Days + 3 Jours + + + + + 7 Days + 7 Jours + + + + 14 Days + 14 Jours + + + + 30 Days + 30 Jours + + + + Recommendation Cache Duration + Durée de la mémoire cache des recommandations + + + + Set how long to cache recommended content before refreshing + Définir la durée de mise en cache du contenu recommandé avant actualisation + + + + + Clear + Effacer + + + + Clear Recommendation Cache + Vider le cache des recommandations + + + + Clear cached recommendations and refresh on next visit + Effacer les recommandations mises en cache et actualiser la page lors de la prochaine visite + + + + Image Cache Duration + Durée de conservation du cache d'images + + + + How long to keep downloaded images in local cache + Combien de temps faut-il conserver les images téléchargées dans le cache local + + + + Clear Image Cache + Vider le cache des images + + + + Remove all cached images from disk + Supprimer toutes les images mises en cache du disque + + + + Recommendation cache cleared + Cache des recommandations effacé + + + + Image cache cleared + Cache d'images vidé - - - LibraryActionMenu - - Edit Metadata - Modifier les métadonnées - - - Edit Images - Modifier les images - - - Refresh Metadata - Actualiser les métadonnées - - - Scan Library Files - Analyser les fichiers de la bibliothèque - - - - - LibraryMetadataEditDialog - - Edit Metadata - Modifier les métadonnées - - - Basic Information - Information de base - - - Classification - Classification - - - Ratings & Release - Notes et versions - - - External IDs - IDs externes - - - Music Metadata - Métadonnées musicales - - - Additional Details - Informations complémentaires - - - Metadata Settings - Paramètres des métadonnées - - - Field Locks - Verrous de champ - - - Path - Chemin - - - Name - Nom - - - Original Title - Titre original - - - Sort Name - Trier par nom - - - Tagline - Slogan - - - Genres - Genres - - - Tags - Mots-clés - - - Studios - Studios - - - Date Added - Date d'ajout - - - Production Year - Année de production - - - Premiere Date - Date de diffusion - - - End Date - Date de fin - - - Status - Statut - - - Runtime (Minutes) - Durée (en minutes) - - - Official Rating - Classement officiel - - - Custom Rating - Évaluation personnalisée - - - Community Rating - Évaluation par la communauté - - - Critic Rating - Note des critiques - - - IMDb Id - IMDb Id - - - MovieDb Id - MovieDb Id - - - TheTVDB Id - TheTVDB Id - - - Album - Album - - - Album Artist - Artiste de l'album - - - Artists - Artistes - - - Composers - Compositeurs - - - Channel Number - Numéro de chaîne - - - Place of Birth - Lieu de naissance - - - Parent Index Number - Numéro d'index du parent - - - Index Number - Numéro d'index - - - Preferred Metadata Language - Langage de métadonnées privilégié - - - Metadata Country Code - Code pays des métadonnées - - - Overview - Aperçu - - - Lock metadata - Verrouiller les métadonnées - - - Comma separated values - Valeurs séparées par des virgules - - - yyyy-MM-dd - yyyy-MM-dd - - - yyyy-MM-ddTHH:mm:ss - yyyy-MM-ddTHH:mm:ss - - - yyyy-MM-dd HH:mm:ss - yyyy-MM-dd HH:mm:ss - - - Minutes - Minutes - - - 0-10 - 0-10 - - - 0-100 - 0-100 - - - Continuing / Ended - En cours / Terminé - - - en-US - en-US - - - US - US - - - Cancel - Annuler - - - Save - Enregistrer - - - - - LibraryCard - - Edit - Modifier - - - Scan - Analyser - - - Delete - Supprimer - - - path - chemin - - - paths - chemins - - - - - PathRowWidget - - Browse server directory - Parcourir le répertoire du serveur - - - Remove this path - Supprimer ce chemin - - - Click + to browse... - Cliquez sur + pour parcourir... - - - - - FetcherRowWidget - - Move up - Monter - - - Move down - Descendre - - - - - ServerBrowserDialog - - Browse Server Directory - Parcourir le répertoire du serveur - - - Cancel - Annuler - - - Select - Sélectionner - - - Failed to load drives - Échec du chargement des disques - - - Access denied or error - Accès refusé ou erreur - - - - - LibraryCreateDialog - - Create Library - Créer une bibliothèque - - - Cancel - Annuler - - - Create - Créer - - - Enter library name... - Entrez le nom de la bibliothèque... - - - 🎬 Movies - 🎬 Films - - - 📺 TV Shows - 📺 Séries - - - 🎵 Music - 🎵 Musique - - - 🎞️ Home Videos - 🎞️ Vidéos familiales - - - 📚 Books - 📚 Livres - - - 🎤 Music Videos - 🎤 Vidéos musicales - - - 📷 Photos - 📷 Photos - - - 📦 Mixed - 📦 Mixte - - - Media Paths: - Chemins d'accès aux médias: - - - Library Settings - Paramètres de la bibliothèque - - - Enable real-time monitoring - Activer la surveillance en temps réel - - - Automatically detect file changes in library folders - Détecter automatiquement les modifications apportées aux fichiers dans les dossiers de la bibliothèque - - - Prefer embedded titles over filenames - Préférez les titres intégrés aux noms de fichiers - - - Use titles stored in media file metadata instead of filenames - Utiliser les titres enregistrés dans les métadonnées des fichiers multimédias plutôt que les noms de fichiers - - - Preferred metadata language: - Langage de métadonnées préféré : - - - Server Default - Paramètres par défaut du serveur - - - English - Anglais - - - Chinese (Simplified) - Chinois (simplifié) - - - Chinese (Traditional) - Chinois (traditionnel) - - - Chinese (Hong Kong) - Chinois (Hong Kong) - - - Japanese - Japonais - - - Korean - Coréen - - - French - Français - - - German - Allemand - - - Spanish - Espagnol - - - Russian - Russe - - - Portuguese - Portugais - - - Italian - Italien - - - Preferred country/region: - Pays/région préféré(e) : - - - United States - États-Unis - - - China - Chine - - - Taiwan - Taïwan - - - Hong Kong - Hong Kong - - - Japan - Japon - - - South Korea - Corée du Sud - - - United Kingdom - Royaume-Uni - - - France - France - - - Germany - Allemagne - - - Preferred image language: - Langue de l'image souhaitée : - - - Save metadata as Nfo - Enregistrer les métadonnées au format Nfo - - - Save metadata in NFO format alongside media files - Enregistrer les métadonnées au format NFO avec les fichiers multimédias - - - Nfo metadata reader - Lecteur de métadonnées Nfo - - - Sort preferred local metadata sources by priority. The first file found will be read. - Triez les sources de métadonnées locales préférées par ordre de priorité. Le premier fichier trouvé sera lu. - - - Allow adult metadata - Autoriser les métadonnées pour adultes - - - Allow matching adult titles when searching for metadata on the internet - Autoriser la recherche de titres pour adultes lors de la recherche de métadonnées sur Internet - - - Import collection info from metadata downloaders - Importer les informations relatives aux collections à partir d'outils de téléchargement de métadonnées - - - Automatically create collections based on metadata - Créer automatiquement des collections à partir des métadonnées - - - Automatically group into collections - Regrouper automatiquement dans des collections - - - Automatically group movies into collections when enough related items exist - Regrouper automatiquement les films dans des collections lorsqu'il y a suffisamment d'éléments associés - - - Minimum collection size: - Taille minimale de la collection: - - - Automatic metadata refresh: - Actualisation automatique des métadonnées: - - - Placeholder metadata refresh: - Actualisation des métadonnées des espaces réservés: - - - Never - Jamais - - - As a scheduled task - Sous forme de tâche planifiée - - - On library scan and scheduled task - Lors de l'analyse de la bibliothèque et des tâches planifiées - - - Every 2 days - Tous les 2 jours - - - Every 3 days - Tous les 3 jours - - - Every 7 days - Tous les 7 jours - - - Every 30 days - Tous les 30 jours - - - Every 60 days - Tous les 60 jours - - - Every 90 days - Tous les 90 jours - - - Metadata fetchers: - Outils de récupération de métadonnées: - - - Series metadata fetchers: - Outils de récupération des métadonnées de séries: - - - Season metadata fetchers: - Outils de récupération des métadonnées des saisons: - - - Episode metadata fetchers: - Outils de récupération des métadonnées des épisodes: - - - Images - Images - - - Enable photos - Activer les photos - - - Allow photo items to be included in this library - Autoriser l'ajout d'éléments photo à cette bibliothèque - - - Import playlists - Importer des listes de lecture - - - Import playlist files that are found in the selected folders - Importer les fichiers de listes de lecture qui se trouvent dans les dossiers sélectionnés - - - Exclude from global search - Exclure de la recherche globale - - - Hide items in this library from global search results - Masquer les éléments de cette bibliothèque dans les résultats de recherche généraux - - - Merge top folders in folder view - Fusionner les dossiers supérieurs dans l'affichage des dossiers - - - Flatten the top-level folders when browsing in folder view - Aplatir les dossiers de niveau supérieur lors de la navigation en mode arborescence - - - Music folder structure: - Structure des dossiers de musique: - - - Other or unstructured - Autres ou non structurés - - - Perfect artist/album/track - Artiste/album/titre idéal - - - Perfect album/track - Album/titre idéal - - - Save artwork into media folders - Enregistrer les illustrations dans des dossiers multimédias - - - Save local metadata - Enregistrer les métadonnées locales - - - Store downloaded metadata and images alongside media files - Enregistrer les métadonnées et les images téléchargées avec les fichiers multimédias - - - Store metadata images alongside media files - Enregistrer les images de métadonnées avec les fichiers multimédias - - - Cache images in server data - Mettre les images en cache dans les données du serveur - - - Store generated and downloaded images in the server data folder - Enregistrer les images générées et téléchargées dans le dossier de données du serveur - - - Save metadata as hidden files - Enregistrer les métadonnées sous forme de fichiers cachés - - - Mark locally saved metadata files as hidden on supported servers - Marquer les fichiers de métadonnées enregistrés localement comme masqués sur les serveurs pris en charge - - - Download images in advance - Télécharger les images à l'avance - - - Download all available images during library scan - Télécharger toutes les images disponibles lors de l'analyse de la bibliothèque - - - Extract chapter images - Extraire les images des chapitres - - - Generate thumbnail images for video chapters - Générer des vignettes pour les chapitres vidéo - - - Generate video preview thumbnails: - Générer des vignettes d'aperçu vidéo: - - - Thumbnail interval: - Intervalle entre les vignettes: - - - 10 seconds - 10 secondes - - - Chapter markers - Marqueurs de chapitre - - - Save preview thumbnails into media folders - Enregistrer les vignettes d'aperçu dans les dossiers multimédias - - - Store generated video preview thumbnail sets alongside media files - Enregistrer les séries de vignettes d'aperçu vidéo générées avec les fichiers multimédias - - - Generate chapters for videos - Créer des chapitres pour les vidéos - - - Create chapter markers automatically for supported videos - Créer automatiquement des marqueurs de chapitre pour les vidéos prises en charge - - - Generated chapter interval: - Intervalle entre les chapitres généré: - - - %1 minutes - %1 minutes - - - Generate intro video markers: - Générer des marqueurs pour la vidéo d'introduction: - - - Image fetchers: - Outils de récupération d'images: - - - Series image fetchers: - Récupérateurs d'images par série: - - - Season image fetchers: - Récupérateurs d'images de saison: - - - Episode image fetchers: - Outils de récupération d'images d'épisodes: - - - Subtitles - Sous-titres - - - Subtitle downloaders: - Logiciels de téléchargement de sous-titres: - - - Require hash match for subtitle downloads - Exiger la correspondance du hachage pour le téléchargement des sous-titres - - - Only download subtitles when the provider reports a matching file hash - Ne téléchargez les sous-titres que lorsque le fournisseur indique que le hachage du fichier correspond - - - Search only for forced subtitles - Rechercher uniquement les sous-titres forcés - - - Restrict automatic subtitle downloads to forced subtitle tracks - Limiter le téléchargement automatique des sous-titres aux pistes de sous-titres forcés - - - Preferred subtitle language: - Langue préférée pour les sous-titres: - - - Save subtitles into media folders - Enregistrer les sous-titres dans des dossiers multimédias - - - Download subtitles and save them alongside media files - Télécharger les sous-titres et les enregistrer avec les fichiers multimédias - - - Skip if audio track matches download language - Ignorer si la piste audio correspond à la langue du téléchargement - - - Uncheck to ensure all videos have subtitles regardless of audio language - Décochez cette case pour vous assurer que toutes les vidéos disposent de sous-titres, quelle que soit la langue de l'audio - - - Skip if video has embedded subtitles - Ignorer si la vidéo comporte des sous-titres intégrés - - - Keeping text versions of subtitles will improve delivery efficiency and reduce likelihood of video transcoding - La conservation de versions textuelles des sous-titres permettra d'améliorer l'efficacité de la diffusion et de réduire le risque de transcodage vidéo - - - Stop downloading subtitles older than: - Ne téléchargez plus les sous-titres datant d'avant: - - - No Limit - Sans limite - - - 14 days - 14 jours - - - 30 days - 30 jours - - - 60 days - 60 jours - - - 90 days - 90 jours - - - 120 days - 120 jours - - - 180 days - 180 jours - - - Lyrics - Paroles - - - Lyrics fetchers: - Moteurs de recherche de paroles: - - - Preferred lyrics language: - Langue préférée pour les paroles: - - - Stop downloading lyrics older than: - Ne plus télécharger les paroles datant d'avant: - - - Save lyrics into media folders - Enregistrer les paroles dans des dossiers multimédias - - - Store downloaded lyrics files alongside media files - Enregistrer les fichiers de paroles téléchargés avec les fichiers multimédias - - - File Organization - Organisation des fichiers - - - Ignore sample files larger than: - Ignorer les fichiers d'exemple dont la taille dépasse: - - - MB - MB - - - Enable Plex .ignore compatibility - Activer la compatibilité avec les fichiers .ignore de Plex - - - Honor Plex .ignore files and folders while scanning - Prendre en compte les fichiers et dossiers .ignore lors de l'analyse - - - Automatically group series - Regrouper automatiquement les séries - - - Try to group episodes and seasons into series automatically - Essayez de regrouper automatiquement les épisodes et les saisons en séries - - - Enable multi-part items - Activer les articles en plusieurs parties - - - Recognize split video files as a single media item - Reconnaître les fichiers vidéo fractionnés comme un seul élément multimédia - - - Multi-version item detection: - Détection des éléments à versions multiples: - - - Both files and metadata - Les fichiers et les métadonnées - - - Files only - Fichiers uniquement - - - Metadata only - Métadonnées uniquement - - - None - Aucun - - - Playback - Lecture - - - Minimum resume percentage: - Pourcentage minimum requis: - - - If stopped before this point, the item will be marked as unplayed - Si vous arrêtez la lecture avant ce point, le titre sera marqué comme non écouté - - - Maximum resume percentage: - Pourcentage maximal de reprise: - - - If stopped after this point, the item will be marked as played - Si vous arrêtez la lecture après ce point, le titre sera marqué comme ayant été écouté - - - Minimum resume duration (seconds): - Durée minimale de la reprise (en secondes): - - - s - s - - - Items shorter than this duration cannot be resumed - Les éléments dont la durée est inférieure à celle-ci ne peuvent pas être repris - - - No Media Paths - Aucun chemin d'accès aux médias - - - No media paths have been added. The library will be created without any content folders. - -Do you want to continue? - Aucun chemin d'accès aux fichiers multimédias n'a été ajouté. La bibliothèque sera créée sans aucun dossier de contenu. - -Souhaitez-vous continuer? - - - Continue - Continuer - - - - - QObject - - Books - Livres - - - - - AddToPlaylistDialog - - Add to Playlist - Ajouter à la liste de lecture - - - Select a playlist - Sélectionnez une liste de lecture - - - Search playlists... - Rechercher des listes de lecture... - - - New Playlist - Nouvelle liste de lecture - - - Cancel - Annuler - - - Add - Ajouter - - - Loading... - Chargement en cours... - - - No playlists found - Aucune liste de lecture trouvée - - - Failed to load playlists: %1 - Échec du chargement des listes de lecture: %1 - - - Create Playlist - Créer une liste de lecture - - - Playlist name - Nom de la liste de lecture - - - Enter playlist name - Entrez le nom de la liste de lecture - - - Create - Créer - - - Playlist created - Liste de lecture créée - - - Failed to create playlist: %1 - Échec de la création de la liste de lecture: %1 - - - - - PageCollections - - Lists - Listes - - - Refresh - Actualiser - - - Create playlist - Créer une liste de lecture - - - Select all playlists - Sélectionner toutes les listes de lecture - - - Clear playlist selection - Effacer la sélection de la liste de lecture - - - Delete selected playlists - Supprimer les listes de lecture sélectionnées - - - New Playlist - Nouvelle liste de lecture - - - Select All - Tout sélectionner - - - Clear Selection - Effacer la sélection - - - Delete Selected - Supprimer la sélection - - - Playlists - Listes de lecture - - - No items found - Aucun élément trouvé - - - No playlists found - Aucune liste de lecture trouvée - - - Loading... - Chargement en cours... - - - No collections found - Aucune collection trouvée - - - %n playlist(s) - - %n liste(s) de lecture - - - - %1 · %2 - %1 · %2 - - - %n selected - - %n sélectionné(s) - - - - %n collection(s) - - %n collection(s) - - - - Failed to load: %1 - Échec du chargement: %1 - - - Create Playlist - Créer une liste de lecture - - - Playlist name - Nom de la liste de lecture - - - Enter playlist name - Entrez le nom de la liste de lecture - - - Create - Créer - - - Playlist created - Liste de lecture créée - - - Failed to create playlist: %1 - Échec de la création de la liste de lecture:%1 - - - Rename - Renommer - - - Rename Playlist - Renommer la liste de lecture - - - New name: - Nouveau nom: - - - Save - Enregistrer - - - Renamed successfully - Renommé avec succès - - - Rename failed: %1 - Échec du changement de nom: %1 - - - Delete - Supprimer - - - Delete Playlist - Supprimer la liste de lecture - - - Are you sure you want to delete %n selected playlist(s)? - -This action cannot be undone. - - Êtes-vous sûr de vouloir supprimer %n liste(s) de lecture sélectionnée(s) ? - -Cette action ne peut pas être annulée. - - - - Are you sure you want to delete playlist "%1"? - -This action cannot be undone. - Êtes-vous sûr de vouloir supprimer la liste de lecture “%1” ? - -Cette action ne peut pas être annulée. - - - Cancel - Annuler - - - "%1" deleted - "%1" supprimé - - - Delete failed: %1 - Échec de la suppression: %1 - - - Deleted %1 playlist(s), then failed: %2 - Suppression de %1 liste(s) de lecture, puis échec : %2 - - - Failed to delete selected playlists: %1 - Échec de la suppression des listes de lecture sélectionnées: %1 - - - Playlist order updated - Ordre de la liste de lecture mis à jour - - - Failed to save playlist order: %1 - Échec de l'enregistrement de l'ordre de la liste de lecture : %1 - - - Deleted %n playlist(s) - - %n liste(s) de lecture supprimée(s) - - - - - - CollectionCard - - Rename - Renommer - - - Delete - Supprimer - - - Playlist - Liste de lecture - - - Collection - Collection - - - item - élément - - - items - éléments - - - - - TextInputDialog - - Cancel - Annuler - - - OK - OK - - - - - - PageUsers - - Users - Utilisateurs - - - Refresh - Actualiser - - - Add user - Ajouter un utilisateur - - - No users found - Aucun utilisateur trouvé - - - No users - Aucun utilisateur - - - %n user(s) - - %n utilisateur(s) - - - - Admin - Admin - - - Disabled - Désactivé - - - Last login: %1 - Dernière connexion: %1 - - - Last active: %1 - Dernière activité: %1 - - - Enable user - Activer l'utilisateur - - - Disable user - Désactiver l'utilisateur - - - Cannot disable yourself - Vous ne pouvez pas vous désactiver vous-même - - - User "%1" disabled - L'utilisateur "%1" a été désactivé - - - User "%1" enabled - L'utilisateur "%1" a été activé - - - Failed to load users: %1 - Échec du chargement des utilisateurs: %1 - - - Failed to update user: %1 - Échec de la mise à jour de l'utilisateur: %1 - - - - - AddUserDialog - - Add User - Ajouter un utilisateur - - - Name - Nom - - - This will be the name displayed for the user on the server. - Ce sera le nom qui s'affichera pour l'utilisateur sur le serveur. - - - Enter user name - Entrez votre nom d'utilisateur - - - Copy from user - Copier depuis l'utilisateur - - - Do not copy - Ne pas copier - - - Optionally copy settings from an existing user. - Vous pouvez également copier les paramètres d'un utilisateur existant. - - - Allow user to manage server - Permettre à l'utilisateur de gérer le serveur - - - Copy Options - Options de copie - - - User policy - Politique d'utilisation - - - User configuration - Configuration de l'utilisateur - - - Choose which settings to copy from the selected user. - Choisissez les paramètres à copier depuis l'utilisateur sélectionné. - - - Library Access - Accès à la bibliothèque - - - Allow access to all libraries - Autoriser l'accès à toutes les bibliothèques - - - Choose which libraries this user can access. - Sélectionnez les bibliothèques auxquelles cet utilisateur a accès. - - - Channel Access - Accès aux chaînes - - - Allow access to all channels - Autoriser l'accès à toutes les chaînes - - - Choose which channels this user can access. - Sélectionnez les chaînes auxquelles cet utilisateur a accès. - - - Create - Créer - - - Cancel - Annuler - - - User name cannot be empty - Le nom d'utilisateur ne peut pas être vide - - - User "%1" created - L'utilisateur “%1” a été créé - - - User "%1" created, but failed to apply permissions: %2 - L'utilisateur “%1” a été créé, mais l'application des autorisations a échoué : %2 - - - Failed to create user: %1 - Échec de la création de l'utilisateur: %1 - - - - - PageTasks - - Tasks - Tâches - - - Refresh - Actualiser - - - Loading... - Chargement en cours... - - - Refresh failed - Échec de l'actualisation - - - All Tasks - Toutes les tâches - - - No tasks found - Aucune tâche trouvée - - - No tasks - Aucune tâche - - - %n task(s) - - %n tâche(s) - - - - %n running - - %n en cours - - - - %n system - - %n système - - - - %1 · %2 - %1 · %2 - - - Other - Autres - - - Scheduled - Planifié - - - Manual - Manuel - - - System - Système - - - Running Now - En cours - - - Tasks that are currently executing. Progress refreshes automatically. - Tâches en cours d'exécution. La progression est mise à jour automatiquement. - - - Scheduled Tasks - Tâches planifiées - - - Tasks with one or more configured triggers. - Tâches comportant un ou plusieurs déclencheurs configurés. - - - Manual Tasks - Tâches manuelles - - - Tasks that only run when started manually. - Tâches qui ne s'exécutent que lorsqu'elles sont lancées manuellement. - - - System Tasks - Tâches système - - - Hidden or built-in tasks reported by the server. - Tâches masquées ou intégrées signalées par le serveur. - - - Details - Détails - - - Schedule: %1 - Programme:%1 - - - State: %1 - État: %1 - - - Last result: %1 - Dernier résultat: %1 - - - %1 · %2% - %1 · %2% - - - Run task - Exécuter la tâche - - - Stop task - Arrêter la tâche - - - Last: %1 - Dernier: %1 - - - %1 — %2% - %1 — %2% - - - Idle - Au repos - - - Running - En cours - - - Cancelling - Annulation - - - Completed - Terminé - - - Failed - Échec - - - Aborted - Interrompu - - - Cancelled - Annulé - - - Task "%1" started - La tâche "%1" a démarré - - - Task "%1" stopped - La tâche "%1" s'est arrêtée - - - Failed to load tasks: %1 - Échec du chargement des tâches: %1 - - - Failed to run task: %1 - Échec de l'exécution de la tâche: %1 - - - Failed to stop task: %1 - Échec de l'arrêt de la tâche: %1 - - - PageLogs Logs - Journaux + Journaux Refresh - Actualiser + Actualiser Log Files - Fichiers journaux + Fichiers journaux Newest files appear first. - Les fichiers les plus récents apparaissent en premier. + Les fichiers les plus récents apparaissent en premier. Log Viewer - Visualiseur de journaux + Visualiseur de journaux Select a log file to inspect its contents - Sélectionnez un fichier journal pour en consulter le contenu + Sélectionnez un fichier journal pour en consulter le contenu No log files found - Aucun fichier journal trouvé + Aucun fichier journal trouvé All Files - Tous les fichiers + Tous les fichiers Total Size - Taille totale + Taille totale Latest Update - Dernière mise à jour + Dernière mise à jour Selected Size - Taille sélectionnée + Taille sélectionnée No files - Aucun fichier + Aucun fichier %n file(s) - + %n fichier(s) + Loading... - Chargement en cours... + Chargement en cours... No log file selected - Aucun fichier journal n'a été sélectionné + Aucun fichier journal n'a été sélectionné Choose a log file from the list to inspect the latest server output. - Sélectionnez un fichier journal dans la liste pour consulter les dernières informations générées par le serveur. + Sélectionnez un fichier journal dans la liste pour consulter les dernières informations générées par le serveur. Modified %1 - %1 modifié + %1 modifié Refresh failed - Échec de l'actualisation + Échec de l'actualisation Failed to load log files: %1 - Échec du chargement des fichiers journaux: %1 + Échec du chargement des fichiers journaux: %1 Failed to load: %1 - Échec du chargement: %1 + Échec du chargement: %1 Save Log File - Enregistrer le fichier journal + Enregistrer le fichier journal Text Files (*.txt);;All Files (*) - Fichiers texte (*.txt) ;; Tous les fichiers (*) + Fichiers texte (*.txt) ;; Tous les fichiers (*) - Log file "%1" saved - Fichier journal “%1” enregistré + Log file "%1" saved + Fichier journal “%1” enregistré Failed to save log file: %1 - Échec de l'enregistrement du fichier journal: %1 + Échec de l'enregistrement du fichier journal: %1 - - - UserEditDialog + PagePlayer - User Settings - Paramètres utilisateur + Select GPU acceleration API for video rendering + Sélectionner l'API d'accélération GPU pour le rendu vidéo - User Settings — %1 - Paramètres utilisateur — %1 + GPU Next (Recommended) + GPU Next (Recommandé) - Name - Nom + GPU (Legacy) + GPU (Legacy) - This is how the user will appear on your server. - C'est ainsi que l'utilisateur apparaîtra sur votre serveur. + Rendering backend used by the player engine + Moteur de rendu utilisé par le lecteur - Enter user name - Entrez votre nom d'utilisateur + + + Player + Lecteur - Administrator - Administrateur + + + + Auto + Auto - Allow remote access - Autoriser l'accès à distance + + VAAPI + VAAPI - Playback - Lecture + + VDPAU + VDPAU - Allow media playback - Autoriser la lecture de fichiers multimédias + + CUDA (NVIDIA) + CUDA (NVIDIA) - Allow audio transcoding - Autoriser le transcodage audio + + None (Software) + Aucun (Logiciel) - Allow video transcoding - Autoriser le transcodage vidéo + + Hardware Decoding + Décodage matériel - Features - Caractéristiques + + Select GPU acceleration method for video decoding + Sélectionner la méthode d'accélération GPU pour le décodage vidéo - Live TV access - Accès à la télévision en direct + + libmpv (Embedded) + libmpv (Intégré) - Live TV recording management - Gestion de l'enregistrement des programmes télévisés en direct + GPU Next + GPU Next - Allow media deletion - Autoriser la suppression des fichiers multimédias + GPU + GPU - Remote control of other users - Contrôle à distance d'autres utilisateurs + + Video Output Driver + Pilote de sortie vidéo - Shared device remote control - Contrôle à distance pour appareils partagés + + Rendering backend for embedded player (Render API mode) + Rendu pour le lecteur intégré (mode API de rendu) - Allow media downloading - Autoriser le téléchargement de fichiers multimédias + + Audio + Audio - Visibility - Visibilité + + Display Resample (Default) + Rééchantillonnage de l'affichage (par défaut) - Hide from login screen - Masquer l'écran de connexion + + Display VSync + Afficher VSync - Hide from login screen (remote) - Masquer depuis l'écran de connexion (à distance) + + Video Sync Mode + Mode de synchronisation vidéo - Hide from unused devices - Masquer les appareils inutilisés + + Method for synchronizing video frames to display + Procédé de synchronisation d'images vidéo en vue de leur affichage - Save - Enregistrer + + Mobius + Mobius - Profile - Profil + + BT.2390 + BT.2390 - Allow access to all libraries - Autoriser l'accès à toutes les bibliothèques + + Hable + Hable - Access - Accès + + Reinhard + Reinhard - Maximum parental rating - Classification parentale maximale + + Tone Mapping + Mappage des tons + + HDR to SDR tone mapping algorithm + + + + + Chinese + Chinois + + + + Chinese (Hong Kong) + Chinois (Hong Kong) + + + + Chinese (Macau) + Chinois (Macao) + + + + Chinese (Taiwan) + Chinois (Taïwan) + + + + English + Anglais + + + + Japanese + Japonais + + + + Korean + Coréen + + + + French + Français + + + + German + Allemand + + + + Spanish + Espagnol + + + + Russian + Russe + + + + Preferred Audio Language + Langue audio préférée + + + + + Rules are matched in order; choose presets or type custom keywords and press Enter + Les règles sont traitées dans l'ordre ; sélectionnez des préréglages ou saisissez des mots-clés personnalisés, puis appuyez sur Entrée + + + Automatically select audio track matching this language + Sélectionner automatiquement la piste audio correspondant à cette langue + + + None Aucun - Parental Control - Contrôle parental + + Preferred Subtitle Language + Langue préférée pour les sous-titres - New password - Nouveau mot de passe + Automatically select subtitle track matching this language + Sélectionner automatiquement la piste de sous-titres correspondant à cette langue - Enter new password - Entrez un nouveau mot de passe + + Original + Original - Confirm password - Confirmez le mot de passe + + 4K + 4K - Re-enter new password - Entrez à nouveau le nouveau mot de passe + + 1080p + 1080p - Save Password - Enregistrer le mot de passe + + 720p + 720p - Password - Mot de passe + + 480p + 480p - Failed to load user data: %1 - Échec du chargement des données utilisateur: %1 + + Remux + Remux - Settings saved - Paramètres enregistrés + + Bitrate High to Low + Débit binaire : de élevé à faible - Failed to save: %1 - Échec de l'enregistrement: %1 + + Bitrate Low to High + Débit binaire : de faible à élevé - User name cannot be empty - Le nom d'utilisateur ne peut pas être vide + + File Size Large to Small + Taille des fichiers : de la plus grande à la plus petite - Passwords do not match - Les mots de passe ne correspondent pas + + File Size Small to Large + Taille des fichiers : de la plus petite à la plus grande - Password updated - Mot de passe mis à jour + + Date New to Old + Par date (du plus récent au plus ancien) - Failed to update password: %1 - Échec de la mise à jour du mot de passe: %1 + + Date Old to New + Par date (du plus ancien au plus récent) - Cancel - Annuler + + Preferred Video Version + Version vidéo recommandée - Emby Connect email - Email Emby Connect + + Choose keyword or sort rules in order; type custom keywords and press Enter + Sélectionnez les mots-clés ou les critères de tri dans l'ordre ; saisissez des mots-clés personnalisés et appuyez sur Entrée - Optional connect email - Adresse e-mail facultative pour la connexion + + Continuous Playback + - Link this user to an Emby Connect account by email. - Associez cet utilisateur à un compte Emby Connect par e-mail. + + Automatically play the next episode or media when playback ends + - Disable user - Désactiver l'utilisateur + + 3 Seconds + 3 Secondes - Allow changing image and password - Autoriser la modification de l'image et du mot de passe + + 5 Seconds + 5 Secondes - Authentication provider - Fournisseur d'authentification + + 10 Seconds + 10 Secondes - Allow video remuxing - Autoriser le remuxage vidéo + + 15 Seconds + 15 Secondes - Simultaneous stream limit - Limite du nombre de flux simultanés + + 30 Seconds + 30 Secondes - Unlimited - Illimité + + Seek Step + Rechercher une étape - Remote client bitrate limit - Limite de débit binaire du client distant + + Number of seconds to skip when pressing forward or rewind + Nombre de secondes à sauter lorsque l'on appuie sur les boutons d'avance rapide ou de retour rapide - Mbps - Mbps + + Long Press Left/Right + Appui long à gauche/à droite - Feature Access - Accès aux fonctionnalités + + Enable long press actions for left/right keys during playback + Activer les actions par appui long sur les touches gauche/droite pendant la lecture - Allow media conversion - Autoriser la conversion des fichiers multimédias + + Long Press Trigger Time + Durée d'enclenchement par pression longue - Allow link sharing - Autoriser le partage de liens + + Set how long left/right keys must be held before the long press action starts + Définissez la durée pendant laquelle les touches gauche et droite doivent être maintenues enfoncées avant que l'action de pression longue ne se déclenche - Downloads - Téléchargements + + Long Press Mode + Mode appui long - Allow sync transcoding - Autoriser le transcodage synchronisé + + Choose whether holding left/right keys performs time jump seek or temporary speed playback + Choisissez si le fait de maintenir les touches gauche/droite enfoncées déclenche un saut temporel ou une lecture à vitesse variable - Subtitles - Sous-titres + + Mouse Edge Long Press + Appui long sur le bouton latéral de la souris - Allow subtitle downloading - Autoriser le téléchargement des sous-titres + + Hold the left or right side of the playback area for speed rewind or fast forward + Appuyez longuement sur le côté gauche ou droit de la zone de lecture pour rembobiner ou avancer rapidement - Allow subtitle management - Autoriser la gestion des sous-titres + + Time Jump Playback + Lecture avec saut temporel - Media Deletion - Suppression de fichiers multimédias + + Speed Playback + Lecture accélérée - Allow media deletion from all libraries - Autoriser la suppression de fichiers multimédias dans toutes les bibliothèques + + 300 ms + 300 ms - Channel Access - Accès aux chaînes + + 500 ms + 500 ms - Allow access to all channels - Autoriser l'accès à toutes les chaînes + + 800 ms + 800 ms - Device Access - Accès aux appareils + + 1000 ms + 1000 ms - Allow access on all devices - Autoriser l'accès sur tous les appareils + Long Press Seek + Appui long pour rechercher - Block unrated items - Masquer les éléments non classés + Hold forward/rewind buttons to continuously seek with acceleration + Maintenez les boutons d'avance rapide et de retour rapide enfoncés pour effectuer une recherche continue avec accélération - Books - Livres + + Independent Player Window + Fenêtre de lecteur indépendant - Games - Jeux + + Open video in a separate detached window + Ouvrir la vidéo dans une fenêtre séparée - Channel content - Contenu de la chaîne + + Volume Normalization + Normalisation du volume - Live TV - TV en direct + + Automatically adjust volume to prevent sudden loud noises + Régler automatiquement le volume pour éviter les bruits soudains et forts - Movies - Films + + Use Custom mpv.conf + Utiliser un fichier mpv.conf personnalisé - Music - Musique + + Load settings from an external mpv.conf configuration file + Charger les paramètres à partir d'un fichier de configuration mpv.conf externe - Trailers - Bandes-annonces + + Config path: %1 + Chemin d'accès au fichier de configuration: %1 - TV shows - Séries télévisées + + Open File + Ouvrir un fichier - Tag Restrictions - Restrictions relatives aux balises + + 6 Hours + 6 Heures - Blocked tags - Balises bloquées + + 12 Hours + 12 Heures - Tag restriction mode - Mode de restriction des balises + + 1 Day + 1 Jour - Block items with these tags - Masquer les éléments portant ces balises + + 3 Days + 3 Jours - Allow items with these tags - Autoriser les éléments portant ces balises + + 7 Days + 7 Jours - Restriction match mode - Mode de correspondance des restrictions + + Clear + Effacer - Match all restrictions - Respecter toutes les restrictions + + External Player + Lecteur externe - Match any restriction - Respecter toutes les restrictions + + Enable External Player + Activer le lecteur externe - Access Schedule - Horaires d'accès + + Supported players on this platform: %1 + Lecteurs pris en charge sur cette plateforme: %1 - Limit when this user can access the server. - Limiter les horaires auxquels cet utilisateur peut accéder au serveur. + + Custom... + Personnalisé... - Add - Ajouter + + Re-detect installed players + Réidentifier les lecteurs installés - Remove - Supprimer + + Select External Player + Sélectionner un lecteur externe - Sunday - Dimanche + Executable Files (*.exe) + Fichiers exécutables (*.exe) - Monday - Lundi + + All Files (*) + Tous les fichiers (*) - Tuesday - Mardi + + Arguments + Arguments - Wednesday - Mercredi + + e.g., --fullscreen --volume=100 + ex, --fullscreen --volume=100 - Thursday - Jeudi + + Quick Play with External Player + Lecture rapide avec un lecteur externe - Friday - Vendredi + + Use external player for quick play actions on cards and menus + Utilisez un lecteur externe pour les actions de lecture rapide sur les cartes et dans les menus - Saturday - Samedi + + Direct Stream + Diffusion en direct - Schedule start time must be earlier than end time - L'heure de début doit être antérieure à l'heure de fin + + Bypass server proxy and stream directly from source. May not work on external networks + Contourner le serveur proxy et diffuser directement depuis la source. Cela peut ne pas fonctionner sur les réseaux externes - - - - - - PageUsers - Delete user - Supprimer un utilisateur + + URL Path Replacement one rule per line: source => target + Remplacement de chemins d'accès URL : une règle par ligne : source => cible - Cannot delete yourself - Vous ne pouvez pas vous supprimer vous-même + + e.g., http://192.168.2.1:19798/path/ => W:/mount/ + ex., http://192.168.2.1:19798/path/ => W:/mount/ - Delete User - Supprimer un utilisateur + + Click to Pause + Cliquez pour mettre en pause - Are you sure you want to delete user "%1"? This action cannot be undone. - Êtes-vous sûr de vouloir supprimer l'utilisateur "%1"? Cette action ne peut pas être annulée. + + Single click on the video area to toggle play/pause + Cliquez une fois sur la zone de la vidéo pour activer ou désactiver la lecture/pause - Delete - Supprimer + + Auto Skip Intro + Sauter automatiquement l'intro - Cancel - Annuler + + Automatically skip the intro sequence when playback starts + Passer automatiquement la séquence d'introduction au début de la lecture - User "%1" deleted - L'utilisateur "%1" a été supprimé + + Auto Skip Outro + Sauter automatiquement l'outro - Failed to delete user: %1 - Échec de la suppression de l'utilisateur: %1 + + Automatically skip the ending credits during playback + Passer automatiquement le générique de fin pendant la lecture - - - - - TaskEditDialog - - Overview - Aperçu - - - Cancel - Annuler - - - Task Summary - Résumé des tâches - - - State - État - - - Category - Catégorie - - - Last Result - Dernier résultat - - - Last Finished - Dernier terminé - - - Schedule - Calendrier - - - Visibility - Visibilité - - - Scheduling Notes - Remarques concernant la planification - - - Trigger Rules - Règles de déclenchement - - - Loading task details... - Chargement des détails de la tâche... - - - Configured Triggers - Déclencheurs configurés - - - Triggers - Déclencheurs - - - Add Trigger - Ajouter un déclencheur - - - Trigger Type - Type de déclencheur - - - Daily - Quotidien - - - Interval - Intervalle - - - Weekly - Hebdomadaire - - - On Startup - Au démarrage - - - Time - Temps - - - Every - Chaque - - - 15 minutes - 15 minutes - - - 30 minutes - 30 minutes - - - 45 minutes - 45 minutes - - - 1 hour - 1 heure - - - 2 hours - 2 heures - - - 3 hours - 3 heures - - - 4 hours - 4 heures - - - 6 hours - 6 heures - - - 8 hours - 8 heures - - - 12 hours - 12 heures - - - 24 hours - 24 heures - - - Day of Week - Jour de la semaine - - - Sunday - Dimanche - - - Monday - Lundi - - - Tuesday - Mardi - - - Wednesday - Mercredi - - - Thursday - Jeudi - - - Friday - Vendredi - - - Saturday - Samedi - - - Task will run when the server starts. - La tâche s'exécutera au démarrage du serveur. - - - Time Limit (hours) - Durée maximale (en heures) - - - Optional - Facultatif - - - Add - Ajouter - - - Save - Enregistrer - - - Remove - Supprimer - - - Max runtime %1 - Durée maximale %1 - - - Read-only - En lecture seule - - - This is a built-in system task reported by the server. Some properties may be managed by the server. - Il s'agit d'une tâche système intégrée signalée par le serveur. Certaines propriétés peuvent être gérées par le serveur. - - - Built-in trigger types are shown as read-only and will be preserved when you save. - Les types de déclencheurs intégrés sont affichés en lecture seule et seront conservés lors de l'enregistrement. - - - Changes in this dialog only update trigger rules. Start or stop a task from the tasks page. - Les modifications apportées dans cette boîte de dialogue ne concernent que les règles de déclenchement. Pour démarrer ou arrêter une tâche, rendez-vous sur la page des tâches. - - - Built-in server triggers are shown as read-only and will be preserved when you save. - Les déclencheurs de serveur intégrés apparaissent en lecture seule et seront conservés lorsque vous enregistrerez. - - - Add, remove, and save trigger rules for this task. - Ajouter, supprimer et enregistrer des règles de déclenchement pour cette tâche. - - - Please enter a valid time limit. - Veuillez saisir une durée valide. - - - This trigger already exists. - Ce déclencheur existe déjà. - - - No description provided. - Aucune description fournie. - - - Other - Autres - - - System task - Tâche système - - - Visible task - Tâche visible - - - Never run - Jamais lancé - - - Not available - Indisponible - - - Built-in - Intégré - - - Daily at %1 - Tous les jours à %1 - - - Every %n hour(s) - - Toutes les %n heures - - - - Every %n minute(s) - - Toutes les %n minutes - - - - %1 at %2 - %1 à %2 - - - On application startup - Au démarrage de l'application - - - Delete trigger - Supprimer le déclencheur - - - No triggers configured - Aucun déclencheur n'est configuré - - - Last: %1 - Dernier: %1 - - - Failed to load task: %1 - Échec du chargement de la tâche: %1 - - - Triggers saved - Déclencheurs enregistrés - - - Failed to save triggers: %1 - Échec de l'enregistrement des déclencheurs: %1 - - - - - TaskUtils - - Sunday - Dimanche - - - Monday - Lundi - - - Tuesday - Mardi - - - Wednesday - Mercredi - - - Thursday - Jeudi - - - Friday - Vendredi - - - Saturday - Samedi - - - display configuration changes - afficher les modifications de configuration - - - the server wakes from sleep - le serveur sort du mode veille - - - plugins are updated - les plugins sont mis à jour - - - No limit - Pas de limite - - - %n day(s) - - %n jour(s) - - - - %n hour(s) - - %n heure(s) - - - - %n minute(s) - - %n minute(s) - - - - Less than a minute - Moins d'une minute - - - Idle - Au repos - - - Running - Lancement - - - Cancelling - Annulation - - - Completed - Terminé - - - Failed - Échec - - - Aborted - Interrompu - - - Cancelled - Annulé - - - Daily - Quotidien - - - Interval - Intervalle - - - Weekly - Hebdomadaire - - - On Startup - Au démarrage - - - System Event - Événement système - - - Daily at %1 - Tous les jours à %1 - - - Every %1 - Chaque %1 - - - %1 at %2 - %1 à %2 - - - On server startup - Au démarrage du serveur - - - When %1 - Lorsque %1 - - - Manual only - Mode manuel uniquement - - - +%n more - - +%n de plus - - - - - ContextMenuUtils - - Undo - Annuler - - - Redo - Refaire - - - Cut - Couper - - - Copy - Copier - - - Paste - Coller - - - Delete - Supprimer - - - Select All - Tout sélectionner - - - - - DownloadManager - - Downloaded Media - Fichiers multimédias téléchargés - - - Failed to write download file - Échec de l'écriture du fichier téléchargé - - - Downloading - Téléchargement - - - Completed - Terminé - - - Download completed - Téléchargement terminé - - - Download failed - Échec du téléchargement - - - Failed to prepare download: %1 - Échec de la préparation du téléchargement: %1 - - - This media is not available for download - Ce fichier n'est pas disponible au téléchargement - - - Failed to resolve download address - Impossible de résoudre l'adresse de téléchargement - - - Failed to create download directory - Impossible de créer le répertoire de téléchargement - - - Failed to open download file - Impossible d'ouvrir le fichier téléchargé - - - Download started - Le téléchargement a commencé - - - Pausing - Mise en pause - - - Paused - En pause - - - Cancelling - Annulation - - - Cancelled - Annulé - - - Download cancelled - Téléchargement annulé - - - Download paused - Téléchargement en pause - - - Download resumed - Reprise du téléchargement - - - Download removed - Téléchargement supprimé - - - Switch back to the original server before resuming this download - Revenez au serveur d'origine avant de reprendre ce téléchargement - - - The original media source is no longer available - La source d'origine n'est plus disponible - - - - - DownloadManagerDialog - - Unknown - Inconnu - - - B - B - - - KB - KB - - - MB - MB - - - GB - GB - - - TB - TB - - - %1% (%2 / %3) - %1%(%2 / %3) - - - Downloading %1% (%2 / %3) - Téléchargement %1%(%2 / %3) - - - %1 downloaded - %1 téléchargé - - - Downloading %1 - Téléchargement %1 - - - %1 %2% (%3 / %4) - %1 %2%(%3 / %4) - - - %1 %2 - %1 %2 - - - Pausing - Mise en pause - - - Paused %1% (%2 / %3) - %1%(%2 / %3)en pause - - - Paused %1 - %1 en pause - - - Paused - En pause - - - Waiting... - En attente... - - - -- - -- - - - Downloads - Téléchargements - - - Download Manager - Gestionnaire de téléchargement - - - Total %1 Active %2 Completed %3 Failed %4 - Total %1 En cours %2 Terminé %3 Échec %4 - - - %1 records, %2 active, %3 completed, %4 failed - %1 enregistrements, %2 actifs, %3 terminés, %4 ayant échoué - - - No download records - Aucun enregistrement de téléchargement - - - No records yet - Il n'y a pas encore d'enregistrements - - - Download tasks will appear here after you start them. - Les tâches de téléchargement s'afficheront ici une fois que vous les aurez lancées. - - - Start a download and the record will appear here. - Lancez le téléchargement et le fichier apparaîtra ici. - - - No. - Non. - - - Active - Actif - - - Completed - Terminé - - - Failed - Échec - - - Download Directory - Répertoire de téléchargement - - - Download Location - Emplacement de téléchargement - - - Saved To - Enregistré dans - - - Choose where new downloads are saved. - Choisissez l'emplacement où enregistrer les nouveaux fichiers téléchargés. - - - Open Directory - Ouvrir un répertoire - - - Set Directory - Définir le répertoire - - - Current Downloads - Téléchargements en cours - - - Stopping a task removes the unfinished file immediately. - L'interruption d'une tâche entraîne la suppression immédiate du fichier inachevé.。 - - - No active downloads - Aucun téléchargement en cours - - - Media - Média - - - Size - Taille - - - Progress - Progression - - - Started - Lancé - - - Status - Statut - - - Action - Action - - - Download History - Historique des téléchargements - - - Select a record to open the media, jump to its folder, or remove it. - Sélectionnez un élément pour ouvrir le fichier multimédia, accéder à son dossier ou le supprimer. - - - No download history - Aucun historique de téléchargement - - - Result - Résultat - - - Finished - Terminé - - - No download record selected - Aucun enregistrement de téléchargement n'a été sélectionné - - - Open Media - Ouvrir média - - - Pause Download - Mettre en pause le téléchargement - - - Resume Download - Reprendre le téléchargement - - - Open Folder - Ouvrir le dossier - - - Delete Record - Supprimer l'enregistrement - - - Stop - Stop - - - Stop Download - Arrêter le téléchargement - - - Stop and remove the unfinished file - Arrêtez et supprimez le fichier inachevé - - - Select Download Directory - Sélectionner le répertoire de téléchargement - - - The download folder is no longer available. - Le dossier de téléchargement n'est plus disponible. - - - The downloaded media file is no longer available. - Le fichier multimédia téléchargé n'est plus disponible. - - - Also Delete File - Supprimer également le fichier - - - Also remove the local file when deleting a history record - Supprimez également le fichier local lors de la suppression d'un enregistrement de l'historique - - - Delete download record for "%1"? - Souhaitez-vous supprimer l'historique de téléchargement pour "%1"? - - - Delete download record for "%1" and remove the local file? - Supprimer l'historique de téléchargement pour "%1" et supprimer le fichier local ? - - - Cancel active download for "%1" and remove the unfinished file? - Annuler le téléchargement en cours de "%1" et supprimer le fichier inachevé ? - - - Delete - Supprimer - - - Cancel - Annuler - - - Delete File - Supprimer le fichier - - - Delete the downloaded file too? - Supprimer également le fichier téléchargé ? - - - Keep File - Conserver le fichier - - - Server: %1 -Status: %2 -Added: %3 -Finished: %4 -Size: %5 - Serveur: %1 -État: %2 -Ajouté: %3 -Terminé: %4 -Taille: %5 - - - Saved To: %1 - Enregistré dans: %1 - - - - - PlayerView + Danmaku Danmaku + + Enable Danmaku + Activer le Danmaku + + + + Display streaming danmaku in the embedded player + Afficher le danmaku en streaming dans le lecteur intégré + + + + Danmaku Renderer + Rendu de danmaku + + + + Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer + Choisissez si le danmaku doit être affiché via la piste de sous-titres ASS ou via le moteur de rendu natif adaptatif de qEmby + + + + ASS Subtitle Track + Piste de sous-titres ASS + + + + Native Smooth Renderer + Rendu fluide natif + + + + Auto Load Danmaku + Chargement automatique du Danmaku + + + + Automatically search and load danmaku when playback starts + Rechercher et charger automatiquement le danmaku au démarrage de la lecture + + + + Auto Match Danmaku + Danmaku à correspondance automatique + + + + Automatically choose the best danmaku match when no cached manual result exists + Choisir automatiquement le mieux adapté parmi les danmaku lorsqu'aucun résultat manuel n'est enregistré en cache + + + + DandanPlay Open API + API ouverture DandanPlay + + + + Danmaku Server + Serveur Danmaku + + + + Manage + Gérer + + + + Prefer Online + Préférez le service en ligne + + + + Prefer Local + Préférez le service local + + + + Online Only + Uniquement en ligne + + + + Local Only + Uniquement local + + + + Danmaku Source + Danmaku Source + + + + Choose whether local danmaku files or online danmaku should be preferred during matching + Choisissez si vous préférez utiliser les fichiers danmaku locaux ou les danmaku en ligne lors de la mise en correspondance + + + + Local Danmaku Folder + Dossier local Danmaku + + + + Place .ass, .json, or .xml danmaku files here. Matching uses the current media title and manual keyword, and the selected danmaku source mode decides whether local files or online results are preferred first. +Folder: %1 + Placez ici vos fichiers danmaku au format .ass, .json ou .xml. La correspondance s'effectue en fonction du titre du média actuel et du mot-clé défini manuellement ; le mode de source danmaku sélectionné détermine si les fichiers locaux ou les résultats en ligne sont prioritaires. +Dossier: %1 + + + + Open Folder + Ouvrir le dossier + + + Current server: %1 +Address: %2 + Serveur actuel: %1 +Adresse: %2 + + + + Current server: %1 + Serveur actuel: %1 + + + + Address: %1 + Adresse: %1 + + + + Supported content: Anime only + Contenu pris en charge : anime uniquement + + + + Description: %1 + Description: %1 + + + + App ID and App Secret are required for official DandanPlay Open API access + L'identifiant d'application et la clé secrète de l'application sont obligatoires pour accéder à l'API ouverte officielle de DandanPlay + + + Danmaku Provider + Fournisseur de Danmaku + + + Select which danmaku service to query for matches and comments + Sélectionnez le service Danmaku sur lequel rechercher des correspondances et des commentaires + + + + Load Related Comments + Charger les commentaires associés + + + + Include related third-party comment sources when the provider supports it + Inclure les sources de commentaires tierces pertinentes lorsque le fournisseur le permet + + + + Dual Subtitle Mode + Mode double sous-titrage + + + + Keep regular subtitles visible alongside danmaku when possible + Dans la mesure du possible, affichez les sous-titres de manière permanente à côté du danmaku + + + + Danmaku Opacity + Opacité du danmaku + + + + Control the transparency of rendered danmaku + Contrôler la transparence du danmaku affiché + + + + Danmaku Size + Taille du danmaku + + + Danmaku Font Weight + Épaisseur de la police Danmaku + + + Adjust the font weight used for danmaku text + Régler l'épaisseur de la police utilisée pour le texte du danmaku + + + Danmaku Outline + Contour du danmaku + + + Control the outline thickness applied to danmaku text + Contrôler l'épaisseur du contour appliqué au texte du danmaku + + + Danmaku Shadow + L'Ombre du Danmaku + + + Control the shadow offset applied to danmaku text + Contrôler le décalage de l'ombre appliqué au texte du danmaku + + + + Scale the font size used for danmaku rendering + Ajuster la taille de la police utilisée pour l'affichage du danmaku + + + + Display Area + Zone d'affichage + + + + Limit how much of the screen height danmaku can occupy + Limiter la hauteur de l'écran que le danmaku peut occuper + + + + Danmaku Density + Densité du danmaku + + + + Reduce or increase the number of simultaneous lanes + Réduire ou augmenter le nombre de voies simultanées + + + + Danmaku Speed + Vitesse du danmaku + + + + Adjust how quickly scrolling danmaku travels across the screen + Réglez la vitesse à laquelle le danmaku défile à l'écran + + + + Danmaku Offset + Décalage du danmaku + + + + Shift danmaku timing earlier or later to align with playback + Avancez ou reculez le temps du danmaku pour l'aligner sur la lecture + + + + Hide Scrolling Danmaku + Masquer le danmaku défilant + + + + Hide standard right-to-left scrolling comments + Masquer les commentaires standard à défilement de droite à gauche + + + + Hide Top Danmaku + Masquer le danmaku du haut + + + + Hide fixed danmaku positioned at the top of the screen + Masquer les danmaku fixes situés en haut de l'écran + + + + Hide Bottom Danmaku + Masquer le danmaku du bas + + + + Hide fixed danmaku positioned at the bottom of the screen + Masquer les danmaku fixes situés en bas de l'écran + + + + Blocked Keywords + Mots-clés bloqués + + + + Filter out danmaku containing these keywords + Filtrer les danmaku contenant ces mots-clés + + + Provider Base URL + URL de base du fournisseur + + + Set a custom Danmaku API endpoint for official or compatible services + Définir un point de terminaison API Danmaku personnalisé pour les services officiels ou compatibles + + + Danmaku App ID + Danmaku App ID + + + Optional + Facultatif + + + Optional App ID for authenticated DandanPlay Open API requests + Identifiant d'application facultatif pour les requêtes authentifiées de l'API ouverte DandanPlay + + + Optional App ID used by the selected danmaku server + ID d'application facultatif utilisé par le serveur danmaku sélectionné + + + Danmaku App Secret + Danmaku App Secret + + + Optional App Secret used to sign DandanPlay Open API requests + Clé d'application facultative utilisée pour signer les requêtes de l'API ouverte DandanPlay + + + Optional App Secret used by the selected danmaku server + Clé d'application facultative utilisée par le serveur danmaku sélectionné + + + + Danmaku Cache Duration + Durée de conservation du cache Danmaku + + + + Reuse online match results, cached comment payloads, and generated ASS files for this long + Réutiliser les résultats des correspondances en ligne, les commentaires mis en cache et les fichiers ASS générés pendant tout ce temps + + + + Clear Danmaku Cache + Vider le cache Danmaku + + + + Remove cached online matches, cached comment payloads, and generated ASS files + Supprimer les correspondances en ligne mises en cache, les données de commentaires mises en cache et les fichiers ASS générés + + + + Danmaku cache cleared + Mémoire cache Danmaku effacée + + + Slow + Lent + + + Normal + Normal + + + Fast + Rapide + + + + Right Sidebar + Barre latérale droite + + + + Bottom HUD + Interface en bas de l'écran + + + + Media Switcher Position + Position du sélecteur de média + + + + Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD + Choisissez si les films récents et le changement de saison ou d'épisode s'affichent dans la barre latérale droite ou dans l'interface en bas de l'écran + + + + PageTasks + + Tasks + Tâches + + + Refresh + Actualiser + + + Loading... + Chargement en cours... + + + Refresh failed + Échec de l'actualisation + + + All Tasks + Toutes les tâches + + + No tasks found + Aucune tâche trouvée + + + No tasks + Aucune tâche + + + %n task(s) + + %n tâche(s) + + + + + %n running + + %n en cours + + + + + %n system + + %n système + + + + + %1 · %2 + %1 · %2 + + + Other + Autres + + + Scheduled + Planifié + + + Manual + Manuel + + + System + Système + + + Running Now + En cours + + + Tasks that are currently executing. Progress refreshes automatically. + Tâches en cours d'exécution. La progression est mise à jour automatiquement. + + + Scheduled Tasks + Tâches planifiées + + + Tasks with one or more configured triggers. + Tâches comportant un ou plusieurs déclencheurs configurés. + + + Manual Tasks + Tâches manuelles + + + Tasks that only run when started manually. + Tâches qui ne s'exécutent que lorsqu'elles sont lancées manuellement. + + + System Tasks + Tâches système + + + Hidden or built-in tasks reported by the server. + Tâches masquées ou intégrées signalées par le serveur. + + + Details + Détails + + + Schedule: %1 + Programme:%1 + + + State: %1 + État: %1 + + + Last result: %1 + Dernier résultat: %1 + + + %1 · %2% + %1 · %2% + + + Run task + Exécuter la tâche + + + Stop task + Arrêter la tâche + + + Last: %1 + Dernier: %1 + + + %1 — %2% + %1 — %2% + + + Idle + Au repos + + + Running + En cours + + + Cancelling + Annulation + + + Completed + Terminé + + + Failed + Échec + + + Aborted + Interrompu + + + Cancelled + Annulé + + + Task "%1" started + La tâche "%1" a démarré + + + Task "%1" stopped + La tâche "%1" s'est arrêtée + + + Failed to load tasks: %1 + Échec du chargement des tâches: %1 + + + Failed to run task: %1 + Échec de l'exécution de la tâche: %1 + + + Failed to stop task: %1 + Échec de l'arrêt de la tâche: %1 + + + + PageTranscoding + + Transcoding + Transcodage + + + Refresh + Actualiser + + + Save + Enregistrer + + + Loading... + Chargement en cours... + + + Server + Serveur + + + Acceleration + Accélération + + + Tone Mapping + Mappage des tons + + + Temp Path + Chemin temporaire + + + General + Général + + + Paths, threads and core transcoding switches. + Chemins d'accès, threads et commutateurs de transcodage principaux. + + + Temporary Path + Chemin temporaire + + + Encoding Threads + Threads encodage + + + Auto + Auto + + + Enable Fallback Font + Activer la police de secours + + + Fallback Font Path + Chemin d'accès à la police de secours + + + Audio VBR + Audio VBR + + + FFmpeg Path + Chemin d'accès à FFmpeg + + + Extraction Threads + Threads d'extraction + + + Subtitle Extraction + Extraction des sous-titres + + + Quality & Cleanup + Qualité et nettoyage + + + Quality, buffering and segment retention. + Qualité, mise en mémoire tampon et conservation des segments. + + + H.264 CRF + H.264 CRF + + + Downmix Boost + Amplification du downmix + + + Enable Throttling + Activer la limitation + + + H.265 CRF + H.265 CRF + + + None + Aucun + + + Dave750 + Dave750 + + + Nightmode + Mode nuit + + + NightmodeDialogue + Mode nuit - Dialogue + + + Dolby Pro Logic + Dolby Pro Logic + + + Dolby Pro Logic II + Dolby Pro Logic II + + + RFC7845 + RFC7845 + + + AC-4 + AC-4 + + + Stereo Downmix + Mixage stéréo + + + Very Slow + Très lent + + + Slower + Plus lent + + + Slow + Lent + + + Medium + Moyen + + + Fast + Rapide + + + Faster + Plus rapide + + + Very Fast + Très rapide + + + Super Fast + Super rapide + + + Ultra Fast + Ultra Fast + + + Encoder Preset + Préréglage de l'encodeur + + + Max Muxing Queue + File d'attente maximale + + + Throttle Delay + Retard d'accélération + + + Enable Segment Deletion + Activer la suppression des segments + + + Segment Keep Seconds + Conserver les secondes du segment + + + YADIF + YADIF + + + BWDIF + BWDIF + + + Bob + Bob + + + Deinterlace Method + Méthode de désentrelacement + + + Double-Rate Deinterlace + Désentrelacement à double débit + + + Throttle Buffer + Tampon d'accélérateur + + + Throttle Hysteresis + Hystérésis de l'accélérateur + + + Throttling Method + Méthode de limitation + + + Hardware Acceleration + Accélération matérielle + + + Devices, decode paths and hardware encoder toggles. + Périphériques, chemins de décodage et commutateurs d'encodeur matériel. + + + Enable Hardware Encoding + Activer l'encodage matériel + + + QSV + QSV + + + NVENC + NVENC + + + AMF + AMF + + + VAAPI + VAAPI + + + VideoToolbox + VideoToolbox + + + V4L2M2M + V4L2M2M + + + RKMPP + RKMPP + + + VAAPI Device + VAAPI Périphérique + + + QSV Device + QSV Périphérique + + + Hardware Decoding Codecs + Codecs de décodage matériel + + + Prefer Native Decoder + Privilégier le décodeur natif + + + Enhanced NVDEC + NVDEC amélioré + + + Intel Low Power H.264 + Intel H.264 à faible consommation + + + Intel Low Power HEVC + Intel HEVC à faible consommation + + + Allow HEVC Encode + Autoriser l'encodage HEVC + + + Allow AV1 Encode + Autoriser l'encodage AV1 + + + Codec Paths + Chemins d'accès aux codecs + + + HDR conversion and tone mapping controls. + Commandes de conversion HDR et de mappage des tons. + + + Enable Tonemapping + Activer le mappage de tons + + + Enable VPP Tonemapping + Activer le mappage de tons VPP + + + Enable VideoToolbox Tonemapping + Activer le mappage de tons VideoToolbox + + + Algorithm + Algorithme + + + BT.2390 + BT.2390 + + + Hable + Hable + + + Reinhard + Reinhard + + + Mobius + Mobius + + + Clip + Clip + + + Linear + Linear + + + Gamma + Gamma + + + Mode + Mode + + + MAX + MAX + + + RGB + RGB + + + LUM + LUM + + + ITP + ITP + + + Range + Gamme + + + TV + TV + + + PC + PC + + + Desaturation + Désaturation + + + Peak + Pointe + + + Parameter + Paramètre + + + VPP Brightness + VPP Luminosité + + + VPP Contrast + VPP Contraste + + + Hardware Tone Mapping + Mappage de tons matériel + + + Software Tone Mapping + Mappage de tons logiciel + + + Advanced + Avancé + + + Codec-level and extraction-specific options. + Options au niveau du codec et spécifiques à l'extraction. + + + 10-bit HEVC Decode + 10-bit HEVC Décodage + + + 10-bit VP9 Decode + 10-bit VP9 Décodage + + + 10-bit HEVC Rext + 10-bit HEVC Rext + + + 12-bit HEVC Rext + 12-bit HEVC Rext + + + Keyframe Extensions + Extensions d'images clés + + + No transcoding configuration available + Aucune configuration de transcodage n'est disponible + + + Refresh failed + Échec de l'actualisation + + + Failed to load transcoding settings + Échec du chargement des paramètres de transcodage + + + Failed to load transcoding settings: %1 + Échec du chargement des paramètres de transcodage:%1 + + + Saving... + Enregistrement en cours... + + + Transcoding settings saved + Paramètres de transcodage enregistrés + + + Failed to save transcoding settings: %1 + Échec de l'enregistrement des paramètres de transcodage:%1 + + + Jellyfin + Jellyfin + + + Emby + Emby + + + Unknown + Inconnu + + + Disabled + Désactivé + + + Enabled + Activé + + + Mode %1 + Mode %1 + + + VPP + VPP + + + Mixed + Mixte + + + Hardware + Matériel + + + Software + Logiciel + + + Default + Par défaut + + + Custom + Personnalisé + + + %1 · %n visible setting(s) + + %1 · %n paramètre(s) visible(s) + + + + + %n codec path(s) + + %n chemin(s) d'accès au(x) codec(s) + + + + + +%1 more + +%1 de plus + + + Decoder + Décodeur + + + Core transcoding switches used by the current server. + Commutateurs de transcodage principaux utilisés par le serveur actuel. + + + Transcoding Temp Path + Chemin d'accès temporaire pour le transcodage + + + Enable Subtitle Extraction + Activer l'extraction des sous-titres + + + Downmix Audio Boost + Amplification audio du mixage + + + Hardware transcoding mode, decoder preference and codec support. + Mode de transcodage matériel, préférences du décodeur et prise en charge des codecs. + + + Hardware Acceleration Type + Type d'accélération matérielle + + + Prefer System Native Decoder + Privilégier le décodeur natif du système + + + Allow HEVC Encoding + Autoriser l'encodage HEVC + + + Allow AV1 Encoding + Autoriser l'encodage AV1 + + + No + Non + + + Yes + Oui + + + Preferred Hardware Decoder + Décodeur matériel recommandé + + + Preferred Hardware Encoder + Encodeur matériel recommandé + + + HDR conversion and tone mapping behavior. + Comportement de la conversion HDR et du tone mapping. + + + Hardware + Software + Matériel + Logiciel + + + Automatic + Automatique + + + System Native + Natif au système + + + 10-bit Decoding + 10-bit Décodage + + + AV1 + AV1 + + + By Segment Request + Par demande de segment + + + Codec Information + Informations sur le codec + + + Drag to Reorder + Faites glisser pour réorganiser + + + Custom Order + Commande sur mesure + + + Encoding Formats + Formats d'encodage + + + H264 + H264 + + + HEVC + HEVC + + + HEVC 10-bit + HEVC 10-bit + + + HEVC RExt 12-bit + HEVC RExt 12-bit + + + HEVC RExt 8/10-bit + HEVC RExt 8/10-bit + + + HEVC RExt Decoding + HEVC RExt Décodage + + + Hardware Decoding + Décodage matériel + + + Hardware Encoding + Encodage matériel + + + MPEG1 + MPEG1 + + + MPEG2 + MPEG2 + + + MPEG4 + MPEG4 + + + Metadata Keyframe Extensions + Extensions des images clés de métadonnées + + + Move Down + Descendre + + + Move Up + Monter + + + OK + OK + + + Paths and Threads + Chemins et Threads + + + Preferred Hardware Decoders + Décodeurs matériels recommandés + + + Preferred Hardware Encoders + Encodeurs matériels préférés + + + Quality and Processing + Qualité et transformation + + + Software Encoders + Encodeurs logiciels + + + Settings + Paramètres + + + Throttling and Segments + Limitation et segments + + + VC1 + VC1 + + + VP8 + VP8 + + + VP9 + VP9 + + + VP9 10-bit + VP9 10-bit + + + VPP Tone Mapping + VPP Mappage des tons + + + + Select Folder + + + + + Select File + + + + + PageUsers + + Users + Utilisateurs + + + Refresh + Actualiser + + + Add user + Ajouter un utilisateur + + + No users found + Aucun utilisateur trouvé + + + No users + Aucun utilisateur + + + %n user(s) + + %n utilisateur(s) + + + + + Admin + Admin + + + Disabled + Désactivé + + + Last login: %1 + Dernière connexion: %1 + + + Last active: %1 + Dernière activité: %1 + + + Enable user + Activer l'utilisateur + + + Disable user + Désactiver l'utilisateur + + + Cannot disable yourself + Vous ne pouvez pas vous désactiver vous-même + + + User "%1" disabled + L'utilisateur "%1" a été désactivé + + + User "%1" enabled + L'utilisateur "%1" a été activé + + + Failed to load users: %1 + Échec du chargement des utilisateurs: %1 + + + Failed to update user: %1 + Échec de la mise à jour de l'utilisateur: %1 + + + Delete user + Supprimer un utilisateur + + + Cannot delete yourself + Vous ne pouvez pas vous supprimer vous-même + + + Delete User + Supprimer un utilisateur + + + Are you sure you want to delete user "%1"? This action cannot be undone. + Êtes-vous sûr de vouloir supprimer l'utilisateur "%1"? Cette action ne peut pas être annulée. + + + Delete + Supprimer + + + Cancel + Annuler + + + User "%1" deleted + L'utilisateur "%1" a été supprimé + + + Failed to delete user: %1 + Échec de la suppression de l'utilisateur: %1 + + + + PathRowWidget + + + Browse server directory + Parcourir le répertoire du serveur + + + + Remove this path + Supprimer ce chemin + + + + Click + to browse... + Cliquez sur + pour parcourir... + + + + PlaybackManager + + + External player not found, falling back to internal player + Le lecteur externe est introuvable ; on passe au lecteur interne + + + + External player not found + + + + + Failed to launch external player + Impossible de lancer le lecteur externe + + + + Launching %1... + Lancement %1... + + + + PlayerDanmakuController + + + Danmaku Disabled + Danmaku désactivé + + + + Danmaku Enabled + Danmaku activé + + + + + Danmaku Loaded (%1) + Danmaku chargé (%1) + + + + + Danmaku Loaded + Danmaku chargé + + + + + Danmaku Loaded from %1 (%2) + Danmaku chargé à %1 (%2) + + + + + Danmaku Loaded from %1 + Danmaku chargé à %1 + + + + No matching danmaku found + Aucun danmaku correspondant n'a été trouvé + + + + + No danmaku available + Aucun danmaku disponible + + + + + Failed to load danmaku + Échec du chargement de danmaku + + + + Unsupported danmaku file + Fichier danmaku non pris en charge + + + + Danmaku Shown + Danmaku affiché + + + + Danmaku Hidden + Danmaku caché + + + + PlayerDanmakuIdentifyDialog + + Local File Fichier local + DandanPlay Continuer à jouer + Unknown Source Source inconnue - No danmaku loaded - Aucun danmaku n'est chargé - - - Loading danmaku... - Chargement du danmaku... - - - Unknown Danmaku - Danmaku inconnu - - + %1 comments %1 commentaires + Comment count unavailable Nombre de commentaires indisponible - Current: %1 | %2 | %3 - Actuellement: %1 | %2 | %3 + + + Unknown Danmaku + Danmaku inconnu + + Source: %1 + Source: %1 + + + Server: %1 Serveur: %1 + + Danmaku Server: %1 + Danmaku Serveur: %1 + + + + Provider: %1 + Fournisseur: %1 + + + + Title: %1 + Titre: %1 + + + + Series: %1 + Séries: %1 + + + + Comments: %1 + Commentaires: %1 + + + + Unknown + Inconnu + + + + Score: %1 + Score: %1 + + + + Episode: S%1E%2 + Épisode: S%1E%2 + + + + Match Reason: %1 + Motif de la correspondance: %1 + + + + Target: %1 + Cible: %1 + + + + Search Danmaku + Rechercher Danmaku + + + + this video + cette vidéo + + + + Search danmaku sources for "%1" and choose the best match. + Recherchez les sources de danmaku contenant "%1" et sélectionnez celle qui correspond le mieux. + + + + Enter title or keyword + Entrez un titre ou un mot-clé + + + + Search + Rechercher + + + + No results match "%1" (%2 total) + + + + + Showing %1 of %2 matches for "%3" + + + + Search for local or online danmaku sources. + Recherchez des sources de danmaku locales ou en ligne. + + + + Search local files and enabled danmaku servers. + Rechercher dans les fichiers locaux et sur les serveurs danmaku activés. + + + + Filter results (title, series, server) + + + + + Danmaku Details + Détails sur le Danmaku + + + + + Select a danmaku source to view details. + Sélectionnez une source de danmaku pour afficher les détails. + + + + Cancel + Annuler + + + + Load Danmaku + Charger Danmaku + + + Searching... + Recherche en cours... + + + + Searching local files and enabled danmaku servers... + Recherche des fichiers locaux et des serveurs danmaku activés... + + + + No matches found + Aucune correspondance trouvée + + + + Found %1 matches + %1 résultats trouvés + + + + Found %1 matches from %2 danmaku servers + %1 résultat trouvé parmi les %2 serveurs danmaku + + + + Search failed + Échec de la recherche + + + + Failed to search danmaku: %1 + Échec de la recherche de danmaku: %1 + + + + PlayerDanmakuSettingsDialog + + + Danmaku Settings + Paramètres du danmaku + + + + Adjust danmaku rendering without leaving playback. Changes are saved and applied immediately. + Réglez l'affichage du danmaku sans quitter la lecture. Les modifications sont enregistrées et appliquées immédiatement. + + + ASS Subtitle Track + Piste de sous-titres ASS + + + Native Smooth Renderer + Rendu fluide natif + + + Danmaku Renderer + Rendu de danmaku + + + Switch between the ASS subtitle track pipeline and qEmby's adaptive native danmaku renderer. + Basculer entre le pipeline de pistes de sous-titres ASS et le moteur de rendu adaptatif natif de qEmby pour les danmaku. + + + Enable Danmaku + Activer le Danmaku + + + Display streaming danmaku in the embedded player + Afficher le danmaku en streaming dans le lecteur intégré + + + Dual Subtitle Mode + Mode double sous-titrage + + + Keep regular subtitles visible alongside danmaku when possible + Dans la mesure du possible, affichez les sous-titres de manière permanente à côté du danmaku + + + + Danmaku Opacity + Opacité du danmaku + + + Control the transparency of rendered danmaku + Contrôler la transparence du danmaku affiché + + + + Danmaku Size + Taille du danmaku + + + + Danmaku Font Weight + Épaisseur de la police Danmaku + + + + Danmaku Outline + Contour du Danmaku + + + + Danmaku Shadow + Ombre de Danmaku + + + + Display Area + Zone d'affichage + + + Limit how much of the screen height danmaku can occupy + Limiter la hauteur de l'écran que le danmaku peut occuper + + + + Danmaku Density + Densité du danmaku + + + Reduce or increase the number of simultaneous lanes + Réduire ou augmenter le nombre de voies simultanées + + + + Danmaku Speed + Vitesse du danmaku + + + Adjust how quickly scrolling danmaku travels across the screen + Réglez la vitesse à laquelle le danmaku défile à l'écran + + + + Danmaku Offset + Décalage du danmaku + + + Shift danmaku timing earlier or later to align with playback + Avancez ou reculez le temps du danmaku pour l'aligner sur la lecture + + + + Hide Scrolling Danmaku + Masquer le danmaku défilant + + + Hide standard right-to-left scrolling comments + Masquer les commentaires standard à défilement de droite à gauche + + + + Hide Top Danmaku + Masquer le danmaku du haut + + + Hide fixed danmaku positioned at the top of the screen + Masquer les danmaku fixes situés en haut de l'écran + + + + Hide Bottom Danmaku + Masquer le danmaku du bas + + + Hide fixed danmaku positioned at the bottom of the screen + Masquer les danmaku fixes situés en bas de l'écran + + + + Blocked Keywords + Mots-clés bloqués + + + 40% + 40% + + + 55% + 55% + + + 72% + 72% + + + 85% + 85% + + + 100% + 100% + + + 80% + 80% + + + 120% + 120% + + + 140% + 140% + + + Low + Faible + + + Medium + Moyen + + + High + Élevé + + + Slow + Lent + + + Normal + Normal + + + Fast + Rapide + + + -1000 ms + -1000 ms + + + -500 ms + -500 ms + + + 0 ms + 0 ms + + + +500 ms + +500 ms + + + +1000 ms + +1000 ms + + + Close + Fermer + + + + PlayerLongPressHandler + + + + Speed: %1X + Vitesse: %1X + + + + PlayerMediaSwitcherPanel + + + Continue Watching + Continuer à regarder + + + + Seasons + Saisons + + + + + Episodes + Épisodes + + + Loading... + Chargement en cours... + + + + No active media found. + Aucun média actif n'a été trouvé. + + + + No episodes found. + Aucun épisode trouvé. + + + S%1E%2 %3 + S%1E%2 %3 + + + Episode %1 %2 + Épisode %1 %2 + + + %1 - S%2E%3 - %4 + %1 - S%2E%3 - %4 + + + %1 - %2 + %1 - %2 + + + %1 - %2 - %3 + %1 - %2 - %3 + + + Season %1 + Saison %1 + + + + PlayerPage + + + + + Speed %1× + + + + + File + Fichier + + + + Position + + + + + Speed + Vitesse + + + + Volume + + + + + Muted + En sourdine + + + + Video + Vidéo + + + + Software (CPU) + Logiciels (CPU) + + + + Decoder + Décodeur + + + + VO + + + + + relay + + + + + Cache + Cache + + + + + Danmaku + Danmaku + + + + « Seek + + + + + Seek » + + + + + Buffering… + + + + + Mute + + + + + Back + Retour + + + + Loading… + + + + + Now + + + + + Buffered + + + + + Not buffered + + + + + + Audio + Audio + + + + Subs + + + + + 弹幕 + + + + + Episodes + Épisodes + + + + Disable Audio + Désactiver le son + + + + Audio off + + + + + Subtitles + Sous-titres + + + + Disable Subtitles + Désactiver les sous-titres + + + + Subtitles off + + + + + Subtitle settings… + + + + + Playback Speed + Vitesse de lecture + + + + Disable danmaku + + + + + Enable danmaku + + + + + Danmaku on + + + + + Danmaku off + + + + + Hide danmaku + + + + + Show danmaku + + + + + Reload + + + + + Reloading danmaku… + + + + + Identify… + + + + + Danmaku settings… + + + + + No status + + + + + PlayerSubtitleSettingsDialog + + + Auto + Auto + + + + Subtitle Settings + Paramètres des sous-titres + + + + Adjust subtitle rendering without leaving playback. Changes are saved and applied immediately. + Modifiez l'affichage des sous-titres sans quitter la lecture. Les modifications sont enregistrées et appliquées immédiatement. + + + + Subtitle Font + Police des sous-titres + + + + + Choose the font family used for subtitle rendering + Choisissez la police utilisée pour l'affichage des sous-titres + + + + Subtitle Timing + Temporisation des sous-titres + + + + Subtitle Size + Taille des sous-titres + + + + Subtitle Position + Position des sous-titres + + + + Subtitle Scale + Échelle des sous-titres + + + + Outline Size + Taille du contour + + + + Shadow Offset + Décalage de l'ombre + + + + PlayerView + + + + + + Continue Watching + Continuer à regarder + + + + + + Episodes + Épisodes + + + + No episodes found. + Aucun épisode trouvé. + + + + No active media found. + Aucun média actif n'a été trouvé. + + + + Network Speed + Vitesse du réseau + + + + Network Speed Enabled + Vitesse du réseau activée + + + + Network Speed Disabled + Vitesse du réseau désactivée + + + + Statistics + Statistiques + + + + File + Fichier + + + + Session + Session + + + + Item ID + Élément ID + + + + %1% + + + + + + Load Local Subtitle File + + + + + Player is not ready + + + + + Subtitle Files (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;SubRip (*.srt);;Advanced SubStation Alpha (*.ass *.ssa);;WebVTT (*.vtt);;MicroDVD / SubViewer (*.sub);;VobSub (*.idx);;SAMI (*.smi);;PGS / HDMV (*.sup);;All Files (*) + + + + + Subtitle Loaded: %1 + + + + + Media Source ID + Source média ID + + + + Play Session ID + Session de jeu ID + + + + Cache + Cache + + + + Playback + Lecture + + + + Refresh Rate + Fréquence de rafraîchissement + + + + Dropped Frames + Images perdues + + + + Output + Sortie + + + + Decoder + Décodeur + + + + Color + Couleur + + + + Channels + Chaînes + + + + Sample Rate + Fréquence d'échantillonnage + + + + Device + Appareil + + + + Subtitle + Sous-titre + + + + Speed + Vitesse + + + + A-V + A-V + + + + Software (CPU) + Logiciels (CPU) + + + + %1-bit + %1-bit + + + + AO %1 + AO %1 + + + + %1 ch + %1 ch + + + + + Chapter + Chapitre + + + + Size + Taille + + + + Video + Vidéo + + + + Audio + Audio + + + + Frame Rate + Fréquence d'images + + + + Resolution + Résolution + + + + Format + Format + + + + + Bitrate + Débit binaire + + + + STRM Direct Play + STRM Lecture directe + + + + STRM Direct Play: ON (Reloading...) + STRM Lecture directe: activée (Rechargement...) + + + + STRM Direct Play: OFF (Reloading...) + Lecture directe STRM : désactivée (Rechargement...) + + + + Speed: %1X + Vitesse: %1X + + + + Disable Audio + Désactiver le son + + + + Track %1 + Piste %1 + + + + Audio: %1 + Audio: %1 + + + No Audio Tracks + Aucune piste audio + + + + Audio Disabled + Audio désactivé + + + + Disable Subtitles + Désactiver les sous-titres + + + + Subtitle %1 + Sous-titre %1 + + + + Subtitle: %1 + Sous-titre: %1 + + + + Subtitles Disabled + Sous-titres désactivés + + + + External Sub %1 + Sous-titres externes %1 + + + + Forward %1s + Avancer de %1 s + + + + Rewind %1s + Reculer de %1 s + + + + Scale: Fit + Échelle : Ajustée + + + + Scale: Crop + Échelle : Recadrage + + + + Scale: Stretch + Échelle : étirement + + + + Scale: Original + Échelle : originale + + + + Muted + En sourdine + + + + + + Volume: %1% + Volume: %1% + + + + Back + Retour + + + + Minimize + Réduire + + + + Maximize + Agrandir + + + + Close + Fermer + + + + Previous + + + + + Rewind + Revenir en arrière + + + + Play/Pause + Lecture/Pause + + + + Forward + Suivant + + + + Next + + + + + Mute/Unmute + Activer/désactiver le son + + + + Playback Speed + Vitesse de lecture + + + + Audio Track + Piste audio + + + + Subtitles + Sous-titres + + + + Settings + Paramètres + + + + Aspect Ratio + Format d'image + + + + Fullscreen + Plein écran + + + + + + + Danmaku + Danmaku + + + + No previous episode + + + + + No previous media + + + + + No next episode + + + + + No next media + + + + + Local File + Fichier local + + + + DandanPlay + Continuer à jouer + + + + Unknown Source + Source inconnue + + + + + No danmaku loaded + Aucun danmaku n'est chargé + + + + Loading danmaku... + Chargement du danmaku... + + + + + Unknown Danmaku + Danmaku inconnu + + + + + %1 comments + %1 commentaires + + + + + Comment count unavailable + Nombre de commentaires indisponible + + + + Current: %1 | %2 | %3 + Actuellement: %1 | %2 | %3 + + + + Server: %1 + Serveur: %1 + + + Danmaku Source: %1 Provider: %2 @@ -7273,6 +11654,7 @@ Fournisseur: %2 Commentaires: %3 + Danmaku Source: %1 Server: %2 @@ -7285,1125 +11667,4018 @@ Fournisseur: %3 Commentaires: %4 + Reload Danmaku Recharger Danmaku Manual Match Danmaku - Mode manuel Danmaku + Mode manuel Danmaku + Hide Danmaku Masquer le danmaku + Show Danmaku Afficher le danmaku + + Danmaku Settings Paramètres du danmaku + + Subtitle Settings Paramètres des sous-titres + + Load Local Danmaku File Charger un fichier Danmaku local + Search Danmaku Rechercher Danmaku + + Danmaku (Loading...) Danmaku (Chargement en cours...) + + Danmaku (Hidden) Danmaku (Caché) Danmaku (Loaded) - Danmaku (Chargé) + Danmaku (Chargé) Danmaku (%1) - Danmaku(%1) + Danmaku(%1) Manual Danmaku Match - Rencontre manuelle de Danmaku + Rencontre manuelle de Danmaku + + Danmaku is unavailable for the current media - Danmaku n'est pas disponible pour le support actuel + Danmaku n'est pas disponible pour le support actuel + Danmaku Files (*.ass *.json *.xml);;ASS Files (*.ass);;JSON Files (*.json);;XML Files (*.xml) Fichiers Danmaku (*.ass *.json *.xml) ;; Fichiers ASS (*.ass) ;; Fichiers JSON (*.json) ;; Fichiers XML (*.xml) Enter a keyword to search danmaku for the current video. - Saisissez un mot-clé pour rechercher des danmaku dans la vidéo en cours. + Saisissez un mot-clé pour rechercher des danmaku dans la vidéo en cours. Series name or episode title - Nom de la série ou titre de l'épisode + Nom de la série ou titre de l'épisode Search - Rechercher + Rechercher - - - - PlayerDanmakuController - - Danmaku Disabled - Danmaku désactivé - - - Danmaku Enabled - Danmaku activé - - - Danmaku Loaded (%1) - Danmaku chargé (%1) - - - Danmaku Loaded - Danmaku chargé - - - Danmaku Loaded from %1 (%2) - Danmaku chargé à %1 (%2) - - - Danmaku Loaded from %1 - Danmaku chargé à %1 - - - No matching danmaku found - Aucun danmaku correspondant n'a été trouvé - - - No danmaku available - Aucun danmaku disponible - - - Failed to load danmaku - Échec du chargement de danmaku - - - Unsupported danmaku file - Fichier danmaku non pris en charge - - - Danmaku Shown - Danmaku affiché - - - Danmaku Hidden - Danmaku caché - - - - - PlayerDanmakuIdentifyDialog - - Local File - Fichier local - - - DandanPlay - Continuer à jouer - - - Unknown Source - Source inconnue - - - %1 comments - %1 commentaires - - - Comment count unavailable - Nombre de commentaires indisponible - - - Unknown Danmaku - Danmaku inconnu - - - Source: %1 - Source: %1 - - - Server: %1 - Serveur: %1 - - - Danmaku Server: %1 - Danmaku Serveur: %1 - - - Provider: %1 - Fournisseur: %1 - - - Title: %1 - Titre: %1 - - - Series: %1 - Séries: %1 - - - Comments: %1 - Commentaires: %1 - - - Unknown - Inconnu - - - Score: %1 - Score: %1 - - - Episode: S%1E%2 - Épisode: S%1E%2 - - - Match Reason: %1 - Motif de la correspondance: %1 - - - Target: %1 - Cible: %1 - - - Search Danmaku - Rechercher Danmaku - - - this video - cette vidéo - - - Search danmaku sources for "%1" and choose the best match. - Recherchez les sources de danmaku contenant "%1" et sélectionnez celle qui correspond le mieux. - - - Enter title or keyword - Entrez un titre ou un mot-clé - - - Search - Rechercher - - - Search for local or online danmaku sources. - Recherchez des sources de danmaku locales ou en ligne. - - - Search local files and enabled danmaku servers. - Rechercher dans les fichiers locaux et sur les serveurs danmaku activés. - - - Danmaku Details - Détails sur le Danmaku - - - Select a danmaku source to view details. - Sélectionnez une source de danmaku pour afficher les détails. - - - Cancel - Annuler - - - Load Danmaku - Charger Danmaku - - - Searching... - Recherche en cours... - - - Searching local files and enabled danmaku servers... - Recherche des fichiers locaux et des serveurs danmaku activés... - - - No matches found - Aucune correspondance trouvée - - - Found %1 matches - %1 résultats trouvés - - - Found %1 matches from %2 danmaku servers - %1 résultat trouvé parmi les %2 serveurs danmaku - - - Search failed - Échec de la recherche - - - Failed to search danmaku: %1 - Échec de la recherche de danmaku: %1 - - - - - PlayerDanmakuSettingsDialog - - Danmaku Settings - Paramètres du danmaku - - - Adjust danmaku rendering without leaving playback. Changes are saved and applied immediately. - Réglez l'affichage du danmaku sans quitter la lecture. Les modifications sont enregistrées et appliquées immédiatement. - - - ASS Subtitle Track - Piste de sous-titres ASS - - - Native Smooth Renderer - Rendu fluide natif - - - Danmaku Renderer - Rendu de danmaku - - - Switch between the ASS subtitle track pipeline and qEmby's adaptive native danmaku renderer. - Basculer entre le pipeline de pistes de sous-titres ASS et le moteur de rendu adaptatif natif de qEmby pour les danmaku. - - - Enable Danmaku - Activer le Danmaku - - - Display streaming danmaku in the embedded player - Afficher le danmaku en streaming dans le lecteur intégré - - - Dual Subtitle Mode - Mode double sous-titrage - - - Keep regular subtitles visible alongside danmaku when possible - Dans la mesure du possible, affichez les sous-titres de manière permanente à côté du danmaku - - - Danmaku Opacity - Opacité du danmaku - - - Control the transparency of rendered danmaku - Contrôler la transparence du danmaku affiché - - - Danmaku Size - Taille du danmaku - - - Danmaku Font Weight - Épaisseur de la police Danmaku - - - Danmaku Outline - Contour du Danmaku - - - Danmaku Shadow - Ombre de Danmaku - - - Display Area - Zone d'affichage - - - Limit how much of the screen height danmaku can occupy - Limiter la hauteur de l'écran que le danmaku peut occuper - - - Danmaku Density - Densité du danmaku - - - Reduce or increase the number of simultaneous lanes - Réduire ou augmenter le nombre de voies simultanées - - - Danmaku Speed - Vitesse du danmaku - - - Adjust how quickly scrolling danmaku travels across the screen - Réglez la vitesse à laquelle le danmaku défile à l'écran - - - Danmaku Offset - Décalage du danmaku - - - Shift danmaku timing earlier or later to align with playback - Avancez ou reculez le temps du danmaku pour l'aligner sur la lecture - - - Hide Scrolling Danmaku - Masquer le danmaku défilant - - - Hide standard right-to-left scrolling comments - Masquer les commentaires standard à défilement de droite à gauche - - - Hide Top Danmaku - Masquer le danmaku du haut - - - Hide fixed danmaku positioned at the top of the screen - Masquer les danmaku fixes situés en haut de l'écran - - - Hide Bottom Danmaku - Masquer le danmaku du bas - - - Hide fixed danmaku positioned at the bottom of the screen - Masquer les danmaku fixes situés en bas de l'écran - - - Blocked Keywords - Mots-clés bloqués - - - 40% - 40% - - - 55% - 55% - - - 72% - 72% - - - 85% - 85% - - - 100% - 100% - - - 80% - 80% - - - 120% - 120% - - - 140% - 140% - - - Low - Faible - - - Medium - Moyen - - - High - Élevé - - - Slow - Lent - - - Normal - Normal - - - Fast - Rapide - - - -1000 ms - -1000 ms - - - -500 ms - -500 ms - - - 0 ms - 0 ms - - - +500 ms - +500 ms - - - +1000 ms - +1000 ms - - - Close - Fermer - - - - - DandanplayProvider - - DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings. - L'API ouverte DandanPlay nécessite désormais un identifiant d'application et une clé secrète. Configurez-les dans les paramètres de Danmaku Server. - - - - - DanmakuServerDialog - - Danmaku Servers - Serveurs Danmaku - - - Manage multiple danmaku servers here. Click a server card to edit it and choose the active server used for matching and loading danmaku. - Gérez ici plusieurs serveurs Danmaku. Cliquez sur la fiche d'un serveur pour la modifier et sélectionner le serveur actif utilisé pour la mise en correspondance et le chargement de Danmaku. - - - Current Server - Serveur actuel - - - Servers - Serveurs - - - Each item shows the danmaku server name, address, app credentials, and description. The built-in official DandanPlay server supports anime only. - Chaque élément affiche le nom du serveur danmaku, son adresse, les identifiants de l'application et une description. Le serveur officiel DandanPlay intégré ne prend en charge que les anime. - - - Add - Ajouter - - - Remove - Supprimer - - - Name - Nom - - - e.g., Official DandanPlay - ex, DandanPlay officiel - - - Server URL - URL du serveur - - - Edit Selected Server - Modifier le serveur sélectionné - - - 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. - Mettez à jour le nom, l'adresse et la description du serveur, ainsi que les identifiants de l'application pour le serveur danmaku sélectionné. Les points de terminaison officiels de DandanPlay nécessitent un ID d'application et une clé secrète, et ne prennent en charge que les anime. - - - Description - Description - - - e.g., Supports anime only - ex, ne prend en charge que les anime - - - App ID - App ID - - - App Secret - App Secret - - - Required for official DandanPlay - Obligatoire pour l'application officielle DandanPlay - - - Cancel - Annuler - - - Save - Enregistrer - - - Danmaku Server %1 - Serveur Danmaku %1 - - - Remove Danmaku Server - Supprimer le serveur Danmaku - - - Remove "%1" from the danmaku server list? - Supprimer "%1" de la liste des serveurs danmaku ? - - - At least one danmaku server is required. - Il faut au moins un serveur Danmaku. - - - At least one enabled danmaku server is required. - Au moins un serveur Danmaku activé est requis. - - - OK - OK - - - Every danmaku server must have a name. - Tout serveur de danmaku doit avoir un nom. - - - Every danmaku server must have a server URL. - Tout serveur Danmaku doit disposer d'une URL de serveur. - - - Server URL must be a valid http or https address. - L'URL du serveur doit être une adresse http ou https valide. - - - - - DanmakuServerListItemWidget - - Remove Server - Supprimer le serveur - - - At least one server is required. - Au moins un serveur est nécessaire. - - - App ID: %1 - App ID:%1 - - - Anime only - Uniquement les Anime - - - App Secret: %1 - App Secret:%1 - - - - - PagePlayer - - Danmaku - Danmaku - - - Enable Danmaku - Activer le Danmaku - - - Display streaming danmaku in the embedded player - Afficher le danmaku en streaming dans le lecteur intégré - - - Danmaku Renderer - Rendu de danmaku - - - Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer - Choisissez si le danmaku doit être affiché via la piste de sous-titres ASS ou via le moteur de rendu natif adaptatif de qEmby - - - ASS Subtitle Track - Piste de sous-titres ASS - - - Native Smooth Renderer - Rendu fluide natif - - - Auto Load Danmaku - Chargement automatique du Danmaku - - - Automatically search and load danmaku when playback starts - Rechercher et charger automatiquement le danmaku au démarrage de la lecture - - - Auto Match Danmaku - Danmaku à correspondance automatique - - - Automatically choose the best danmaku match when no cached manual result exists - Choisir automatiquement le mieux adapté parmi les danmaku lorsqu'aucun résultat manuel n'est enregistré en cache - - - DandanPlay Open API - API ouverture DandanPlay - - - Danmaku Server - Serveur Danmaku - - - Manage - Gérer - - - Prefer Online - Préférez le service en ligne - - - Prefer Local - Préférez le service local - - - Online Only - Uniquement en ligne - - - Local Only - Uniquement local - - - Danmaku Source - Danmaku Source - - - Choose whether local danmaku files or online danmaku should be preferred during matching - Choisissez si vous préférez utiliser les fichiers danmaku locaux ou les danmaku en ligne lors de la mise en correspondance - - - Local Danmaku Folder - Dossier local Danmaku - - - Place .ass, .json, or .xml danmaku files here. Matching uses the current media title and manual keyword, and the selected danmaku source mode decides whether local files or online results are preferred first. -Folder: %1 - Placez ici vos fichiers danmaku au format .ass, .json ou .xml. La correspondance s'effectue en fonction du titre du média actuel et du mot-clé défini manuellement ; le mode de source danmaku sélectionné détermine si les fichiers locaux ou les résultats en ligne sont prioritaires. -Dossier: %1 - - - Open Folder - Ouvrir le dossier - - - Current server: %1 -Address: %2 - Serveur actuel: %1 -Adresse: %2 - - - Current server: %1 - Serveur actuel: %1 - - - Address: %1 - Adresse: %1 - - - Supported content: Anime only - Contenu pris en charge : anime uniquement - - - Description: %1 - Description: %1 - - - App ID and App Secret are required for official DandanPlay Open API access - L'identifiant d'application et la clé secrète de l'application sont obligatoires pour accéder à l'API ouverte officielle de DandanPlay - - - Danmaku Provider - Fournisseur de Danmaku - - - Select which danmaku service to query for matches and comments - Sélectionnez le service Danmaku sur lequel rechercher des correspondances et des commentaires - - - Load Related Comments - Charger les commentaires associés - - - Include related third-party comment sources when the provider supports it - Inclure les sources de commentaires tierces pertinentes lorsque le fournisseur le permet - - - Dual Subtitle Mode - Mode double sous-titrage - - - Keep regular subtitles visible alongside danmaku when possible - Dans la mesure du possible, affichez les sous-titres de manière permanente à côté du danmaku - - - Danmaku Opacity - Opacité du danmaku - - - Control the transparency of rendered danmaku - Contrôler la transparence du danmaku affiché - - - Danmaku Size - Taille du danmaku - - - Danmaku Font Weight - Épaisseur de la police Danmaku - - - Adjust the font weight used for danmaku text - Régler l'épaisseur de la police utilisée pour le texte du danmaku - - - Danmaku Outline - Contour du danmaku - - - Control the outline thickness applied to danmaku text - Contrôler l'épaisseur du contour appliqué au texte du danmaku - - - Danmaku Shadow - L'Ombre du Danmaku - - - Control the shadow offset applied to danmaku text - Contrôler le décalage de l'ombre appliqué au texte du danmaku - - - Scale the font size used for danmaku rendering - Ajuster la taille de la police utilisée pour l'affichage du danmaku - - - Display Area - Zone d'affichage - - - Limit how much of the screen height danmaku can occupy - Limiter la hauteur de l'écran que le danmaku peut occuper - - - Danmaku Density - Densité du danmaku - - - Reduce or increase the number of simultaneous lanes - Réduire ou augmenter le nombre de voies simultanées - - - Danmaku Speed - Vitesse du danmaku - - - Adjust how quickly scrolling danmaku travels across the screen - Réglez la vitesse à laquelle le danmaku défile à l'écran - - - Danmaku Offset - Décalage du danmaku - - - Shift danmaku timing earlier or later to align with playback - Avancez ou reculez le temps du danmaku pour l'aligner sur la lecture - - - Hide Scrolling Danmaku - Masquer le danmaku défilant - - - Hide standard right-to-left scrolling comments - Masquer les commentaires standard à défilement de droite à gauche - - - Hide Top Danmaku - Masquer le danmaku du haut - - - Hide fixed danmaku positioned at the top of the screen - Masquer les danmaku fixes situés en haut de l'écran - - - Hide Bottom Danmaku - Masquer le danmaku du bas - - - Hide fixed danmaku positioned at the bottom of the screen - Masquer les danmaku fixes situés en bas de l'écran - - - Blocked Keywords - Mots-clés bloqués - - - Filter out danmaku containing these keywords - Filtrer les danmaku contenant ces mots-clés - - - Provider Base URL - URL de base du fournisseur - - - Set a custom Danmaku API endpoint for official or compatible services - Définir un point de terminaison API Danmaku personnalisé pour les services officiels ou compatibles - - - Danmaku App ID - Danmaku App ID - - - Optional - Facultatif - - - Optional App ID for authenticated DandanPlay Open API requests - Identifiant d'application facultatif pour les requêtes authentifiées de l'API ouverte DandanPlay - - - Optional App ID used by the selected danmaku server - ID d'application facultatif utilisé par le serveur danmaku sélectionné - - - Danmaku App Secret - Danmaku App Secret - - - Optional App Secret used to sign DandanPlay Open API requests - Clé d'application facultative utilisée pour signer les requêtes de l'API ouverte DandanPlay - - - Optional App Secret used by the selected danmaku server - Clé d'application facultative utilisée par le serveur danmaku sélectionné - - - Danmaku Cache Duration - Durée de conservation du cache Danmaku - - - Reuse online match results, cached comment payloads, and generated ASS files for this long - Réutiliser les résultats des correspondances en ligne, les commentaires mis en cache et les fichiers ASS générés pendant tout ce temps - - - Clear Danmaku Cache - Vider le cache Danmaku - - - Remove cached online matches, cached comment payloads, and generated ASS files - Supprimer les correspondances en ligne mises en cache, les données de commentaires mises en cache et les fichiers ASS générés - - - Danmaku cache cleared - Mémoire cache Danmaku effacée - - - Slow - Lent - - - Normal - Normal - - - Fast - Rapide - - - - - PlayerSubtitleSettingsDialog - - Auto - Auto - - - Subtitle Settings - Paramètres des sous-titres - - - Adjust subtitle rendering without leaving playback. Changes are saved and applied immediately. - Modifiez l'affichage des sous-titres sans quitter la lecture. Les modifications sont enregistrées et appliquées immédiatement. - - - Subtitle Font - Police des sous-titres - - - Choose the font family used for subtitle rendering - Choisissez la police utilisée pour l'affichage des sous-titres - - - Subtitle Timing - Temporisation des sous-titres - - - Subtitle Size - Taille des sous-titres - - - Subtitle Position - Position des sous-titres - - - Subtitle Scale - Échelle des sous-titres - - - Outline Size - Taille du contour - - - Shadow Offset - Décalage de l'ombre - - - - - MediaListModel - - Season %1 - Saison %1 - - - %1 min - %1 min - - - %1 Episodes - %1 Épisodes - - - - - PagePlayer - - Right Sidebar - Barre latérale droite - - - Bottom HUD - Interface en bas de l'écran - - - Media Switcher Position - Position du sélecteur de média - - - Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD - Choisissez si les films récents et le changement de saison ou d'épisode s'affichent dans la barre latérale droite ou dans l'interface en bas de l'écran - - - - - PlayerView Seasons & Episodes - Saisons et épisodes + Saisons et épisodes + Switch season and episode - Changer de saison et d'épisode + Changer de saison et d'épisode + Switch media Changer de support + Media Switcher Sélecteur de médias + + Loading... Chargement en cours... + S%1E%2 %3 S%1E%2 %3 %1 - S%2E%3 - %4 - %1 - S%2E%3 - %4 + %1 - S%2E%3 - %4 %1 - %2 - %1 - %2 + %1 - %2 - - PlayerMediaSwitcherPanel + PlayerViewModel - Continue Watching - Continuer à regarder + + Track + - Seasons - Saisons + + Subtitle + + + Loading danmaku… + + + + + Danmaku off + + + + + No danmaku + + + + + Danmaku + Danmaku + + + + %1 · %2 comments + + + + Episodes - Épisodes + Épisodes + + + + Continue Watching + Continuer à regarder + + + + No previous item + + + + + No next item + + + + + Failed to switch media + + + + + ProxySettingsDialog + + + Proxy Settings + + + + + Server Proxy Settings + + + + + Use Global Proxy + + + + + When enabled, this server uses the global proxy settings. Disable to configure a proxy specific to this server. + + + + + These settings apply as the default proxy for all servers, unless a server overrides them. + + + + + Proxy Mode + + + + + Do not use proxy + + + + + Use system proxy + + + + + Custom proxy + + + + + Type + + + + + Host + + + + + e.g. 127.0.0.1 + + + + + Port + Port + + + + 1 - 65535 + + + + + Username + Nom d'utilisateur + + + + + Optional + Facultatif + + + + Password + Mot de passe + + + + Bypass for local addresses + + + + + Test Connection + + + + + Probe URL used to verify the proxy connection. Leave empty to use the default. + + + + + Cancel + Annuler + + + + Save + Enregistrer + + + + Loaded global proxy settings below. You can review or modify them before saving. + + + + + Editing global proxy. Fill in the form below and save to apply it globally. + + + + + Please enter the proxy host. + + + + + Please enter a valid port (1 - 65535). + + + + + Invalid Proxy + + + + + + ✗ %1 + + + + + Invalid test URL. + + + + + Testing... + + + + + ✓ Success (HTTP %1, %2 ms) + + + + + ✗ Failed (%1) + + + + + QObject + + Books + Livres + + + + + %1 min + %1 min + + + + %1 h %2 min + + + + + Season %1 + Saison %1 + + + + S%1 E%2 + + + + + Episode %1 + + + + + + Size: %1 + Taille: %1 + + + + Router + + + Navigation is not available. + + + + + Navigation error + + + + + The page could not be opened. + + + + + Page migration in progress + + + + + This page is not available in the QML shell yet. + + + + + Page not found + + + + + Unknown route: %1 + + + + + The route is empty or invalid. + + + + + Page failed to load + + + + + The page component could not be created. + + + + + Back + Retour + + + + Home + Accueil + + + + SearchHistoryPopup + + Search History + Historique des recherches + + + Recent + Récent + + + Count + Compte + + + + Show More + Afficher plus + + + + Show Less + Afficher moins + + + + Sort by recent searches + Trier par recherches récentes + + + + Sort by most searched + Trier par les plus recherchés + + + + Clear search history + Effacer l'historique de recherche + + + + Confirm clear all history + Confirmer la suppression de l'historique + + + + SearchPage + + + All + Tout + + + + Movies + Films + + + + Shows + Séries + + + + Collections + Collections + + + + People + Personnes + + + + Poster view + + + + + Wide view + + + + + Searching… + + + + + Search failed + + + + + No matching media + + + + + SearchView + + Search Results + Résultats de la recherche + + + All + Tout + + + Movies + Films + + + Shows + Séries + + + Collections + Collections + + + People + Personnes + + + Toggle View Mode + Changer de mode d'affichage + + + 0 Results + 0 Résultats + + + Search: "%1" + Rechercher: "%1" + + + Searching... + Recherche... + + + %1 Results + %1 Résultats + + + + SearchViewModel + + + 0 Results + 0 Résultats + + + + Search Results + Résultats de la recherche + + + + Search: "%1" + Rechercher: "%1" + + + + Searching... + + + + + %1 Results + %1 Résultats + + + + Unable to load search results + + + + + Error Loading Results + + + + + SeasonPage + + + Episode Number + Numéro de l'épisode + + + + Title + Titre + + + + Premiere Date + Date de diffusion + + + + Runtime + Durée + + + + More… + + + + + ♥ Favorite + + + + + ♡ Favorite + + + + + Remove season from favorites + + + + + Add season to favorites + + + + + Refresh + Actualiser + + + + Episodes + Épisodes + + + + Descending + + + + + Ascending + + + + + No episodes + + + + + This season does not have any available episodes. + + + + + SeasonView + + Episode Number + Numéro de l'épisode + + + Title + Titre + + + Premiere Date + Date de diffusion + + + Runtime + Durée Loading... - Chargement en cours... + Chargement en cours... - No active media found. - Aucun média actif n'a été trouvé. + Unknown Series + Série inconnue - No episodes found. - Aucun épisode trouvé. + Error Loading Episodes + Erreur lors du chargement des épisodes - S%1E%2 %3 - S%1E%2 %3 + ▶ Play + ▶ Jouer - Episode %1 %2 - Épisode %1 %2 + ▶ Continue E%1 + ▶ Continuer E%1 - %1 - S%2E%3 - %4 - %1 - S%2E%3 - %4 + 0 Episodes + 0 Épisodes - %1 - %2 - %1 - %2 - - - %1 - %2 - %3 - %1 - %2 - %3 - - - Season %1 - Saison %1 + %1 Episodes + %1 Épisodes + + SeasonViewModel + + + 0 Episodes + 0 Épisodes + + + + + + Play + Jouer + + + + + Loading... + Chargement en cours... + + + + + Unknown Series + Série inconnue + + + + + %1 Episodes + %1 Épisodes + + + + + Error Loading Episodes + Erreur lors du chargement des épisodes + + + + + Unable to load this season's episodes. + + + + + Continue E%1 + + + + + Continue + Continuer + + + + ServerBrowserDialog + + + Browse Server Directory + Parcourir le répertoire du serveur + + + + Cancel + Annuler + + + + Select + Sélectionner + + + + Failed to load drives + Échec du chargement des disques + + + + Access denied or error + Accès refusé ou erreur + + + + SettingsAboutPage + + + About + À propos + + + + qEmby + + + + + Version %1 + Version %1 + + + + © 2026 AlanHJ. All rights reserved. + + + + + GitHub Repository + GitHub Dépôt + + + + Report an Issue + + + + + This project is open-source software built with Qt 6 and modern C++. +Special thanks to the Emby and Jellyfin communities for their fantastic APIs. + + + + + Acknowledgements + Remerciements + + + + People who help qEmby reach more users. + Les personnes qui aident qEmby à toucher plus d'utilisateurs. + + + + French translation + Traduction française + + + + Open Source Licenses + + + + + SettingsAppearancePage + + + Appearance + Apparence + + + + Theme Mode + Mode thème + + + + Switch application theme (takes effect immediately) + Changer le thème de l'application (la modification prend effet immédiatement) + + + + System Default + Paramètres par défaut du système + + + + Light Mode + Mode clair + + + + Dark Mode + Mode sombre + + + + Font Size + Taille de la police + + + + Adjust the overall font size of the interface + Régler la taille globale de la police de l'interface + + + + Small + Petit + + + + Medium + Moyen + + + + Large + Grand + + + + Sidebar Position + Position de la barre latérale + + + + Place the navigation sidebar on the left or right side + Placez la barre latérale à gauche ou à droite + + + + Left + Gauche + + + + Right + Droite + + + + Pin Sidebar + Épingler la barre latérale + + + + Keep the sidebar always visible and integrated into the main layout + Faire en sorte que la barre latérale reste toujours visible et intégrée à la mise en page principale + + + + Custom Sidebar + + + + + Customize which items appear in the navigation sidebar + + + + + Custom Sidebar Options + + + + + Hide Search Bar + + + + + Hide Home Item + + + + + Hide Favorites Item + + + + + Startup Window State + État de la fenêtre au démarrage + + + + Choose window state when the application launches + Choisissez l'état de la fenêtre au lancement de l'application + + + + Normal Window + Fenêtre standard + + + + Maximized + Agrandi + + + + Reduce Animations + Réduire les animations + + + + Reduce page transitions and hover effects for better performance + Réduisez les transitions entre les pages et les effets au survol pour améliorer les performances + + + + Snapshot Navigation + Navigation par aperçus + + + + Use static screenshots during page transitions for smoother animation on complex views + Utilisez des captures d'écran statiques lors des transitions entre les pages pour obtenir des animations plus fluides sur les vues complexes + + + + Enable Search History + Activer l'historique de recherche + + + + Save recent searches locally for quick reuse + Enregistrer les recherches récentes localement pour pouvoir les réutiliser rapidement + + + + Search History Options + + + + + Enable Search Autocomplete + Activer la saisie semi-automatique + + + + SettingsGeneralPage + + + General + Général + + + + Language + Langue + + + + App interface language (requires restart) + Langue interface de l'application (nécessite un redémarrage) + + + + System Default + Paramètres par défaut du système + + + + 简体中文 + + + + + English + Anglais + + + + French + Français + + + + Remember Last Server + Mémoriser le dernier serveur + + + + Automatically connect to the last used server on startup + Se connecter automatiquement au dernier serveur utilisé au démarrage + + + + Close to Tray + Fermer dans la barre d'état + + + + Minimize to system tray instead of exiting the application + Réduire dans la barre d'état système au lieu de quitter l'application + + + + Network Proxy + + + + + Proxy for the current server + + + + + Configure proxy settings for Emby/Jellyfin connections + + + + + Configure... + + + + + Enable Logging + Activer la journalisation + + + + Record debug information to a log file for troubleshooting + Enregistrer les informations de débogage dans un fichier journal à des fins de dépannage + + + + SettingsLibraryPage + + + Library + Bibliothèque + + + + Show Continue Watching + Afficher Continuer à regarder + + + + Display unfinished media on the home screen + Afficher les fichiers multimédias inachevés sur l'écran d'accueil + + + + Show Latest Added + Afficher les derniers ajouts + + + + Display recently added media on the home screen + Afficher les fichiers médias récemment ajoutés sur l'écran d'accueil + + + + Show Recommended + Afficher les recommandations + + + + Display recommended media on the home screen + Afficher les médias recommandés sur l'écran d'accueil + + + + Show Media Libraries + Afficher les bibliothèques Médias + + + + Display media libraries on the home screen + Afficher les bibliothèques Médias sur l'écran d'accueil + + + + Show Each Library + Afficher chaque bibliothèque + + + + Display each library section individually on the home screen + Afficher chaque section de la bibliothèque séparément sur l'écran d'accueil + + + + Show Completed Watching + + + + + Display completed media on the home screen + + + + + Continue Watching Request Limit + + + + + + + + Full list request count. 0 means unlimited; home sections use the same value up to 50. + + + + + + + + 0 = Unlimited + + + + + Latest Media Request Limit + + + + + Recommended Request Limit + + + + + Completed Watching Request Limit + + + + + Adaptive Images + Images adaptatives + + + + When a specific image type is missing, automatically use another available type as fallback + Lorsqu'un type d'image spécifique est manquant, utiliser automatiquement un autre type disponible à la place + + + + Show Media Tooltips + Afficher les infobulles des médias + + + + Show media details in tooltips when hovering over media cards + Afficher les détails des médias dans des infobulles lorsque l'on survole les fiches médias + + + + Show Favorite Folders + Afficher les dossiers favoris + + + + Display favorite folders in the favorites page + Afficher les dossiers favoris sur la page des favoris + + + + Default Library View + Affichage par défaut de la bibliothèque + + + + Choose how media items are presented by default + Choisissez la manière dont les éléments multimédias s'affichent par défaut + + + + Poster Wall + Mur d'affiches + + + + List View + Affichage sous forme de liste + + + + Image Quality + Qualité d'image + + + + Set image download quality (higher uses more bandwidth) + Définir la qualité de téléchargement des images (une qualité supérieure consomme plus de bande passante) + + + + Low + Faible + + + + Medium + Moyen + + + + High + Élevé + + + + Original + Original + + + + Shimmer Loading Animation + Animation de chargement scintillante + + + + Show shimmer skeleton animation while loading dashboard content + Afficher une animation de squelette scintillant pendant le chargement du contenu du tableau de bord + + + + + Custom Home Section Order + Ordre des sections de l'accueil + + + + Override the default order of the main home screen sections + Modifier l'ordre par défaut des sections de l'écran d'accueil + + + + Drag-and-drop section reordering will be available in a future update. + + + + + Cache Management + Gestion du cache + + + + Recommendation Cache Duration + Durée de la mémoire cache des recommandations + + + + Set how long to cache recommended content before refreshing + Définir la durée de mise en cache du contenu recommandé avant actualisation + + + + 6 Hours + 6 Heures + + + + 12 Hours + 12 Heures + + + + + 1 Day + 1 Jour + + + + + 3 Days + 3 Jours + + + + + 7 Days + 7 Jours + + + + Clear Recommendation Cache + Vider le cache des recommandations + + + + Clear cached recommendations and refresh on next visit + Effacer les recommandations mises en cache et actualiser la page lors de la prochaine visite + + + + + Cache + Cache + + + + + Clear + Effacer + + + + Image Cache Duration + Durée de conservation du cache d'images + + + + How long to keep downloaded images in local cache + Combien de temps faut-il conserver les images téléchargées dans le cache local + + + + 14 Days + 14 Jours + + + + 30 Days + 30 Jours + + + + Clear Image Cache + Vider le cache des images + + + + Remove all cached images from disk + Supprimer toutes les images mises en cache du disque + + + + SettingsPage + + + Settings + Paramètres + + + + General + Général + + + + Appearance + Apparence + + + + Library + Bibliothèque + + + + Player + Lecteur + + + + About + À propos + + + + SettingsPlayerPage + + + + Player + Lecteur + + + + Hardware Decoding + Décodage matériel + + + + Select GPU acceleration method for video decoding + Sélectionner la méthode d'accélération GPU pour le décodage vidéo + + + + + Auto + Auto + + + + VAAPI + VAAPI + + + + VDPAU + VDPAU + + + + CUDA (NVIDIA) + CUDA (NVIDIA) + + + + None (Software) + Aucun (Logiciel) + + + + Video Output Driver + Pilote de sortie vidéo + + + + Rendering backend for embedded player (Render API mode) + Rendu pour le lecteur intégré (mode API de rendu) + + + + libmpv (Embedded) + libmpv (Intégré) + + + + Video Sync Mode + Mode de synchronisation vidéo + + + + Method for synchronizing video frames to display + Procédé de synchronisation d'images vidéo en vue de leur affichage + + + + Audio + Audio + + + + Display Resample (Default) + Rééchantillonnage de l'affichage (par défaut) + + + + Display VSync + Afficher VSync + + + + Tone Mapping + Mappage des tons + + + + HDR to SDR tone mapping algorithm + + + + + Mobius + Mobius + + + + BT.2390 + BT.2390 + + + + Hable + Hable + + + + Reinhard + Reinhard + + + + Preferred Audio Language + Langue audio préférée + + + + + Rules are matched in order; choose presets or type custom keywords and press Enter + Les règles sont traitées dans l'ordre ; sélectionnez des préréglages ou saisissez des mots-clés personnalisés, puis appuyez sur Entrée + + + + e.g., chi,eng,jpn + + + + + Preferred Subtitle Language + Langue préférée pour les sous-titres + + + + e.g., chi,eng + + + + + Preferred Video Version + Version vidéo recommandée + + + + Choose keyword or sort rules in order; type custom keywords and press Enter + Sélectionnez les mots-clés ou les critères de tri dans l'ordre ; saisissez des mots-clés personnalisés et appuyez sur Entrée + + + + e.g., original,2160p,1080p + + + + + Continuous Playback + + + + + Automatically play the next episode or media when playback ends + + + + + Long Press Left/Right + Appui long à gauche/à droite + + + + Enable long press actions for left/right keys during playback + Activer les actions par appui long sur les touches gauche/droite pendant la lecture + + + + Long Press Options + + + + + Seek Step + Rechercher une étape + + + + Number of seconds to skip when pressing forward or rewind + Nombre de secondes à sauter lorsque l'on appuie sur les boutons d'avance rapide ou de retour rapide + + + + 3 Seconds + 3 Secondes + + + + 5 Seconds + 5 Secondes + + + + 10 Seconds + 10 Secondes + + + + 15 Seconds + 15 Secondes + + + + 30 Seconds + 30 Secondes + + + + Long Press Trigger Time + Durée d'enclenchement par pression longue + + + + Set how long left/right keys must be held before the long press action starts + Définissez la durée pendant laquelle les touches gauche et droite doivent être maintenues enfoncées avant que l'action de pression longue ne se déclenche + + + + 300 ms + 300 ms + + + + 500 ms + 500 ms + + + + 800 ms + 800 ms + + + + 1000 ms + 1000 ms + + + + Long Press Mode + Mode appui long + + + + Choose whether holding left/right keys performs time jump seek or temporary speed playback + Choisissez si le fait de maintenir les touches gauche/droite enfoncées déclenche un saut temporel ou une lecture à vitesse variable + + + + Time Jump Playback + Lecture avec saut temporel + + + + Speed Playback + Lecture accélérée + + + + Mouse Edge Long Press + Appui long sur le bouton latéral de la souris + + + + Hold the left or right side of the playback area for speed rewind or fast forward + Appuyez longuement sur le côté gauche ou droit de la zone de lecture pour rembobiner ou avancer rapidement + + + + Media Switcher Position + Position du sélecteur de média + + + + Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD + Choisissez si les films récents et le changement de saison ou d'épisode s'affichent dans la barre latérale droite ou dans l'interface en bas de l'écran + + + + Right Sidebar + Barre latérale droite + + + + Bottom HUD + Interface en bas de l'écran + + + + Click to Pause + Cliquez pour mettre en pause + + + + Single click on the video area to toggle play/pause + Cliquez une fois sur la zone de la vidéo pour activer ou désactiver la lecture/pause + + + + Auto Skip Intro + Sauter automatiquement l'intro + + + + Automatically skip the intro sequence when playback starts + Passer automatiquement la séquence d'introduction au début de la lecture + + + + Auto Skip Outro + Sauter automatiquement l'outro + + + + Automatically skip the ending credits during playback + Passer automatiquement le générique de fin pendant la lecture + + + + Independent Player Window + Fenêtre de lecteur indépendant + + + + Open video in a separate detached window + Ouvrir la vidéo dans une fenêtre séparée + + + + Volume Normalization + Normalisation du volume + + + + Automatically adjust volume to prevent sudden loud noises + Régler automatiquement le volume pour éviter les bruits soudains et forts + + + + Use Custom mpv.conf + Utiliser un fichier mpv.conf personnalisé + + + + Load settings from an external mpv.conf configuration file + Charger les paramètres à partir d'un fichier de configuration mpv.conf externe + + + + mpv.conf + + + + + Config path: ~/<config>/mpv/mpv.conf + + + + + Open File + Ouvrir un fichier + + + + Danmaku + Danmaku + + + + Enable Danmaku + Activer le Danmaku + + + + Display streaming danmaku in the embedded player + Afficher le danmaku en streaming dans le lecteur intégré + + + + Danmaku Renderer + Rendu de danmaku + + + + Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer + Choisissez si le danmaku doit être affiché via la piste de sous-titres ASS ou via le moteur de rendu natif adaptatif de qEmby + + + + ASS Subtitle Track + Piste de sous-titres ASS + + + + Native Smooth Renderer + Rendu fluide natif + + + + Danmaku Source + Danmaku Source + + + + Choose whether local danmaku files or online danmaku should be preferred during matching + Choisissez si vous préférez utiliser les fichiers danmaku locaux ou les danmaku en ligne lors de la mise en correspondance + + + + Prefer Local + Préférez le service local + + + + Prefer Online + Préférez le service en ligne + + + + Online Only + Uniquement en ligne + + + + Local Only + Uniquement local + + + + Auto Load Danmaku + Chargement automatique du Danmaku + + + + Automatically search and load danmaku when playback starts + Rechercher et charger automatiquement le danmaku au démarrage de la lecture + + + + Auto Match Danmaku + Danmaku à correspondance automatique + + + + Automatically choose the best danmaku match when no cached manual result exists + Choisir automatiquement le mieux adapté parmi les danmaku lorsqu'aucun résultat manuel n'est enregistré en cache + + + + Load Related Comments + Charger les commentaires associés + + + + Include related third-party comment sources when the provider supports it + Inclure les sources de commentaires tierces pertinentes lorsque le fournisseur le permet + + + + Dual Subtitle Mode + Mode double sous-titrage + + + + Keep regular subtitles visible alongside danmaku when possible + Dans la mesure du possible, affichez les sous-titres de manière permanente à côté du danmaku + + + + Danmaku Opacity + Opacité du danmaku + + + + Control the transparency of rendered danmaku + Contrôler la transparence du danmaku affiché + + + + Danmaku Size + Taille du danmaku + + + + Scale the font size used for danmaku rendering + Ajuster la taille de la police utilisée pour l'affichage du danmaku + + + + Display Area + Zone d'affichage + + + + Limit how much of the screen height danmaku can occupy + Limiter la hauteur de l'écran que le danmaku peut occuper + + + + Danmaku Density + Densité du danmaku + + + + Reduce or increase the number of simultaneous lanes + Réduire ou augmenter le nombre de voies simultanées + + + + Danmaku Speed + Vitesse du danmaku + + + + Adjust how quickly scrolling danmaku travels across the screen + Réglez la vitesse à laquelle le danmaku défile à l'écran + + + + Danmaku Offset + Décalage du danmaku + + + + Shift danmaku timing earlier or later to align with playback + Avancez ou reculez le temps du danmaku pour l'aligner sur la lecture + + + + Hide Scrolling Danmaku + Masquer le danmaku défilant + + + + Hide standard right-to-left scrolling comments + Masquer les commentaires standard à défilement de droite à gauche + + + + Hide Top Danmaku + Masquer le danmaku du haut + + + + Hide fixed danmaku positioned at the top of the screen + Masquer les danmaku fixes situés en haut de l'écran + + + + Hide Bottom Danmaku + Masquer le danmaku du bas + + + + Hide fixed danmaku positioned at the bottom of the screen + Masquer les danmaku fixes situés en bas de l'écran + + + + Blocked Keywords + Mots-clés bloqués + + + + Filter out danmaku containing these keywords + Filtrer les danmaku contenant ces mots-clés + + + + Enter keywords separated by commas + + + + + Danmaku Cache Duration + Durée de conservation du cache Danmaku + + + + Reuse online match results, cached comment payloads, and generated ASS files for this long + Réutiliser les résultats des correspondances en ligne, les commentaires mis en cache et les fichiers ASS générés pendant tout ce temps + + + + 6 Hours + 6 Heures + + + + 12 Hours + 12 Heures + + + + 1 Day + 1 Jour + + + + 3 Days + 3 Jours + + + + 7 Days + 7 Jours + + + + External Player + Lecteur externe + + + + Enable External Player + Activer le lecteur externe + + + + Supported players on this platform: %1 + Lecteurs pris en charge sur cette plateforme: %1 + + + + Choose which external player to launch + + + + + MPV + + + + + VLC + + + + + MPC-Qt + + + + + Arguments + Arguments + + + + Additional command-line arguments passed to the external player + + + + + e.g., --fullscreen --volume=100 + ex, --fullscreen --volume=100 + + + + Quick Play with External Player + Lecture rapide avec un lecteur externe + + + + Use external player for quick play actions on cards and menus + Utilisez un lecteur externe pour les actions de lecture rapide sur les cartes et dans les menus + + + + Direct Stream + Diffusion en direct + + + + Bypass server proxy and stream directly from source. May not work on external networks + Contourner le serveur proxy et diffuser directement depuis la source. Cela peut ne pas fonctionner sur les réseaux externes + + + + SettingsView + + + Settings + Paramètres + + + + General + Général + + + + Appearance + Apparence + + + + Player + Lecteur + + + + Library + Bibliothèque + + + + About + À propos + + + + SideBar + + + Search... + + + + + Home + Accueil + + + + Favorites + Favoris + + + + MEDIA + MEDIA + + + + Settings + Paramètres + + + + Manage + Gérer + + + + Logout + Déconnexion + + + + SidebarViewModel + + + My Server + Mon serveur + + + + SplitPlayerButton + + + Play with %1 + Jouer avec %1 + + + + TaskEditDialog + + + Overview + Aperçu + + + + + Cancel + Annuler + + + + Task Summary + Résumé des tâches + + + + State + État + + + + Category + Catégorie + + + + Last Result + Dernier résultat + + + + Last Finished + Dernier terminé + + + + Schedule + Calendrier + + + + Visibility + Visibilité + + + + Scheduling Notes + Remarques concernant la planification + + + + Trigger Rules + Règles de déclenchement + + + + Loading task details... + Chargement des détails de la tâche... + + + + Configured Triggers + Déclencheurs configurés + + + + Triggers + Déclencheurs + + + + + + Add Trigger + Ajouter un déclencheur + + + + Trigger Type + Type de déclencheur + + + + Daily + Quotidien + + + + Interval + Intervalle + + + + Weekly + Hebdomadaire + + + + On Startup + Au démarrage + + + + + Time + Temps + + + + Every + Chaque + + + + 15 minutes + 15 minutes + + + + 30 minutes + 30 minutes + + + + 45 minutes + 45 minutes + + + + 1 hour + 1 heure + + + + 2 hours + 2 heures + + + + 3 hours + 3 heures + + + + 4 hours + 4 heures + + + + 6 hours + 6 heures + + + + 8 hours + 8 heures + + + + 12 hours + 12 heures + + + + 24 hours + 24 heures + + + + Day of Week + Jour de la semaine + + + + Sunday + Dimanche + + + + Monday + Lundi + + + + Tuesday + Mardi + + + + Wednesday + Mercredi + + + + Thursday + Jeudi + + + + Friday + Vendredi + + + + Saturday + Samedi + + + + Task will run when the server starts. + La tâche s'exécutera au démarrage du serveur. + + + + Time Limit (hours) + Durée maximale (en heures) + + + + Optional + Facultatif + + + Add + Ajouter + + + + Save + Enregistrer + + + + Remove + Supprimer + + + + Max runtime %1 + Durée maximale %1 + + + + Read-only + En lecture seule + + + + This is a built-in system task reported by the server. Some properties may be managed by the server. + Il s'agit d'une tâche système intégrée signalée par le serveur. Certaines propriétés peuvent être gérées par le serveur. + + + + Built-in trigger types are shown as read-only and will be preserved when you save. + Les types de déclencheurs intégrés sont affichés en lecture seule et seront conservés lors de l'enregistrement. + + + + Changes in this dialog only update trigger rules. Start or stop a task from the tasks page. + Les modifications apportées dans cette boîte de dialogue ne concernent que les règles de déclenchement. Pour démarrer ou arrêter une tâche, rendez-vous sur la page des tâches. + + + + Built-in server triggers are shown as read-only and will be preserved when you save. + Les déclencheurs de serveur intégrés apparaissent en lecture seule et seront conservés lorsque vous enregistrerez. + + + + Add, remove, and save trigger rules for this task. + Ajouter, supprimer et enregistrer des règles de déclenchement pour cette tâche. + + + + Please enter a valid time limit. + Veuillez saisir une durée valide. + + + + This trigger already exists. + Ce déclencheur existe déjà. + + + + No description provided. + Aucune description fournie. + + + + Other + Autres + + + + System task + Tâche système + + + + Visible task + Tâche visible + + + + Never run + Jamais lancé + + + + Not available + Indisponible + + + + Built-in + Intégré + + + Daily at %1 + Tous les jours à %1 + + + Every %n hour(s) + + Toutes les %n heures + + + + + Every %n minute(s) + + Toutes les %n minutes + + + + + %1 at %2 + %1 à %2 + + + On application startup + Au démarrage de l'application + + + Delete trigger + Supprimer le déclencheur + + + + No triggers configured + Aucun déclencheur n'est configuré + + + Last: %1 + Dernier: %1 + + + + Failed to load task: %1 + Échec du chargement de la tâche: %1 + + + + Triggers saved + Déclencheurs enregistrés + + + + Failed to save triggers: %1 + Échec de l'enregistrement des déclencheurs: %1 + + + + TaskUtils + + Sunday + Dimanche + + + Monday + Lundi + + + Tuesday + Mardi + + + Wednesday + Mercredi + + + Thursday + Jeudi + + + Friday + Vendredi + + + Saturday + Samedi + + + display configuration changes + afficher les modifications de configuration + + + the server wakes from sleep + le serveur sort du mode veille + + + plugins are updated + les plugins sont mis à jour + + + No limit + Pas de limite + + + %n day(s) + + %n jour(s) + + + + + %n hour(s) + + %n heure(s) + + + + + %n minute(s) + + %n minute(s) + + + + + Less than a minute + Moins d'une minute + + + Idle + Au repos + + + Running + Lancement + + + Cancelling + Annulation + + + Completed + Terminé + + + Failed + Échec + + + Aborted + Interrompu + + + Cancelled + Annulé + + + Daily + Quotidien + + + Interval + Intervalle + + + Weekly + Hebdomadaire + + + On Startup + Au démarrage + + + System Event + Événement système + + + Daily at %1 + Tous les jours à %1 + + + Every %1 + Chaque %1 + + + %1 at %2 + %1 à %2 + + + On server startup + Au démarrage du serveur + + + When %1 + Lorsque %1 + + + Manual only + Mode manuel uniquement + + + +%n more + + +%n de plus + + + + + + TasksViewModel + + + Running Now + En cours + + + + System Tasks + Tâches système + + + + Scheduled Tasks + Tâches planifiées + + + + Manual Tasks + Tâches manuelles + + + + Tasks that are currently executing. Progress refreshes automatically. + Tâches en cours d'exécution. La progression est mise à jour automatiquement. + + + + Hidden or built-in tasks reported by the server. + Tâches masquées ou intégrées signalées par le serveur. + + + + Tasks with one or more configured triggers. + Tâches comportant un ou plusieurs déclencheurs configurés. + + + + Tasks that only run when started manually. + Tâches qui ne s'exécutent que lorsqu'elles sont lancées manuellement. + + + + Other + Autres + + + + System + Système + + + + Scheduled + Planifié + + + + Manual + Manuel + + + + State: %1 + État: %1 + + + + Last result: %1 + Dernier résultat: %1 + + + + Failed to load tasks: %1 + Échec du chargement des tâches: %1 + + + + Task "%1" stopped + La tâche "%1" s'est arrêtée + + + + Task "%1" started + La tâche "%1" a démarré + + + + Failed to stop task: %1 + Échec de l'arrêt de la tâche: %1 + + + + Failed to run task: %1 + Échec de l'exécution de la tâche: %1 + + + + No tasks + Aucune tâche + + + + %n task(s) + + %n tâche(s) + + + + + + %n running + + %n en cours + + + + + + %n system + + %n système + + + + + + TextInputDialog + + + Cancel + Annuler + + + + OK + OK + + + + ThemedDialog + + + Confirm + + + + + Cancel + Annuler + + + + Destructive action + + + + + TitleBar + + + Search... + + + + + ToastHost + + + Success + + + + + Warning + + + + + Error + + + + + Information + + + + + %1: %2 + + + + + Dismiss notification + + + + + TranscodingViewModel + + + Saving… + + + + + Loading… + + + + + Unavailable + + + + + Not loaded + + + + + Jellyfin encoding configuration + + + + + Emby encoding configuration + + + + + Failed to load transcoding settings: %1 + Échec du chargement des paramètres de transcodage:%1 + + + + Transcoding settings saved + Paramètres de transcodage enregistrés + + + + Failed to save transcoding settings: %1 + Échec de l'enregistrement des paramètres de transcodage:%1 + + + + TrayManager + + + Show qEmby + + + + + Quit + + + + + UserDashboardViewModel + + + Continue Watching + Continuer à regarder + + + + Latest Media + Derniers Medias + + + + Recommended + Recommandé + + + + Completed Watching + + + + + Unable to load the home page + + + + + Unable to load this library + + + + + UserEditDialog + + + User Settings + Paramètres utilisateur + + + + + User Settings — %1 + Paramètres utilisateur — %1 + + + + Name + Nom + + + + This is how the user will appear on your server. + C'est ainsi que l'utilisateur apparaîtra sur votre serveur. + + + + Enter user name + Entrez votre nom d'utilisateur + + + + Administrator + Administrateur + + + + Allow remote access + Autoriser l'accès à distance + + + + Playback + Lecture + + + + Allow media playback + Autoriser la lecture de fichiers multimédias + + + + Allow audio transcoding + Autoriser le transcodage audio + + + + Allow video transcoding + Autoriser le transcodage vidéo + + + Features + Caractéristiques + + + + Live TV access + Accès à la télévision en direct + + + + Live TV recording management + Gestion de l'enregistrement des programmes télévisés en direct + + + Allow media deletion + Autoriser la suppression des fichiers multimédias + + + + Remote control of other users + Contrôle à distance d'autres utilisateurs + + + + Shared device remote control + Contrôle à distance pour appareils partagés + + + + Allow media downloading + Autoriser le téléchargement de fichiers multimédias + + + + Visibility + Visibilité + + + + Hide from login screen + Masquer l'écran de connexion + + + + Hide from login screen (remote) + Masquer depuis l'écran de connexion (à distance) + + + + Hide from unused devices + Masquer les appareils inutilisés + + + + + Save + Enregistrer + + + + Profile + Profil + + + + Allow access to all libraries + Autoriser l'accès à toutes les bibliothèques + + + + + Access + Accès + + + + Maximum parental rating + Classification parentale maximale + + + + + None + Aucun + + + + Parental Control + Contrôle parental + + + + New password + Nouveau mot de passe + + + + Enter new password + Entrez un nouveau mot de passe + + + + Confirm password + Confirmez le mot de passe + + + + Re-enter new password + Entrez à nouveau le nouveau mot de passe + + + + Save Password + Enregistrer le mot de passe + + + + Password + Mot de passe + + + + Failed to load user data: %1 + Échec du chargement des données utilisateur: %1 + + + + Settings saved + Paramètres enregistrés + + + + Failed to save: %1 + Échec de l'enregistrement: %1 + + + + User name cannot be empty + Le nom d'utilisateur ne peut pas être vide + + + + Passwords do not match + Les mots de passe ne correspondent pas + + + + Password updated + Mot de passe mis à jour + + + + Failed to update password: %1 + Échec de la mise à jour du mot de passe: %1 + + + + Cancel + Annuler + + + + Emby Connect email + Email Emby Connect + + + + Optional connect email + Adresse e-mail facultative pour la connexion + + + + Link this user to an Emby Connect account by email. + Associez cet utilisateur à un compte Emby Connect par e-mail. + + + + Disable user + Désactiver l'utilisateur + + + + Allow changing image and password + Autoriser la modification de l'image et du mot de passe + + + + Authentication provider + Fournisseur d'authentification + + + + Allow video remuxing + Autoriser le remuxage vidéo + + + + Simultaneous stream limit + Limite du nombre de flux simultanés + + + + Unlimited + Illimité + + + + Remote client bitrate limit + Limite de débit binaire du client distant + + + + Mbps + Mbps + + + + Feature Access + Accès aux fonctionnalités + + + + Allow media conversion + Autoriser la conversion des fichiers multimédias + + + + Allow link sharing + Autoriser le partage de liens + + + + Downloads + Téléchargements + + + + Allow sync transcoding + Autoriser le transcodage synchronisé + + + + Subtitles + Sous-titres + + + + Allow subtitle downloading + Autoriser le téléchargement des sous-titres + + + + Allow subtitle management + Autoriser la gestion des sous-titres + + + + Media Deletion + Suppression de fichiers multimédias + + + + Allow media deletion from all libraries + Autoriser la suppression de fichiers multimédias dans toutes les bibliothèques + + + + Channel Access + Accès aux chaînes + + + + Allow access to all channels + Autoriser l'accès à toutes les chaînes + + + + Device Access + Accès aux appareils + + + + Allow access on all devices + Autoriser l'accès sur tous les appareils + + + + Block unrated items + Masquer les éléments non classés + + + + Books + Livres + + + + Games + Jeux + + + + Channel content + Contenu de la chaîne + + + + Live TV + TV en direct + + + + Movies + Films + + + + Music + Musique + + + + Trailers + Bandes-annonces + + + + TV shows + Séries télévisées + + + + Tag Restrictions + Restrictions relatives aux balises + + + + Blocked tags + Balises bloquées + + + + Tag restriction mode + Mode de restriction des balises + + + + Block items with these tags + Masquer les éléments portant ces balises + + + + Allow items with these tags + Autoriser les éléments portant ces balises + + + + Restriction match mode + Mode de correspondance des restrictions + + + + Match all restrictions + Respecter toutes les restrictions + + + + Match any restriction + Respecter toutes les restrictions + + + + Access Schedule + Horaires d'accès + + + + Limit when this user can access the server. + Limiter les horaires auxquels cet utilisateur peut accéder au serveur. + + + + Add + Ajouter + + + + Remove + Supprimer + + + + Sunday + Dimanche + + + + Monday + Lundi + + + + Tuesday + Mardi + + + + Wednesday + Mercredi + + + + Thursday + Jeudi + + + + Friday + Vendredi + + + + Saturday + Samedi + + + + Schedule start time must be earlier than end time + L'heure de début doit être antérieure à l'heure de fin + + + + UsersViewModel + + + Failed to load users: %1 + Échec du chargement des utilisateurs: %1 + + + + User "%1" enabled + L'utilisateur "%1" a été activé + + + + User "%1" disabled + L'utilisateur "%1" a été désactivé + + + + Failed to update user: %1 + Échec de la mise à jour de l'utilisateur: %1 + + + + User "%1" deleted + L'utilisateur "%1" a été supprimé + + + + Failed to delete user: %1 + Échec de la suppression de l'utilisateur: %1 + + + + WebdavPassphrasePrompt + + + + Show Password + Afficher le mot de passe + + + + Toggle passphrase visibility + + + + + Cancel + Annuler + + + + OK + OK + + + + Sync passphrase + + + + + Hide Password + + + + + WebdavSyncDialog + + + Cloud Sync (WebDAV) + + + + + Server Configuration + + + + + Server URL: + + + + + WebDAV username + + + + + WebDAV password + + + + + + Show Password + Afficher le mot de passe + + + + Username: + + + + + Password: + + + + + Ignore SSL certificate errors + + + + + Root directory: + + + + + HTTPS: + + + + + + Test Connection + + + + + Save Profile + + + + + Upload Current Configuration + + + + + Custom tag: + + + + + Snapshot tag + + + + + Encrypt save + + + + + Upload Now + + + + + Snapshots on Server + + + + + Refresh + Actualiser + + + + Device + Appareil + + + + Version + Version + + + + OS + + + + + Created + + + + + Size + Taille + + + + File Name + + + + + Apply strategy: + + + + + Replace + + + + + Merge + + + + + Local wins + + + + + Delete Selected + Supprimer la sélection + + + + Apply to Local + + + + + Last sync: %1 + + + + + Last sync: never + + + + + This device will be uploaded as: +%1 + + + + + Hide Password + + + + + + Invalid profile + + + + + Please fill in the server URL, username and password before testing. + + + + + Please fill in the server URL, username and password before saving. + + + + + Internal error + + + + + Profile store is unavailable. + + + + + Profile saved + + + + + Save failed + Échec de l'enregistrement + + + + qEmby could not write to %1. + + + + + Profile not configured + + + + + Please save the WebDAV profile before uploading. + + + + + Encrypt Snapshot + + + + + Enter a passphrase to encrypt this snapshot. +You will need the same passphrase to restore it on another device. + + + + + Upload + + + + + Uploading snapshot... + + + + + Loading snapshot list... + + + + + Replace local config entirely + + + + + Merge (cloud wins on conflict) + + + + + Local wins (only fill missing keys) + + + + + Confirm apply + + + + + Apply snapshot "%1" with strategy: +%2 +This will overwrite parts of your local configuration. Continue? + + + + + Apply + + + + + + Cancel + Annuler + + + + Downloading snapshot... + + + + + Delete snapshot + + + + + Delete "%1" from the WebDAV server? + +This cannot be undone. + + + + + Delete + Supprimer + + + + Deleting snapshot... + + + + + Device: %1 +Version: %2 +OS: %3 +Created: %4 +Size: %5 +File Name: %6 + + + + + Testing connection... + + + + + Connection OK + + + + + Failed + Échec + + diff --git a/src/qEmbyApp/resources/i18n/qEmby_zh_CN.ts b/src/qEmbyApp/resources/i18n/qEmby_zh_CN.ts index 207d034..026fe4d 100644 --- a/src/qEmbyApp/resources/i18n/qEmby_zh_CN.ts +++ b/src/qEmbyApp/resources/i18n/qEmby_zh_CN.ts @@ -1,778 +1,1526 @@ - - MainWindow - - Press Back again to logout - 再次点击返回键退出登录 - - - Refreshing Home... - 正在刷新主页... - - - Refreshing Favorites... - 正在刷新收藏... - - - Search... - 搜索... - - - Search - 搜索 - - - - - LoginView - - Login - 登录 - - - Select Server - 选择服务器 - - - Connect to Server - 连接到服务器 - - - Server Address (e.g. 192.168.1.1 or domain) - 服务器地址 (例如 192.168.1.1 或域名) - - - Server URL (e.g. example.com, example.com/emby or https://example.com) - 服务器 URL(例如 example.com、example.com/emby 或 https://example.com) - - - Server URL - 服务器 URL - - - Server Address - 服务器地址 - - - Server Address (e.g. example.com/emby or 192.168.1.1/jellyfin) - 服务器地址(例如 example.com/emby 或 192.168.1.1/jellyfin) - - - Port (e.g. 8096) - 端口 (例如 8096) - - - Port - 端口 - - - Ignore SSL certificate verification - 忽略 SSL 证书验证 - - - Proxy settings - 代理设置 - - - Server Proxy - 服务器代理 - - - Configure... - 配置... - - - Using global proxy - 使用全局代理 - - - No proxy - 不使用代理 - - - System proxy - 使用系统代理 - - - Custom (incomplete) - 自定义代理(信息不完整) - - - Server Proxy — %1 - 服务器代理 — %1 - - - Use this only for trusted self-signed or private CA HTTPS servers. - 仅对可信任的自签名或私有 CA HTTPS 服务器使用此选项。 - - - Only for trusted self-signed or private CA servers. - 仅用于可信自签名或私有 CA 服务器。 - - - Username - 用户名 - - - Password - 密码 - - - Show Password - 显示密码 - - - Cancel - 取消 - - - Edit - 编辑 - - - Delete - 删除 - - - Add New Server - 添加新服务器 - - - Add New Server - 添加新服务器 - - - Connect to another server - 连接到其他服务器 - - - Save & Login - 保存并登录 - - - Remove Server - 移除服务器 - - - Are you sure you want to remove this server? -This action cannot be undone. - 您确定要移除此服务器吗? -此操作无法撤销。 - - - Remove - 移除 - - - Server address and username cannot be empty. - 服务器地址和用户名不能为空。 - - - Server URL and username cannot be empty. - 服务器 URL 和用户名不能为空。 - - - Please enter a valid server URL. - 请输入有效的服务器 URL。 - - - Please enter a valid server address. - 请输入有效的服务器地址。 - - - Please enter a valid port number. - 请输入有效的端口号。 - - - Server URL must use http:// or https://. - 服务器 URL 必须使用 http:// 或 https://。 - - - Connecting... - 正在连接... - - - Connection Failed - 连接失败 - - - Failed to connect or session expired. Please log in again. -Error: - 连接失败或会话已过期。请重新登录。 -错误: - - - Cloud Sync (WebDAV) - 云同步(WebDAV) - - - - - NetworkManager - - TLS/SSL 证书错误: %1 - TLS/SSL 证书错误: %1 - - - 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 - 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 - - - - - HomeView - - Search... - 搜索... - - - Search - 搜索 - - - Home - 首页 - - - Favorites - 收藏 - - - MEDIA - 媒体库 - - - Settings - 设置 - - - Manage - 管理 - - - Downloads - 下载 - - - Cloud Sync (WebDAV) - 云同步(WebDAV) - - - Logout - 退出 - - - My Server - 我的服务器 - - - Administrator - 管理员 - - - User - 用户 - - - Refreshing Home... - 正在刷新主页... - - - Refreshing Favorites... - 正在刷新收藏... - - - This feature is under development - 该功能正在开发中 - - - Server Management - 服务器管理 - - - - - LibraryView - - All - 全部 - - - Movies - 电影 - - - Shows - 剧集 - - - Music - 音乐 - - - Videos - 视频 - - - Recent - 最近添加 - - - Playlists - 播放列表 - - - Lists - 列表 - - - Collections - 合集 - - - Favorites - 我的最爱 - - - Folders - 文件夹 - - - Title - 标题 - - - Date Added - 添加日期 - - - Date Played - 播放日期 - - - Runtime - 播放时长 - - - Premiere Date - 首映日期 - - - 0 Items - 0 个项目 - - - Loading... - 正在加载... - - - %1 Items - %1 个项目 - - - %1 / %2 Items - %1 / %2 个项目 - - - Toggle View Mode - 切换视图模式 - - - Error Loading Items - 加载项目失败 - - - - - DetailActionWidget - - ▶ Resume - ▶ 继续播放 - - - ▶ Play - ▶ 播放 - - - ↺ Play from Beginning - ↺ 从头开始播放 - - - ▶ Resume %1 - ▶ 继续播放 %1 - - - ↺ Restart %1 - ↺ 重新播放 %1 - - - ▶ Play %1 - ▶ 播放 %1 - - - %1 remaining - 剩余 %1 - - - Version %1 - 版本 %1 - - - Unknown Video - 未知视频 - - - 🚫 No Subtitles - 🚫 无字幕 - - - Disable subtitle display - 禁用字幕显示 - - - Audio Track - 音轨 - - - Subtitle - 字幕 - - - Forced - 强制 - - - External - 外部 - - - SDH - 听障 - - - Standard Subtitle - 标准字幕 - - - %1 hr %2 min - %1 小时 %2 分钟 - - - %1 min - %1 分钟 - - - - - DetailBottomInfoWidget - - Tags - 标签 - - - Studios - 工作室 - - - Media Info - 媒体信息 - - - Added %1 - 添加于 %1 - - - Video - 视频 - - - Audio - 音频 - - - Subtitle - 字幕 - - - Title - 标题 - - - Codec - 编码 - - - Language - 语言 - - - Codec Tag - 编码标签 - - - Profile - 配置项 - - - Level - 级别 - - - Resolution - 分辨率 - - - Aspect Ratio - 宽高比 - - - Interlaced - 隔行扫描 - - - Framerate - 帧率 - - - Bitrate - 码率 - - - Bit Depth - 位深 - - - Pixel Format - 像素格式 - - - Ref Frames - 参考帧 - - - Layout - 布局 - - - Channels - 声道 - - - Sample Rate - 采样率 - - - Default - 默认 - - - Yes - 是 - - - Index - 索引 - - - Forced - 强制 - - - Hearing Impaired - 听力障碍 - - - External - 外部 - - - Text Stream - 纯文本流 - - - Ext. Stream Supported - 支持外部流 - - - External URL - 外部 URL - - - Delivery Method - 传输方式 - - - Protocol - 协议 - - - Ext. Video Type - 外部视频类型 - - - Ext. Video SubType - 外部视频子类型 - - - Size - 大小 - - - Path - 路径 - - - Delivery URL - 传输 URL - - - External Links - 外部链接 - - - - - SplitPlayerButton - - Play with %1 - 使用 %1 播放 - - - - - MediaActionMenu - - Play - 播放 - - - Play with %1 - 使用 %1 播放 - - - View Details - 查看详情 - - - Download - 下载 - + AddToPlaylistDialog + Add to Playlist 添加到播放列表 - Remove from Playlist - 从播放列表中移除 + + Select a playlist + 请选择一个播放列表 - Add to Favorites - 添加到收藏 + + Search playlists... + 搜索播放列表... - Remove from Favorites - 取消收藏 + + + New Playlist + 新建播放列表 - Mark as Played - 标记为已播放 + + Cancel + 取消 - Mark as Unplayed - 标记为未播放 + + + Add + 添加 - Remove from Continue Watching - 从继续观看中移除 + Loading... + 加载中... - Edit Metadata - 编辑元数据 + No playlists found + 未找到任何播放列表 - Edit Images - 编辑图像 + + Failed to load playlists: %1 + 加载播放列表失败:%1 + Create Playlist + 创建播放列表 + + + Playlist name + 播放列表名称 + + + + + Enter playlist name + 请输入播放列表名称 + + + + Create + 创建 + + + + Playlist created + 播放列表已创建 + + + + Failed to create playlist: %1 + 创建播放列表失败:%1 + + + + AddUserDialog + + + Add User + 添加用户 + + + + Name + 名称 + + + + This will be the name displayed for the user on the server. + 这将作为用户在服务器上的显示名称。 + + + + Enter user name + 输入用户名 + + + + Copy from user + 从用户复制 + + + + + Do not copy + 不复制 + + + + Optionally copy settings from an existing user. + 可选地从现有用户复制设置。 + + + + Allow user to manage server + 允许用户管理服务器 + + + + Copy Options + 复制选项 + + + + User policy + 用户策略 + + + + User configuration + 用户配置 + + + + Choose which settings to copy from the selected user. + 选择要从所选用户复制的设置。 + + + + Library Access + 媒体库访问 + + + + Allow access to all libraries + 允许访问所有媒体库 + + + + Choose which libraries this user can access. + 选择该用户可以访问的媒体库。 + + + + Channel Access + 频道访问 + + + + Allow access to all channels + 允许访问所有频道 + + + + Choose which channels this user can access. + 选择该用户可以访问的频道。 + + + + Create + 创建 + + + + Cancel + 取消 + + + + User name cannot be empty + 用户名不能为空 + + + + User "%1" created + 用户“%1”已创建 + + + + User "%1" created, but failed to apply permissions: %2 + 用户“%1”已创建,但应用权限失败:%2 + + + + Failed to create user: %1 + 创建用户失败:%1 + + + + AdminCollectionsPage + + + + Playlists + 播放列表 + + + + %1 playlists · %2 selected + %1 个播放列表 · 已选 %2 个 + + + + %1 playlists + %1 个播放列表 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + + Create playlist + 创建播放列表 + + + + Clear selection + 清除选择 + + + + Select all + 全选 + + + + Delete selected + 删除所选 + + + + Rename + 重命名 + + + + + + Delete + 删除 + + + + Loading playlists + 正在加载播放列表 + + + + Could not load playlists + 无法加载播放列表 + + + + No playlists found + 未找到播放列表 + + + + Create a playlist to organize media for quick access. + 创建播放列表以整理媒体,方便快速访问。 + + + + Saving changes + 正在保存更改 + + + + Create Playlist + 创建播放列表 + + + + Create + 创建 + + + + + Playlist name + 播放列表名称 + + + + Rename Playlist + 重命名播放列表 + + + + Save + 保存 + + + Delete Playlist 删除播放列表 - Identify - 识别 + + + This action cannot be undone + 此操作无法撤销 - Refresh Metadata - 刷新元数据 + + Are you sure you want to delete playlist “%1”? This action cannot be undone. + 确定要删除播放列表“%1”吗?此操作无法撤销。 - Remove Media - 移除媒体 + + Delete Selected + 删除所选 + + + + Delete %1 selected playlists? This action cannot be undone. + 删除已选的 %1 个播放列表?此操作无法撤销。 + + + + AdminLibrariesModel + + + No media folders + 没有媒体文件夹 + + + + AdminLibrariesPage + + + + Libraries + 媒体库 + + + + Refresh + 刷新 + + + + Scan all + 全部扫描 + + + + + Add library + 添加媒体库 + + + + Clear selection + 清除选择 + + + + Select all + 全选 + + + + Delete selected + 删除所选 + + + + %1 libraries · %2 selected + %1 个媒体库 · 已选 %2 个 + + + + %1 libraries + %1 个媒体库 + + + + Scanning %1% + 扫描中 %1% + + + + Cancel + 取消 + + + + Edit + 编辑 + + + + Images + 图片 + + + + Scan + 扫描 + + + + Metadata + 元数据 + + + + Move up + 上移 + + + + Move down + 下移 + + + + + Delete library + 删除媒体库 + + + + Loading libraries + 正在加载媒体库 + + + + Fetching media libraries from the server… + 正在从服务器获取媒体库… + + + + Could not load libraries + 无法加载媒体库 + + + + No media libraries found + 未找到媒体库 + + + + Applying changes + 正在应用更改 + + + + Please wait… + 请稍候… + + + + + Delete + 删除 + + + + Remove “%1” from the server? Media files will not be deleted. + 从服务器移除“%1”?媒体文件不会被删除。 + + + + Delete selected libraries + 删除所选媒体库 + + + + Remove %1 selected libraries from the server? Media files will not be deleted. + 从服务器移除已选的 %1 个媒体库?媒体文件不会被删除。 + + + + AdminLibrariesViewModel + + + Failed to load libraries: %1 + 加载媒体库失败:%1 + + + + Library created successfully + 媒体库创建成功 + + + + Failed to create library: %1 + 创建媒体库失败:%1 + + + + Library updated successfully + 媒体库更新成功 + + + + Failed to update library: %1 + 更新媒体库失败:%1 + + + + Library "%1" deleted + 媒体库“%1”已删除 + + + + Failed to delete library: %1 + 删除媒体库失败:%1 + + + + Deleted %n library(s) + 已删除 %n 个媒体库 + + + Deleted %1 library(s), then failed: %2 + 已删除 %1 个媒体库,随后失败:%2 + + + + Refreshing metadata for "%1"... + 正在刷新“%1”的元数据… + + + + Scanning "%1"... + 正在扫描“%1”… + + + + + Scan failed: %1 + 扫描失败:%1 + + + + Full library scan started + 已开始完整媒体库扫描 + + + + Scan cancelled + 扫描已取消 + + + + Cancel failed: %1 + 取消失败:%1 + + + + Library order updated + 媒体库顺序已更新 + + + + Failed to save order: %1 + 保存顺序失败:%1 + + + + AdminLogsPage + + + Logs + 日志 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + Refresh log files + 刷新日志文件 + + + + Save… + 保存… + + + + Save %1 + 保存 %1 + + + + No log files found + 未找到日志文件 + + + + The server did not report any downloadable logs. + 服务器未提供可下载的日志。 + + + + No log file selected + 未选择日志文件 + + + + Saving… + 保存中… + + + + Save as… + 另存为… + + + + Log contents + 日志内容 + + + + Loading log + 正在加载日志 + + + + Could not load log + 无法加载日志 + + + + Try again + 重试 + + + + Select a log file + 选择日志文件 + + + + Choose a file above to inspect its contents. + 在上方选择文件以查看内容。 + + + + Loading logs + 正在加载日志 + + + + Fetching available server log files… + 正在获取服务器日志文件… + + + + Could not load logs + 无法加载日志 + + + + AdminTasksPage + + + Tasks + 任务 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + Refresh tasks + 刷新任务 + + + + All Tasks + 全部任务 + + + + Scheduled + 已计划 + + + + Running + 运行中 + + + + Manual + 手动 + + + + Running Now + 正在运行 + + + + Tasks that are currently executing. Progress refreshes automatically. + 当前正在执行的任务。进度会自动刷新。 + + + + Scheduled Tasks + 计划任务 + + + + Tasks with one or more configured triggers. + 配置了一个或多个触发器的任务。 + + + + Manual Tasks + 手动任务 + + + + Tasks that only run when started manually. + 仅在手动启动时运行的任务。 + + + + Hidden or built-in tasks reported by the server. + 服务器报告的隐藏或内置任务。 + + + + System + 系统 + + + + Edit + 编辑 + + + + Edit %1 + 编辑 %1 + + + + Working… + 处理中… + + + + Stop + 停止 + + + + Run + 运行 + + + + Stop %1 + 停止 %1 + + + + Run %1 + 运行 %1 + + + + Schedule: %1 + 计划:%1 + + + + %1 · %2% + %1 · %2% + + + + Loading tasks + 正在加载任务 + + + + Fetching scheduled tasks from the server… + 正在从服务器获取计划任务… + + + + Could not load tasks + 无法加载任务 + + + + No tasks found + 未找到任务 + + + + The server did not report any scheduled tasks. + 服务器未报告任何计划任务。 + + + + AdminTranscodingPage + + + + None + 无 + + + + + + Auto + 自动 + + + + Ultra fast + 极快 + + + + Super fast + 超快 + + + + Very fast + 很快 + + + + Faster + 较快 + + + + Fast + 快 + + + + Medium + 中等 + + + + Slow + 慢 + + + + Slower + 较慢 + + + + Very slow + 很慢 + + + + Disabled + 已禁用 + + + + Hardware + 硬件 + + + + Software + 软件 + + + + Hardware + Software + 硬件 + 软件 + + + + Transcoding + 转码 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + Reset + 重置 + + + + Saving… + 保存中… + + + + Save + 保存 + + + + Try again + 重试 + + + + + Hardware acceleration + 硬件加速 + + + + Hardware acceleration type + 硬件加速类型 + + + + VAAPI device + VAAPI 设备 + + + + QSV device + QSV 设备 + + + + Hardware decoding codecs + 硬件解码编解码器 + + + + Prefer system native decoder + 优先使用系统原生解码器 + + + + Enhanced NVDEC + 增强 NVDEC + + + + Enable hardware encoding + 启用硬件编码 + + + + Intel low power H.264 + Intel 低功耗 H.264 + + + + Intel low power HEVC + Intel 低功耗 HEVC + + + + Allow HEVC encoding + 允许 HEVC 编码 + + + + Allow AV1 encoding + 允许 AV1 编码 + + + + Hardware acceleration mode + 硬件加速模式 + + + + Yes + 是 + + + + No + 否 + + + + Advanced + 高级 + + + + Advanced mode exposes the server codec configuration below. + 高级模式会显示下方的服务器编解码器配置。 + + + + Codecs + 编解码器 + + + + Info + 信息 + + + + Settings + 设置 + + + + Paths and threads + 路径与线程 + + + + Encoding threads + 编码线程数 + + + + Extraction threads + 提取线程数 + + + + Transcoding temporary path + 转码临时路径 + + + + FFmpeg path + FFmpeg 路径 + + + + Fallback font path + 备用字体路径 + + + + Enable fallback font + 启用备用字体 + + + + Maximum muxing queue size + 最大复用队列大小 + + + + Quality and processing + 质量与处理 + + + + Encoder preset + 编码器预设 + + + + H.264 CRF + H.264 CRF + + + + H.265 CRF + H.265 CRF + + + + Audio downmix boost + 音频缩混增益 + + + + Enable audio VBR + 启用音频 VBR + + + + Deinterlace method + 去隔行方法 + + + + Double-rate deinterlacing + 双倍帧率去隔行 + + + + Enable subtitle extraction + 启用字幕提取 + + + + Tone mapping + 色调映射 + + + + Tone mapping mode + 色调映射模式 + + + + Enable tone mapping + 启用色调映射 + + + + Enable VPP tone mapping + 启用 VPP 色调映射 + + + + Enable VideoToolbox tone mapping + 启用 VideoToolbox 色调映射 + + + + Algorithm + 算法 + + + + Mode + 模式 + + + + Range + 范围 + + + + Desaturation + 去饱和 + + + + Peak + 峰值 + + + + Parameter + 参数 + + + + VPP brightness + VPP 亮度 + + + + VPP contrast + VPP 对比度 + + + + Throttling and segments + 节流与分段 + + + + Enable throttling + 启用节流 + + + + Throttle delay (seconds) + 节流延迟(秒) + + + + Throttle buffer size + 节流缓冲区大小 + + + + Throttle hysteresis + 节流迟滞 + + + + Delete old segments + 删除旧分段 + + + + Segment retention (seconds) + 分段保留时间(秒) + + + + Loading transcoding settings + 正在加载转码设置 + + + + Transcoding settings unavailable + 转码设置不可用 + + + + No transcoding configuration is available for this server. + 此服务器没有可用的转码配置。 + + + + Discard changes? + 放弃更改? + + + + Discard + 放弃 + + + + Cancel + 取消 + + + + Restore all fields to the last values loaded from the server? + 将所有字段恢复为上次从服务器加载的值? + + + + Browse… + 浏览… + + + + AdminUsersPage + + + Users + 用户 + + + + 1 user + 1 个用户 + + + + %1 users + %1 个用户 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + Refresh users + 刷新用户 + + + + Add user + 添加用户 + + + + Try again + 重试 + + + + Edit user %1 + 编辑用户 %1 + + + + Admin + 管理员 + + + + Disabled + 已禁用 + + + + You + 你 + + + + Last login + 上次登录 + + + + Last active + 上次活动 + + + + Password + 密码 + + + + Set + 已设置 + + + + Not set + 未设置 + + + + You cannot disable or delete yourself + 不能禁用或删除自己 + + + + Disable %1 + 禁用 %1 + + + + Enable %1 + 启用 %1 + + + + Working… + 处理中… + + + + + Delete + 删除 + + + + Delete user %1 + 删除用户 %1 + + + + No users found + 未找到用户 + + + + Add a user to grant access to this server. + 添加用户以授予此服务器访问权限。 + + + + Loading users… + 正在加载用户… + + + + Unable to load users + 无法加载用户 + + + + Delete user + 删除用户 + + + + Delete user %1 permanently + 永久删除用户 %1 + + + + Cancel + 取消 + + + + Are you sure you want to delete user "%1"? This action cannot be undone. + 确定要删除用户“%1”吗?此操作无法撤销。 - BaseView + Added to Favorites 已添加到收藏 + Removed from Favorites 已取消收藏 + Failed to update favorite status 更新收藏状态失败 + Marked as Played 已标记为已播放 + Marked as Unplayed 已标记为未播放 + Removed from Continue Watching 已从继续观看中移除 + Removed from current playlist 已从当前播放列表中移除 + + + Operation failed 操作失败 + Added to playlist "%1" 已添加到播放列表“%1” + Failed to add to playlist: %1 添加到播放列表失败:%1 + Failed to remove from playlist: %1 从播放列表中移除失败:%1 + + + + + This action requires administrator privileges 此操作需要管理员权限 + Identify is not supported for this item type 此项目类型暂不支持识别 + Duplicate Add 重复添加 + %1 is already in playlist %2. Add it again? @@ -781,31 +1529,37 @@ Add it again? 仍要再次添加吗? - "%1" is already in playlist "%2". + "%1" is already in playlist "%2". Add it again? - “%1”已经在播放列表“%2”中。 + “%1”已经在播放列表“%2”中。 仍要再次添加吗? + + Cancel 取消 + Add Again 再次添加 + current playlist 当前播放列表 + Delete Playlist 删除播放列表 - Are you sure you want to delete playlist "%1"? + + Are you sure you want to delete playlist "%1"? This action cannot be undone. 确定要删除播放列表“%1”吗? @@ -813,35 +1567,54 @@ This action cannot be undone. 此操作不可撤销。 + Delete 删除 - "%1" deleted - "%1"已删除 + + "%1" deleted + "%1"已删除 + Delete failed: %1 删除失败:%1 + + Metadata saved + 元数据已保存 + + + + Failed to update metadata: %1 + 更新元数据失败:%1 + + + + this item 此项目 - Refreshing metadata for "%1"... + + Refreshing metadata for "%1"... 正在刷新“%1”的元数据... + Metadata refresh failed: %1 刷新元数据失败:%1 + Remove Media 移除媒体 - Are you sure you want to remove "%1" from the server? + + Are you sure you want to remove "%1" from the server? This action cannot be undone. 确定要从服务器移除“%1”吗? @@ -849,4592 +1622,3140 @@ This action cannot be undone. 此操作不可撤销。 + Remove 移除 - "%1" removed + + "%1" removed “%1”已移除 + Failed to remove media: %1 移除媒体失败:%1 - - MediaIdentifyDialog + CategoryPage - Unknown Title - 未知标题 + + Date Added + 添加日期 - Provider IDs: %1 - 提供方 ID:%1 + + Date Played + 播放日期 - Album Artist: %1 - 专辑艺术家:%1 + + Title + 标题 - Artists: %1 - 艺术家:%1 + + Runtime + 时长 - Identify - 识别 + + Premiere Date + 首播日期 - this item - 此项目 + + Refreshing… + 刷新中… - Search remote metadata matches for "%1" and apply the correct result. - 为“%1”搜索远程元数据候选,并应用正确的匹配结果。 + + Refresh + 刷新 - Enter title or keyword - 输入标题或关键词 + + Descending + 降序 - IMDb Id (optional) - IMDb Id(可选) + + Ascending + 升序 - MovieDb Id (optional) - MovieDb Id(可选) + + Poster view + 海报视图 - TheTVDB Id (optional) - TheTVDB Id(可选) + + Wide view + 宽幅视图 - Any - 任意 + + + No items in this category + 此分类中没有项目 - Search - 搜索 + + + Try refreshing or choosing another category. + 请尝试刷新或选择其他分类。 - Enter a title or provider ID to search. - 请输入标题或提供方 ID 后开始搜索。 - - - Cancel - 取消 - - - Apply Match - 应用匹配 - - - Preview unavailable - 暂无预览 - - - Loading preview... - 正在加载预览... - - - Searching... - 正在搜索... - - - No matches found - 未找到匹配结果 - - - Found %1 matches - 找到 %1 个匹配结果 - - - Search failed - 搜索失败 - - - Failed to search metadata: %1 - 搜索元数据失败:%1 - - - Applying match... - 正在应用匹配... - - - Metadata updated - 元数据已更新 - - - Apply failed - 应用失败 - - - Failed to apply match: %1 - 应用匹配失败:%1 + + Loading more… + 加载更多… - - MediaImageEditDialog + CategoryView - Primary - 主图 + Title + 标题 - Backdrop - 背景图 + Date Added + 添加日期 - Banner - 横幅图 + Date Played + 播放日期 - Thumb - 缩略图 + Runtime + 播放时长 - Logo - Logo + Premiere Date + 首映日期 - Art - 艺术图 + 0 Items + 0 个项目 - - Disc - 唱片图 - - - Box - 盒封 - - - Box Rear - 盒封背面 - - - Menu - 菜单图 - - - Screenshot - 截图 - - - Chapter - 章节图 - - - Profile - 资料图 - - - No image - 暂无图像 - - - %1 image(s) available - %1 张图片可用 - - - %1 %2 - %1 %2 - - - %1 image(s) - %1 张图片 - - - %1 × %2 - %1 × %2 - - - Index %1 - 索引 %1 - - - Image Files (%1);;All Files (*) - 图片文件 (%1);;所有文件 (*) - - - Edit Images - 编辑图像 - - - this item - 此项目 - - - Manage image types for "%1". Changes are applied immediately. - 管理“%1”的各类图像,修改会立即生效。 - - - Loading image information... - 正在加载图像信息... - - - Image Types - 图像类型 - - - Drop an image file or image URL here to replace it. - 将图片文件或图片地址拖到这里即可替换。 - - - Choose Image - 选择图像 - - - Image URL - 图片地址 - - - Download - 下载 - - - Close - 关闭 - - - Remove Image - 移除图像 - - - Upload Image - 上传图像 - - - Preview unavailable - 暂无预览 - - - Loaded images for %1 type(s) - 已加载 %1 种图像类型 - - - No images found yet - 暂未找到图像 - - - Failed to load images - 加载图像失败 - - - Failed to load images: %1 - 加载图像失败:%1 - - - No image for this type - 该类型暂无图像 - - - Loading preview... - 正在加载预览... - - - Select Image - 选择图像 - - - Enter Image URL - 输入图片地址 - - - Paste an image address for the %1 image of "%2". - 请为“%2”的 %1 图像粘贴一个图片地址。 - - - https://example.com/image.jpg - https://example.com/image.jpg - - - Save Image - 保存图像 - - - Downloading image... - 正在下载图像... - - - Download failed - 下载失败 - - - Failed to download image: %1 - 下载图像失败:%1 - - - Unknown error - 未知错误 - - - Save failed - 保存失败 - - - Failed to save image: %1 - 保存图像失败:%1 - - - Image downloaded - 图像已下载 - - - Invalid image URL - 图片地址无效 - - - Dropped data does not contain a supported image - 拖入的内容不包含受支持的图像 - - - Failed to read image file - 读取图像文件失败 - - - Uploading image... - 正在上传图像... - - - Image updated - 图像已更新 - - - Upload failed - 上传失败 - - - Failed to upload image: %1 - 上传图像失败:%1 - - - Are you sure you want to remove the %1 image from "%2"? - 确定要从“%2”中移除 %1 图像吗? - - - Remove - 移除 - - - Cancel - 取消 - - - Removing image... - 正在移除图像... - - - Image removed - 图像已移除 - - - Remove failed - 移除失败 - - - Failed to remove image: %1 - 移除图像失败:%1 - - - No image uploaded for this type. - 该类型尚未上传图像。 - - - No image uploaded for this type. -Choose a file, paste an image URL, or drag one onto the preview. - 该类型尚未上传图像。 -请选择文件、粘贴图片地址,或直接拖到预览图上。 - - - Replace Image - 替换图像 - - - - - MediaService - - Invalid image URL - 图片地址无效 - - - Downloaded data is not a valid image - 下载的数据不是有效的图像 - - - - - DetailView Loading... - 加载中... + 正在加载... - Error Loading Item - 加载项目时出错 + %1 Items + %1 个项目 - Click to copy number - 点击复制番号 + Toggle View Mode + 切换视图模式 - Number copied: %1 - 番号已复制:%1 + Error Loading Items + 加载项目失败 - Seasons - 季 - - - Cast & Crew - 演职人员 - - - Additional Parts - 附加内容 - - - Collection - 合集归属 - - - More Like This - 相似推荐 - - - Episodes - 剧集 - - - Ep # - 集数 - - - 1-%1 - 1-%1 - - - Jump to episode number - 输入集号后回车跳转 - - - Jump to episode number (1-%1) - 输入集号后回车跳转(1-%1) - - - Episode %1 not found - 未找到第 %1 集 - - - ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>More</a> - ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>更多</a> - - - %1 hr %2 min - %1 小时 %2 分钟 - - - %1 min - %1 分钟 - - - Actor - 演员 - - - Director - 导演 - - - Writer - 编剧 - - - Producer - 制片人 - - - Composer - 作曲 - - - Conductor - 指挥 - - - GuestStar - 特邀嘉宾 - - - Editor - 剪辑 - - - Lyricist - 作词 + Refresh Recommendations + 刷新推荐 - - DashboardView + CategoryViewModel + + 0 Items + 0 项 + + + + Loading... + 加载中… + + + + + Unable to load items + 无法加载项目 + + + + + Error Loading Items + 加载项目出错 + + + + %1 Items + %1 项 + + + + CollectionCard + + + Rename + 重命名 + + + + Delete + 删除 + + + + Playlist + 播放列表 + + + + Collection + 合集 + + + + item + 个项目 + + + + items + 个项目 + + + + CollectionsListModel + + + %n item(s) + %n 项 + + + CollectionsViewModel + + + Failed to load playlists: %1 + 加载播放列表失败:%1 + + + + Playlist created + 播放列表已创建 + + + + Failed to create playlist: %1 + 创建播放列表失败:%1 + + + + Renamed successfully + 重命名成功 + + + + Rename failed: %1 + 重命名失败:%1 + + + + Deleted %n playlist(s) + 已删除 %n 个播放列表 + + + Deleted %1 playlist(s), then failed: %2 + 已删除 %1 个播放列表,随后失败:%2 + + + + Playlist order updated + 播放列表顺序已更新 + + + + Failed to save playlist order: %1 + 保存播放列表顺序失败:%1 + + + + ContextMenuUtils + + + Undo + 撤销 + + + + Redo + 重做 + + + + Cut + 剪切 + + + + Copy + 复制 + + + + Paste + 粘贴 + + + + Delete + 删除 + + + + Select All + 全选 + + + + DandanplayProvider + + + DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings. + 弹弹Play 开放 API 现在要求提供 App ID 和 App Secret。请在弹幕服务器设置中完成配置。 + + + + DanmakuServerDialog + + + + + + + + Danmaku Servers + 弹幕服务器 + + + + Manage multiple danmaku servers here. Click a server card to edit it and choose the active server used for matching and loading danmaku. + 在这里管理多个弹幕服务器。点击某个服务器卡片即可编辑它,并选择实际用于匹配和加载弹幕的当前服务器。 + + + Current Server + 当前服务器 + + + + Servers + 服务器列表 + + + + Each item shows the danmaku server name, address, app credentials, and description. The built-in official DandanPlay server supports anime only. + 每个列表项会显示弹幕服务器名称、地址、App 凭据和说明。内置的官方弹弹Play 服务器仅支持动画。 + + + + Add + 添加 + + + + Remove + 删除 + + + + Name + 名称 + + + + e.g., Official DandanPlay + 例如:官方弹弹Play + + + + Server URL + 服务器地址 + + + + Edit Selected Server + 编辑当前服务器 + + + + 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. + 修改当前所选弹幕服务器的名称、地址、说明和 App 凭据。官方弹弹Play 接口要求提供 App ID 和 App Secret,且仅支持动画。 + + + + Description + 说明 + + + + e.g., Supports anime only + 例如:仅支持动画 + + + + App ID + App ID + + + + App Secret + App Secret + + + + + Required for official DandanPlay + 官方弹弹Play 必填 + + + + + Cancel + 取消 + + + + Save + 保存 + + + + Danmaku Server %1 + 弹幕服务器 %1 + + + + Remove Danmaku Server + 删除弹幕服务器 + + + + Remove "%1" from the danmaku server list? + 要将“%1”从弹幕服务器列表中删除吗? + + + + + At least one danmaku server is required. + 至少需要保留一个弹幕服务器。 + + + At least one enabled danmaku server is required. + 至少需要保留一个已启用的弹幕服务器。 + + + + + + + + OK + 确定 + + + + Every danmaku server must have a name. + 每个弹幕服务器都必须填写名称。 + + + + Every danmaku server must have a server URL. + 每个弹幕服务器都必须填写服务器地址。 + + + + Server URL must be a valid http or https address. + 服务器地址必须是合法的 http 或 https 地址。 + + + + DanmakuServerListItemWidget + + + + Remove Server + 删除服务器 + + + At least one server is required. + 至少需要保留一个服务器。 + + + + App ID: %1 + App ID:%1 + + + + Anime only + 仅支持动画 + + + + App Secret: %1 + App Secret:%1 + + + + DashboardPage + + + Loading your home page… + 正在加载主页… + + + + Unable to load the home page + 无法加载主页 + + + Continue Watching 继续观看 + Latest Media - 最近更新 + 最新媒体 + Recommended - 为你推荐 + 推荐 + Completed Watching - 完成观看 - - - Recommended Movies - 推荐电影 + 已看完 + All Libraries - 媒体库 - - - See All > - 查看全部 > - - - Scanning "%1"... - 正在扫描“%1”... - - - Scan failed: %1 - 扫描失败:%1 - - - Metadata saved - 元数据已保存 - - - Failed to update metadata: %1 - 更新元数据失败:%1 + 全部媒体库 - DashboardSectionOrderWidget + Drag the items below to change the order of home screen sections. Left to right matches top to bottom on the home screen. 拖动下方项目以调整主页各区块的显示顺序。这里从左到右的顺序,对应主页中从上到下的顺序。 + Continue Watching 继续观看 + Latest Media 最近更新 + Recommended 为你推荐 + Completed Watching 完成观看 + All Libraries 媒体库 + Each Library Sections 各媒体库分区 %1. %2 - %1. %2 + %1. %2 - - FavoritesView + DashboardView - Favorite Movies - 收藏的电影 + Continue Watching + 继续观看 - Favorite Shows - 收藏的电视剧 + Latest Media + 最近更新 - Favorite Collections - 收藏的合集 + Recommended + 为你推荐 - Favorite Playlists - 收藏的播放列表 + Completed Watching + 完成观看 - Favorite People - 收藏的演员 + Recommended Movies + 推荐电影 - Favorite Folders - 收藏的文件夹 + All Libraries + 媒体库 See All > - 查看全部 > + 查看全部 > - - - - SearchView - Search Results - 搜索结果 + + + This action requires administrator privileges + 此操作需要管理员权限 - All - 全部 + + this item + 此项目 - Movies - 电影 + + Refreshing metadata for "%1"... + 正在刷新“%1”的元数据… - Shows - 剧集 - - - Collections - 合集 - - - People - 人物 - - - Toggle View Mode - 切换视图模式 - - - 0 Results - 0 个结果 - - - Search: "%1" - 搜索: "%1" - - - Searching... - 正在搜索... - - - %1 Results - %1 个结果 - - - - - SearchHistoryPopup - - Search History - 搜索记录 - - - Recent - 最近 - - - Count - 次数 - - - Show More - 展开更多 - - - Show Less - 收起 - - - Sort by recent searches - 按最近搜索排序 - - - Sort by most searched - 按热度排序 - - - Clear search history - 清空搜索记录 - - - Confirm clear all history - 再次点击清空全部记录 - - - - - CategoryView - - Title - 标题 - - - Date Added - 添加日期 - - - Date Played - 播放日期 - - - Runtime - 播放时长 - - - Premiere Date - 首映日期 - - - 0 Items - 0 个项目 - - - Loading... - 正在加载... - - - %1 Items - %1 个项目 - - - Toggle View Mode - 切换视图模式 - - - Error Loading Items - 加载项目失败 - - - Refresh Recommendations - 刷新推荐 - - - - - PlayerView - - Continue Watching - 继续观看 - - - Episodes - 剧集 - - - No episodes found. - 未找到剧集。 - - - No active media found. - 未找到继续观看的媒体。 - - - Network Speed - 实时网速 - - - Network Speed Enabled - 网速显示已开启 - - - Network Speed Disabled - 网速显示已隐藏 - - - Statistics - 统计信息 - - - File - 文件 - - - Session - 会话 - - - Item ID - 媒体 ID - - - Media Source ID - 媒体源 ID - - - Play Session ID - 播放会话 ID - - - Cache - 缓存 - - - Playback - 播放 - - - Refresh Rate - 刷新率 - - - Dropped Frames - 丢帧 - - - Output - 输出 - - - Decoder - 解码器 - - - Color - 色彩 - - - Channels - 声道 - - - Sample Rate - 采样率 - - - Device - 设备 - - - Subtitle - 字幕 - - - Speed - 速度 - - - A-V - 音视频 - - - Software (CPU) - 软解 (CPU) - - - %1-bit - %1 位 - - - AO %1 - AO %1 - - - %1 ch - %1 声道 - - - Chapter - 章节 - - - Size - 大小 - - - Video - 视频 - - - Audio - 音频 - - - Frame Rate - 帧率 - - - Resolution - 分辨率 - - - Format - 格式 - - - Bitrate - 码率 - - - STRM Direct Play - STRM直连 - - - STRM Direct Play: ON (Reloading...) - STRM 直连播放: 开启 (重新加载中...) - - - STRM Direct Play: OFF (Reloading...) - STRM 直连播放: 关闭 (重新加载中...) - - - Speed: %1X - 播放速度: %1X - - - Disable Audio - 关闭音频 - - - Track %1 - 音轨 %1 - - - Audio: %1 - 音轨: %1 - - - No Audio Tracks - 无可用音轨 - - - Audio Disabled - 音频已关闭 - - - Disable Subtitles - 关闭字幕 - - - Subtitle %1 - 字幕 %1 - - - Subtitle: %1 - 字幕: %1 - - - Subtitles Disabled - 字幕已关闭 - - - Load Local Subtitle File - 加载本地字幕文件 - - - Subtitle Loaded: %1 - 已加载字幕:%1 - - - Player is not ready - 播放器尚未就绪 - - - Subtitle Files (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;SubRip (*.srt);;Advanced SubStation Alpha (*.ass *.ssa);;WebVTT (*.vtt);;MicroDVD / SubViewer (*.sub);;VobSub (*.idx);;SAMI (*.smi);;PGS / HDMV (*.sup);;All Files (*) - 字幕文件 (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;SubRip (*.srt);;高级 SubStation Alpha (*.ass *.ssa);;WebVTT (*.vtt);;MicroDVD / SubViewer (*.sub);;VobSub (*.idx);;SAMI (*.smi);;PGS / HDMV (*.sup);;所有文件 (*) - - - External Sub %1 - 外挂字幕 %1 - - - Forward %1s - 前进 %1 秒 - - - Rewind %1s - 后退 %1 秒 - - - Scale: Fit - 画幅: 适应屏幕 - - - Scale: Crop - 画幅: 铺满裁剪 - - - Scale: Stretch - 画幅: 强制拉伸 - - - Scale: Original - 画幅: 原始比例 - - - Muted - 已静音 - - - Volume: %1% - 音量: %1% - - - %1% - %1% - - - Back - 返回 - - - Minimize - 最小化 - - - Maximize - 最大化 - - - Close - 关闭 - - - Rewind - 快退 - - - Play/Pause - 播放/暂停 - - - Forward - 快进 - - - Mute/Unmute - 静音/取消静音 - - - Playback Speed - 播放速度 - - - Audio Track - 音轨 - - - Subtitles - 字幕 - - - Settings - 设置 - - - Aspect Ratio - 画幅比例 - - - Fullscreen - 全屏 - - - - - SettingsView - - Settings - 设置 - - - General - 通用 - - - Appearance - 外观 - - - Player - 播放器 - - - Library - 媒体库 - - - About - 关于 - - - - - PageGeneral - - General - 通用 - - - System - 跟随系统 - - - English - 英文 - - - French - 法语 - - - Language - 语言 - - - App interface language (requires restart) - 应用界面语言(需要重启生效) - - - Remember Last Server - 记住上次服务器 - - - Automatically connect to the last used server on startup - 启动时自动连接到上次使用的服务器 - - - Close to Tray - 关闭到托盘 - - - Minimize to system tray instead of exiting the application - 最小化到系统托盘而非退出应用 - - - Enable Logging - 启用日志 - - - Record debug information to a log file for troubleshooting - 将调试信息记录到日志文件,便于排查问题 - - - Log path: %1 - 日志路径: %1 - - - Size: %1 - 大小: %1 - - - Open Log - 打开日志 - - - Clear Log - 清理日志 - - - Are you sure you want to clear all log files? - -This will remove the current log and rotated backups. This action cannot be undone. - 确定要清理所有日志文件吗? - -这会删除当前日志和所有轮转备份。此操作无法撤销。 - - - Clear - 清理 - - - Cancel - 取消 - - - Log files cleared - 日志文件已清理 - - - Open Location - 打开所在位置 - - - Configure... - 配置... - - - Network Proxy - 网络代理 - - - Proxy for the current server (global or server-specific) - 当前服务器代理(全局或服务器专用) - - - no proxy - 不使用代理 - - - system proxy - 使用系统代理 - - - custom (incomplete) - 自定义代理(信息不完整) - - - %1 %2:%3 - %1 %2:%3 - - - Proxy for the current server — using global: %1 - 当前服务器代理 — 使用全局:%1 - - - Proxy for the current server — currently: %1 - 当前服务器代理 — 当前:%1 - - - Default proxy for all servers — currently: %1 - 所有服务器的默认代理 — 当前:%1 - - - Default proxy for all servers (per-server settings override this) - 所有服务器的默认代理(服务器级设置可覆盖) - - - Default proxy for all servers (per-server settings override this) — currently: no proxy - 所有服务器的默认代理(服务器级设置可覆盖) — 当前:不使用代理 - - - Default proxy for all servers (per-server settings override this) — currently: system proxy - 所有服务器的默认代理(服务器级设置可覆盖) — 当前:使用系统代理 - - - Default proxy for all servers (per-server settings override this) — currently: custom (incomplete) - 所有服务器的默认代理(服务器级设置可覆盖) — 当前:自定义代理(信息不完整) - - - Default proxy for all servers (per-server settings override this) — currently: %1 %2:%3 - 所有服务器的默认代理(服务器级设置可覆盖) — 当前:%1 %2:%3 - - - - - ProxySettingsDialog - - Proxy Settings - 代理设置 - - - Server Proxy Settings - 服务器代理设置 - - - Use Global Proxy - 使用全局代理 - - - When enabled, this server uses the global proxy settings. Disable to configure a proxy specific to this server. - 开启后,此服务器将使用全局代理设置。关闭以为该服务器单独配置代理。 - - - These settings apply as the default proxy for all servers, unless a server overrides them. - 这些设置作为所有服务器的默认代理,除非服务器另行覆盖。 - - - Proxy Mode - 代理模式 - - - Do not use proxy - 不使用代理 - - - Use system proxy - 使用系统代理 - - - Custom proxy - 自定义代理 - - - Type - 类型 - - - Host - 主机 - - - e.g. 127.0.0.1 - 例如:127.0.0.1 - - - Port - 端口 - - - 1 - 65535 - 1 - 65535 - - - Username - 用户名 - - - Optional - 可选 - - - Password - 密码 - - - Bypass for local addresses - 本地地址直连 - - - Test Connection - 测试连接 - - - Save - 保存 - - - Cancel - 取消 - - - Invalid Proxy - 代理配置无效 - - - Please enter the proxy host. - 请输入代理主机地址。 - - - Please enter a valid port (1 - 65535). - 请输入有效端口(1 - 65535)。 - - - Testing... - 测试中... - - - ✓ Success (HTTP %1, %2 ms) - ✓ 成功(HTTP %1,%2 ms) - - - ✗ Failed (%1) - ✗ 失败(%1) - - - ✗ %1 - ✗ %1 - - - Probe URL used to verify the proxy connection. Leave empty to use the default. - 用于验证代理连接的探测地址。留空将使用内置默认地址。 - - - Invalid test URL. - 测试地址无效。 - - - Editing global proxy. Fill in the form below and save to apply it globally. - 正在编辑全局代理。在下方填写并保存即可应用为全局代理。 - - - Loaded global proxy settings below. You can review or modify them before saving. - 已将全局代理设置加载到下方,可在保存前进行查看或修改。 - - - - - PageAppearance - - Appearance - 外观 - - - System Default - 跟随系统 - - - Light Mode - 浅色模式 - - - Dark Mode - 深色模式 - - - Theme Mode - 主题模式 - - - Switch application theme (takes effect immediately) - 切换应用主题(立即生效) - - - UI Animations - 界面动画 - - - Enable smooth page transitions and hover effects - 启用流畅的页面过渡和悬浮效果 - - - - - PagePlayer - - Select GPU acceleration API for video rendering - 选择视频渲染的 GPU 加速接口 - - - GPU Next (Recommended) - GPU Next (推荐) - - - GPU (Legacy) - GPU (旧版) - - - Rendering backend used by the player engine - 播放引擎使用的渲染后端 - - - - - PageLibrary - - Library - 媒体库 - - - Library Display - 媒体库显示 - - - Show Continue Watching - 显示继续观看 - - - Display unfinished media on the home screen - 在主页显示未看完的媒体内容 - - - Show Latest Added - 显示最近添加 - - - Display recently added media on the home screen - 在主页显示最近添加的媒体内容 - - - Show Recommended - 显示推荐 - - - Display recommended media on the home screen - 在主页显示推荐的媒体内容 - - - Show Media Libraries - 显示媒体库 - - - Display media libraries on the home screen - 在主页显示媒体库入口 - - - Show Each Library - 显示每个媒体库 - - - Display each library section individually on the home screen - 在主页为每个媒体库独立显示内容区域 - - - Show Completed Watching - 显示完成观看 - - - Display completed media on the home screen - 在主页显示已完成观看的媒体内容 - - - Continue Watching Request Limit - 继续观看请求数量 - - - Latest Media Request Limit - 最近更新请求数量 - - - Recommended Request Limit - 为你推荐请求数量 - - - Completed Watching Request Limit - 完成观看请求数量 - - - Full list request count. 0 means unlimited; home sections use the same value up to 50. - 全部列表请求数量。0 表示不限制;主页区域使用同一数值但最多 50 个。 - - - Unlimited - 不限制 - - - Adaptive Images - 自适应图片 - - - When a specific image type is missing, automatically use another available type as fallback - 当特定类型的图片缺失时,自动使用其他可用类型的图片替代显示 - - - Show Media Tooltips - 显示媒体提示 - - - Show media details in tooltips when hovering over media cards - 悬停媒体卡片时,在提示框中显示媒体详细信息 - - - Show Favorite Folders - 显示收藏的文件夹 - - - Display favorite folders in the favorites page - 在收藏页面显示收藏的文件夹 - - - Poster Wall - 海报墙 - - - List View - 列表视图 - - - Default Library View - 默认媒体库视图 - - - Choose how media items are presented by default - 选择媒体项目的默认展示方式 - - - Low - 低 - - - Medium - 中 - - - High - 高 - - - Original - 原始 - - - Image Quality - 图片质量 - - - Set image download quality (higher uses more bandwidth) - 设置图片下载质量(越高占用带宽越多) - - - Shimmer Loading Animation - 骨架屏加载动画 - - - Show shimmer skeleton animation while loading dashboard content - 在主页内容加载期间显示闪光骨架屏占位动画 - - - Custom Home Section Order - 自定义主页顺序 - - - Override the default order of the main home screen sections - 覆盖主页主要区块的默认显示顺序 - - - Cache Management - 缓存管理 - - - 6 Hours - 6 小时 - - - 12 Hours - 12 小时 - - - 1 Day - 1 天 - - - 3 Days - 3 天 - - - 7 Days - 7 天 - - - 14 Days - 14 天 - - - 30 Days - 30 天 - - - Recommendation Cache Duration - 推荐缓存时间 - - - Set how long to cache recommended content before refreshing - 设置推荐内容的缓存有效期,过期后自动刷新 - - - Clear - 清理 - - - Clear Recommendation Cache - 清理推荐缓存 - - - Clear cached recommendations and refresh on next visit - 清除已缓存的推荐内容,下次访问时重新获取 - - - Image Cache Duration - 图片缓存时间 - - - How long to keep downloaded images in local cache - 已下载图片在本地缓存中的保留时间 - - - Clear Image Cache - 清理图片缓存 - - - Remove all cached images from disk - 移除磁盘上所有缓存的图片 - - - Recommendation cache cleared - 推荐缓存已清理 - - - Image cache cleared - 图片缓存已清理 - - - - - PageAbout - - About - 关于 - - - Version %1 - 版本 %1 - - - GitHub Repository - GitHub 仓库 - - - Acknowledgements - 致谢 - - - People who help qEmby reach more users. - 帮助 qEmby 触达更多用户的贡献者。 - - - French translation - 法语翻译 - - - - - SeasonView - - Episode Number - 集数 - - - Title - 标题 - - - Premiere Date - 首映日期 - - - Runtime - 时长 - - - Loading... - 正在加载... - - - Unknown Series - 未知剧集 - - - Error Loading Episodes - 加载剧集失败 - - - ▶ Play - ▶ 播放 - - - ▶ Continue E%1 - ▶ 继续播放 第%1集 - - - 0 Episodes - 0 集 - - - %1 Episodes - %1 集 - - - - - MediaCardDelegate - - No Image - 无图片 - - - - - MpvWidget - - OpenGL rendering initialization failed. - OpenGL 渲染初始化失败。 - - - - - MpvController - - Failed to create MPV instance. - 创建 MPV 实例失败。 - - - Failed to initialize MPV context. - 初始化 MPV 上下文失败。 - - - - - PageAppearance - - Small - 小 - - - Medium - 中 - - - Large - 大 - - - Font Size - 字体大小 - - - Adjust the overall font size of the interface - 调整界面整体字体大小 - - - Left - 左侧 - - - Right - 右侧 - - - Sidebar Position - 侧边栏位置 - - - Place the navigation sidebar on the left or right side - 将导航侧边栏放置在左侧或右侧 - - - Pin Sidebar - 固定侧边栏 - - - Keep the sidebar always visible and integrated into the main layout - 始终显示侧边栏并将其融入主界面布局 - - - Custom Sidebar - 自定义侧边栏 - - - Customize which items appear in the navigation sidebar - 自定义导航侧边栏中显示的项目 - - - Hide Search Bar - 隐藏搜索栏 - - - Hide Home Item - 隐藏主页项 - - - Hide Favorites Item - 隐藏收藏项 - - - Normal Window - 正常窗口 - - - Maximized - 最大化 - - - Startup Window State - 启动时窗口状态 - - - Choose window state when the application launches - 选择应用启动时的窗口状态 - - - Reduce Animations - 降低动画效果 - - - Reduce page transitions and hover effects for better performance - 减少页面过渡和悬停动画以提升性能 - - - Snapshot Navigation - 截图导航模式 - - - Use static screenshots during page transitions for smoother animation on complex views - 页面切换时使用静态截图替代实时渲染,提升复杂视图的过渡流畅度 - - - Enable Search History - 启用搜索记录 - - - Save recent searches locally for quick reuse - 在本地保存最近搜索,便于快速复用 - - - Enable Search Autocomplete - 启用搜索补全 - - - Clear Search History - 清理搜索记录 - - - Clear All - 全部清理 - - - Are you sure you want to clear search history for all servers? - -This action cannot be undone. - 确定要清理所有服务器的搜索记录吗? - -此操作无法撤销。 - - - Clear - 清理 - - - Search history cleared - 搜索记录已清理 - - - Cancel - 取消 - - - Save recent searches locally and offer autocomplete in search boxes - 在本地保存最近搜索,并在搜索框中提供自动补全 - - - - - PagePlayer - - Player - 播放器 - - - Auto - 自动 - - - VAAPI - VAAPI - - - VDPAU - VDPAU - - - CUDA (NVIDIA) - CUDA (NVIDIA) - - - None (Software) - 无 (软件解码) - - - Hardware Decoding - 硬件解码 - - - Select GPU acceleration method for video decoding - 选择视频解码的 GPU 加速方式 - - - libmpv (Embedded) - libmpv (内嵌) - - - GPU Next - GPU Next - - - GPU - GPU - - - Video Output Driver - 视频输出驱动 - - - Rendering backend for embedded player (Render API mode) - 内嵌播放器渲染后端(Render API 模式) - - - Audio - 音频 - - - Display Resample (Default) - 显示重采样(默认) - - - Display VSync - 显示垂直同步 - - - Video Sync Mode - 视频同步模式 - - - Method for synchronizing video frames to display - 视频帧与显示器的同步方式 - - - Chinese - 中文 - - - Chinese (Hong Kong) - 中文(港) - - - Chinese (Macau) - 中文(澳) - - - Chinese (Taiwan) - 中文(台) - - - English - 英语 - - - Japanese - 日语 - - - Korean - 韩语 - - - French - 法语 - - - German - 德语 - - - Spanish - 西班牙语 - - - Russian - 俄语 - - - Preferred Audio Language - 首选音轨语言 - - - Rules are matched in order; choose presets or type custom keywords and press Enter - 规则会按顺序匹配;可选择预设或输入自定义关键词后按回车 - - - Automatically select audio track matching this language - 自动选择匹配该语言的音轨 - - - None - 无 - - - Preferred Subtitle Language - 首选字幕语言 - - - Automatically select subtitle track matching this language - 自动选择匹配该语言的字幕 - - - Original - 原始 - - - 4K - 4K - - - 1080p - 1080p - - - 720p - 720p - - - 480p - 480p - - - Remux - Remux - - - Bitrate High to Low - 码率从高到低 - - - Bitrate Low to High - 码率从低到高 - - - File Size Large to Small - 文件大小从大到小 - - - File Size Small to Large - 文件大小从小到大 - - - Date New to Old - 日期从新到旧 - - - Date Old to New - 日期从旧到新 - - - Preferred Video Version - 首选视频版本 - - - Choose keyword or sort rules in order; type custom keywords and press Enter - 按顺序选择关键词或排序规则;输入自定义关键词后按回车 - - - Continuous Playback - 连续播放 - - - Automatically play the next episode or media when playback ends - 播放结束后自动播放下一集或下一个媒体 - - - 3 Seconds - 3 秒 - - - 5 Seconds - 5 秒 - - - 10 Seconds - 10 秒 - - - 15 Seconds - 15 秒 - - - 30 Seconds - 30 秒 - - - Seek Step - 快进步长 - - - Number of seconds to skip when pressing forward or rewind - 按下前进或后退时跳过的秒数 - - - Long Press Left/Right - 长按左右键 - - - Enable long press actions for left/right keys during playback - 启用播放时左右键的长按操作 - - - Long Press Trigger Time - 长按触发时长 - - - Set how long left/right keys must be held before the long press action starts - 设置左右键按住多久后开始触发长按动作 - - - Long Press Mode - 长按模式 - - - Choose whether holding left/right keys performs time jump seek or temporary speed playback - 选择长按左右键时执行时间跳转还是临时倍速播放 - - - Mouse Edge Long Press - 鼠标左右区域长按 - - - Hold the left or right side of the playback area for speed rewind or fast forward - 在播放界面的左侧或右侧按住鼠标以进行倍速快退或快进 - - - Time Jump Playback - 时间跳转播放 - - - Speed Playback - 倍速播放 - - - 300 ms - 300 毫秒 - - - 500 ms - 500 毫秒 - - - 800 ms - 800 毫秒 - - - 1000 ms - 1000 毫秒 - - - Long Press Seek - 长按快进 - - - Hold forward/rewind buttons to continuously seek with acceleration - 长按前进/后退按钮以加速连续快进 - - - Independent Player Window - 独立播放窗口 - - - Open video in a separate detached window - 在独立的分离窗口中打开视频 - - - Volume Normalization - 音量标准化 - - - Automatically adjust volume to prevent sudden loud noises - 自动调节音量以防止突然的大声噪音 - - - Use Custom mpv.conf - 使用自定义 mpv.conf - - - Load settings from an external mpv.conf configuration file - 从外部 mpv.conf 配置文件加载设置 - - - Config path: %1 - 配置文件路径: %1 - - - Open File - 打开文件 - - - External Player - 外置播放器 - - - Enable External Player - 启用外置播放器 - - - Supported players on this platform: %1 - 当前平台支持的播放器:%1 - - - Custom... - 自定义... - - - Re-detect installed players - 重新检测已安装的播放器 - - - Select External Player - 选择外置播放器 - - - Executable Files (*.exe) - 可执行文件 (*.exe) - - - All Files (*) - 所有文件 (*) - - - Arguments - 启动参数 - - - e.g., --fullscreen --volume=100 - 例如 --fullscreen --volume=100 - - - - - PlaybackManager - - External player not found, falling back to internal player - 未找到外部播放器,回退到内置播放器 - - - Failed to launch external player - 启动外部播放器失败 - - - Launching %1... - 正在启动 %1... - - - - - PagePlayer - - Quick Play with External Player - 快捷播放使用外置播放器 - - - Use external player for quick play actions on cards and menus - 卡片和菜单上的快捷播放操作将使用外置播放器 - - - Direct Stream - 直链播放 - - - Bypass server proxy and stream directly from source. May not work on external networks - 绕过服务器代理,直接从源地址播放。在外网环境下可能无法使用 - - - URL Path Replacement one rule per line: source => target - URL路径替换 每行一条规则:源路径 => 目标路径 - - - e.g., http://192.168.2.1:19798/path/ => W:/mount/ - 例如 http://192.168.2.1:19798/path/ => W:/mount/ - - - - - - PagePlayer - - Click to Pause - 单击暂停 - - - Single click on the video area to toggle play/pause - 在视频画面上单击鼠标左键以切换播放/暂停 - - - Auto Skip Intro - 自动跳过片头 - - - Automatically skip the intro sequence when playback starts - 播放开始时自动跳过片头片段 - - - Auto Skip Outro - 自动跳过片尾 - - - Automatically skip the ending credits during playback - 播放时自动跳过片尾演职员表 - - - - ManageView - - Management - 管理 - - - Dashboard - 仪表盘 - - - Libraries - 媒体库 - - - Lists - 列表 - - - Users - 用户 - - - Tasks - 任务 - - - Logs - 日志 - - - Transcoding - 转码 - - - Lists - 列表 - - - Transcoding - 转码 - - - Users - 用户 - - - Tasks - 计划任务 - - - Logs - 日志 - - - - - PageTranscoding - - Transcoding - 转码 - - - Refresh - 刷新 - - - Save - 保存 - - - Loading... - 加载中... - - - Server - 服务端 - - - Acceleration - 加速 - - - Tone Mapping - 色调映射 - - - Temp Path - 临时路径 - - - General - 常规 - - - Paths, threads and core transcoding switches. - 路径、线程和核心转码开关。 - - - Temporary Path - 临时目录 - - - Encoding Threads - 编码线程 - - - Auto - 自动 - - - Enable Fallback Font - 启用后备字体 - - - Fallback Font Path - 后备字体路径 - - - Audio VBR - 音频 VBR - - - FFmpeg Path - FFmpeg 路径 - - - Extraction Threads - 抽取线程 - - - Subtitle Extraction - 字幕提取 - - - Quality & Cleanup - 质量与清理 - - - Quality, buffering and segment retention. - 质量、缓冲与分片保留设置。 - - - H.264 CRF - H.264 CRF - - - Downmix Boost - 混音增益 - - - Enable Throttling - 启用限速 - - - H.265 CRF - H.265 CRF - - - None - 无 - - - Dave750 - Dave750 - - - Nightmode - 夜间模式 - - - NightmodeDialogue - NightmodeDialogue - - - Dolby Pro Logic - Dolby Pro Logic - - - Dolby Pro Logic II - Dolby Pro Logic II - - - RFC7845 - RFC7845 - - - AC-4 - AC-4 - - - Stereo Downmix - 立体声下混 - - - Very Slow - 很慢 - - - Slower - 更慢 - - - Slow - 慢 - - - Medium - 中等 - - - Fast - 快 - - - Faster - 更快 - - - Very Fast - 很快 - - - Super Fast - 超快 - - - Ultra Fast - 极速 - - - Encoder Preset - 编码预设 - - - Max Muxing Queue - 最大复用队列 - - - Throttle Delay - 限速延迟 - - - Enable Segment Deletion - 启用分片清理 - - - Segment Keep Seconds - 分片保留秒数 - - - YADIF - YADIF - - - BWDIF - BWDIF - - - Bob - Bob - - - Deinterlace Method - 去隔行方式 - - - Double-Rate Deinterlace - 双倍帧率去隔行 - - - Throttle Buffer - 限速缓冲区 - - - Throttle Hysteresis - 限速滞后 - - - Throttling Method - 限速方式 - - - Hardware Acceleration - 硬件加速 - - - Devices, decode paths and hardware encoder toggles. - 设备、解码路径以及硬件编码开关。 - - - Enable Hardware Encoding - 启用硬件编码 - - - QSV - QSV - - - NVENC - NVENC - - - AMF - AMF - - - VAAPI - VAAPI - - - VideoToolbox - VideoToolbox - - - V4L2M2M - V4L2M2M - - - RKMPP - RKMPP - - - VAAPI Device - VAAPI 设备 - - - QSV Device - QSV 设备 - - - Hardware Decoding Codecs - 硬件解码编解码器 - - - Prefer Native Decoder - 优先系统原生解码器 - - - Enhanced NVDEC - 增强 NVDEC - - - Intel Low Power H.264 - Intel 低功耗 H.264 - - - Intel Low Power HEVC - Intel 低功耗 HEVC - - - Allow HEVC Encode - 允许 HEVC 编码 - - - Allow AV1 Encode - 允许 AV1 编码 - - - Codec Paths - 编解码路径 - - - HDR conversion and tone mapping controls. - HDR 转换与色调映射控制。 - - - Enable Tonemapping - 启用色调映射 - - - Enable VPP Tonemapping - 启用 VPP 色调映射 - - - Enable VideoToolbox Tonemapping - 启用 VideoToolbox 色调映射 - - - Algorithm - 算法 - - - BT.2390 - BT.2390 - - - Hable - Hable - - - Reinhard - Reinhard - - - Mobius - Mobius - - - Clip - Clip - - - Linear - Linear - - - Gamma - Gamma - - - Mode - 模式 - - - MAX - MAX - - - RGB - RGB - - - LUM - LUM - - - ITP - ITP - - - Range - 范围 - - - TV - TV - - - PC - PC - - - Desaturation - 去饱和 - - - Peak - 峰值 - - - Parameter - 参数 - - - VPP Brightness - VPP 亮度 - - - VPP Contrast - VPP 对比度 - - - Hardware Tone Mapping - 硬件色调映射 - - - Software Tone Mapping - 软件色调映射 - - - Advanced - 高级 - - - Codec-level and extraction-specific options. - 编解码级别和抽帧相关的高级选项。 - - - 10-bit HEVC Decode - 10 位 HEVC 解码 - - - 10-bit VP9 Decode - 10 位 VP9 解码 - - - 10-bit HEVC Rext - 10 位 HEVC Rext - - - 12-bit HEVC Rext - 12 位 HEVC Rext - - - Keyframe Extensions - 关键帧扩展名 - - - No transcoding configuration available - 没有可用的转码配置 - - - Refresh failed - 刷新失败 - - - Failed to load transcoding settings - 加载转码设置失败 - - - Failed to load transcoding settings: %1 - 加载转码设置失败:%1 - - - Saving... - 保存中... - - - Transcoding settings saved - 转码设置已保存 - - - Failed to save transcoding settings: %1 - 保存转码设置失败:%1 - - - Jellyfin - Jellyfin - - - Emby - Emby - - - Unknown - 未知 - - - Disabled - 已禁用 - - - Enabled - 已启用 - - - Mode %1 - 模式 %1 - - - VPP - VPP - - - Mixed - 混合 - - - Hardware - 硬件 - - - Software - 软件 - - - Default - 默认 - - - Custom - 自定义 - - - %1 · %n visible setting(s) - - %1 · 显示 %n 项设置 - - - - %n codec path(s) - - %n 个编解码路径 - - - - +%1 more - 另有 %1 项 - - - Decoder - 解码器 - - - Core transcoding switches used by the current server. - 当前服务端实际使用的核心转码开关。 - - - Transcoding Temp Path - 转码临时目录 - - - Enable Subtitle Extraction - 启用字幕提取 - - - Downmix Audio Boost - 音频下混增益 - - - Hardware transcoding mode, decoder preference and codec support. - 硬件转码模式、首选解码器以及编解码支持设置。 - - - Hardware Acceleration Type - 硬件加速类型 - - - Prefer System Native Decoder - 优先系统原生解码器 - - - Allow HEVC Encoding - 允许 HEVC 编码 - - - Allow AV1 Encoding - 允许 AV1 编码 - - - No - 否 - - - Yes - 是 - - - Preferred Hardware Decoder - 首选硬件解码器 - - - Preferred Hardware Encoder - 首选硬件编码器 - - - HDR conversion and tone mapping behavior. - HDR 转换与色调映射行为。 - - - Hardware + Software - 硬件 + 软件 - - - Automatic - 自动 - - - System Native - 系统原生 - - - 10-bit Decoding - 10 位解码 - - - AV1 - AV1 - - - By Segment Request - 按分段请求 - - - Codec Information - 编解码器信息 - - - Drag to Reorder - 拖动调整顺序 - - - Custom Order - 自定义顺序 - - - Encoding Formats - 编码格式 - - - H264 - H264 - - - HEVC - HEVC - - - HEVC 10-bit - HEVC 10 位 - - - HEVC RExt 12-bit - HEVC RExt 12 位 - - - HEVC RExt 8/10-bit - HEVC RExt 8/10 位 - - - HEVC RExt Decoding - HEVC RExt 解码 - - - Hardware Decoding - 硬件解码 - - - Hardware Encoding - 硬件编码 - - - MPEG1 - MPEG1 - - - MPEG2 - MPEG2 - - - MPEG4 - MPEG4 - - - Metadata Keyframe Extensions - 按需关键帧元数据扩展名 - - - Move Down - 下移 - - - Move Up - 上移 - - - OK - 确定 - - - Paths and Threads - 路径与线程 - - - Preferred Hardware Decoders - 首选硬件解码器 - - - Preferred Hardware Encoders - 首选硬件编码器 - - - Quality and Processing - 质量与处理 - - - Software Encoders - 软件编码器 - - - Settings - 设置 - - - Throttling and Segments - 节流与分段 - - - VC1 - VC1 - - - VP8 - VP8 - - - VP9 - VP9 - - - VP9 10-bit - VP9 10 位 - - - VPP Tone Mapping - VPP 色调映射 - - - - - EmbyCodecInfoDialog - - Max Bitrate: - 最大码率: - - - Frame Sizes: - 帧尺寸: - - - Frame Rates: - 帧率范围: - - - Max Instances: - 最大实例数: - - - Color Formats: - 颜色格式: - - - Profile - Profile - - - Max Level - 最高级别 - - - Bit Depths - 位深 - - - Resolutions - 分辨率 - - - max %1x%2 - 最大 %1x%2 - - - %1fps...%2fps - %1fps...%2fps - - - max %1fps - 最大 %1fps - - - %1fps - %1fps - - - - - EmbyCodecParametersDialog - - Codec Settings - 编解码器设置 - - - Loading codec parameters... - 正在加载编解码器参数... - - - Basic Parameters - 基础参数 - - - Advanced Parameters - 高级参数 - - - Cancel - 取消 - - - Reset to Defaults - 重置为默认值 - - - Save - 保存 - - - No editable parameters available for this codec. - 当前编解码器没有可编辑参数。 - - - Saving... - 保存中... - - - Failed to load codec parameters - 加载编解码器参数失败 - - - Failed to load codec parameters: %1 - 加载编解码器参数失败:%1 - - - Codec parameters saved - 编解码器参数已保存 - - - Failed to save codec parameters: %1 - 保存编解码器参数失败:%1 - - - - - PageDashboard - - Dashboard - 仪表盘 - - - Server Information - 服务器信息 - - - Refresh - 刷新 - - - Restart Server - 重启服务器 - - - Shutdown Server - 关闭服务器 - - - Server Name - 服务器名称 - - - Version - 版本 - - - Operating System - 操作系统 - - - Architecture - 架构 - - - Address - 地址 - - - Now Playing - 正在播放 - - - Nothing is playing - 当前没有正在播放的内容 - - - Active Sessions - 活跃会话 - - - No active sessions - 暂无活跃会话 - - - Activity Log - 活动日志 - - - No activity records - 暂无活动记录 - - - Failed to load: %1 - 加载失败:%1 - - - Are you sure you want to restart the server? All active sessions will be terminated. - 确定要重启服务器吗?所有活跃的会话将被终止。 - - - Restart - 重启 - - - Cancel - 取消 - - - Server restart command sent - 已发送服务器重启命令 - - - Restart failed: %1 - 重启失败:%1 - - - Are you sure you want to shut down the server? This will stop all services and disconnect all clients. - 确定要关闭服务器吗?这将停止所有服务并断开所有客户端的连接。 - - - Shutdown - 关机 - - - Server shutdown command sent - 已发送服务器关机命令 - - - Shutdown failed: %1 - 关机失败:%1 - - - - - PageLibraries - - Libraries - 媒体库 - - - Refresh library list - 刷新媒体库列表 - - - Scan all libraries - 扫描全部媒体库 - - - Create new library - 创建新媒体库 - - - Cancel scan - 取消扫描 - - - No media libraries found - 未找到任何媒体库 - - - Loading... - 加载中... - - - 0 libraries - 0 个媒体库 - - - %n library(s) - - %n 个媒体库 - - - - Library created successfully - 媒体库创建成功 - - - Failed to create library: %1 - 创建媒体库失败:%1 - - - Library updated successfully - 媒体库更新成功 - - - Failed to update library: %1 - 更新媒体库失败:%1 - - - Metadata saved - 元数据已保存 - - - Failed to update metadata: %1 - 更新元数据失败:%1 - - - Delete Library - 删除媒体库 - - - Are you sure you want to delete the library "%1"? - -This will remove the library from the server, but will not delete the actual media files. - 确定要删除媒体库"%1"吗? - -这将从服务器移除该媒体库,但不会删除实际的媒体文件。 - - - Delete - 删除 - - - Cancel - 取消 - - - Library "%1" deleted - 媒体库"%1"已删除 - - - Failed to delete library: %1 - 删除媒体库失败:%1 - - - Scanning "%1"... - 正在扫描"%1"... - - - Refreshing metadata for "%1"... - 正在刷新“%1”的元数据... + + Scanning "%1"... + 正在扫描“%1”... + Metadata refresh failed: %1 - 刷新元数据失败:%1 + 元数据刷新失败:%1 + Scan failed: %1 扫描失败:%1 - Full library scan started - 已开始全库扫描 + Metadata saved + 元数据已保存 - Scan cancelled - 扫描已取消 - - - Cancel failed: %1 - 取消失败:%1 - - - Library order updated - 媒体库顺序已更新 - - - Failed to save order: %1 - 保存顺序失败:%1 - - - Failed to load libraries: %1 - 加载媒体库失败:%1 - - - Select all libraries - 全选媒体库 - - - Clear library selection - 取消全选 - - - Delete selected libraries - 删除已选媒体库 - - - Delete Selected - 删除已选 - - - %n selected - - 已选 %n 个 - - - - %1 · %2 - %1 · %2 - - - Are you sure you want to delete %n selected library(s)? - -This will remove the libraries from the server, but will not delete the actual media files. - - 确定要删除 %n 个已选媒体库吗? - -此操作会从服务器移除媒体库,但不会删除实际媒体文件。 - - - - 确定要删除 %1 个已选媒体库吗? - -此操作会从服务器移除媒体库,但不会删除实际媒体文件。 - 确定要删除 %1 个已选媒体库吗? - -此操作会从服务器移除媒体库,但不会删除实际媒体文件。 - - - 删除已选 - 删除已选 - - - Deleted %1 library(s), then failed: %2 - 已删除 %1 个媒体库,后续失败:%2 - - - Failed to delete selected libraries: %1 - 删除已选媒体库失败:%1 - - - Deleted %n library(s) - - 已删除 %n 个媒体库 - + Failed to update metadata: %1 + 更新元数据失败:%1 - - NowPlayingCard + DashboardViewModel + Direct Play 直接播放 + Direct Stream 直接串流 + Transcode 转码 - - - - LibraryActionMenu - Edit Metadata - 编辑元数据 + + Failed to load: %1 + 加载失败:%1 - Edit Images - 编辑图像 + + Restart Server + 重启服务器 - Refresh Metadata - 刷新元数据 + + Are you sure you want to restart the server? All active sessions will be terminated. + 确定要重启服务器吗?所有活动会话将被终止。 - Scan Library Files - 扫描媒体库文件 + + Restart + 重启 + + + + + Cancel + 取消 + + + + Server restart command sent + 服务器重启命令已发送 + + + + Restart failed: %1 + 重启失败:%1 + + + + Shutdown Server + 关闭服务器 + + + + Are you sure you want to shut down the server? This will stop all services and disconnect all clients. + 确定要关闭服务器吗?这将停止所有服务并断开所有客户端。 + + + + Shutdown + 关机 + + + + Server shutdown command sent + 服务器关机命令已发送 + + + + Shutdown failed: %1 + 关机失败:%1 - - LibraryMetadataEditDialog + DetailActionWidget - Edit Metadata - 编辑元数据 + + ▶ Resume + ▶ 继续播放 - Basic Information - 基础信息 + + + + + ▶ Play + ▶ 播放 - Classification - 分类 + + ↺ Play from Beginning + ↺ 从头开始播放 - Ratings & Release - 评分与发行 + + ▶ Resume %1 + ▶ 继续播放 %1 - External IDs - 外部标识 + + ↺ Restart %1 + ↺ 重新播放 %1 - Music Metadata - 音乐元数据 + + ▶ Play %1 + ▶ 播放 %1 - Additional Details - 附加详情 + + No Subtitles + 无字幕 - Metadata Settings - 元数据设置 + + + %1 remaining + 剩余 %1 - Field Locks - 字段锁定 + + Version %1 + 版本 %1 - Path - 路径 + + Unknown Video + 未知视频 - Name - 名称 + 🚫 No Subtitles + 🚫 无字幕 - Original Title - 原始标题 + + Disable subtitle display + 禁用字幕显示 - Sort Name - 排序名称 + + Audio Track + 音轨 - Tagline - 宣传语 + + Subtitle + 字幕 - Genres - 类型 + + Forced + 强制 + + External + 外部 + + + + SDH + 听障 + + + Standard Subtitle + 标准字幕 + + + + %1 hr %2 min + %1 小时 %2 分钟 + + + + %1 min + %1 分钟 + + + + DetailBottomInfoWidget + + Tags 标签 + Studios 工作室 - Date Added - 加入日期 + + Media Info + 媒体信息 - Production Year - 年份 + + Added %1 + 添加于 %1 - Premiere Date - 发行日期 + + Video + 视频 - End Date - 结束日期 + + Audio + 音频 - Status - 状态 + + Subtitle + 字幕 - Runtime (Minutes) - 时长(分钟) + + Title + 标题 - Official Rating - 家长分级 + + Codec + 编码 - Custom Rating - 自定义评分 + + + + Language + 语言 - Community Rating - 社区评分 + + + Codec Tag + 编码标签 - Critic Rating - 影评人评分 + + + Profile + 配置项 - IMDb Id - IMDb Id + + Level + 级别 - MovieDb Id - MovieDb Id + + Resolution + 分辨率 - TheTVDB Id - TheTVDB Id + + Aspect Ratio + 宽高比 - Album - 专辑 + + Interlaced + 隔行扫描 - Album Artist - 专辑艺人 + + Framerate + 帧率 - Artists - 艺人 + + + Bitrate + 码率 - Composers - 作曲家 + + Bit Depth + 位深 - Channel Number - 频道编号 + + Pixel Format + 像素格式 - Place of Birth - 出生地 + + Ref Frames + 参考帧 - Parent Index Number - 父级序号 + + Layout + 布局 - Index Number + + Channels + 声道 + + + + Sample Rate + 采样率 + + + + + Default + 默认 + + + + + + + + + + + + Yes + 是 + + + + Index + 索引 + + + + Forced + 强制 + + + + Hearing Impaired + 听力障碍 + + + + External + 外部 + + + + Text Stream + 纯文本流 + + + + Ext. Stream Supported + 支持外部流 + + + + External URL + 外部 URL + + + + Delivery Method + 传输方式 + + + + Protocol + 协议 + + + + Ext. Video Type + 外部视频类型 + + + + Ext. Video SubType + 外部视频子类型 + + + + Size + 大小 + + + + Path + 路径 + + + + Delivery URL + 传输 URL + + + + External Links + 外部链接 + + + + DetailPage + + + Resume + 继续播放 + + + + Play + 播放 + + + + Play from beginning + 从头播放 + + + + Favorited + 已收藏 + + + + Favorite + 收藏 + + + + Mark unplayed + 标为未看 + + + + Mark played + 标为已看 + + + + More + 更多 + + + + Version + 版本 + + + + Seasons + 季 + + + + Episodes + 选集 + + + + Cast & Crew + 演职人员 + + + + Additional Parts + 附加部分 + + + + Collection + 合集 + + + + More Like This + 相似推荐 + + + + Loading details + 正在加载详情 + + + + Unable to load details + 无法加载详情 + + + + DetailView + + Loading... + 加载中... + + + Error Loading Item + 加载项目时出错 + + + Click to copy number + 点击复制番号 + + + Number copied: %1 + 番号已复制:%1 + + + Seasons + 季 + + + Cast & Crew + 演职人员 + + + Additional Parts + 附加内容 + + + Collection + 合集归属 + + + More Like This + 相似推荐 + + + Episodes + 剧集 + + + Ep # + 集数 + + + 1-%1 + 1-%1 + + + Jump to episode number + 输入集号后回车跳转 + + + Jump to episode number (1-%1) + 输入集号后回车跳转(1-%1) + + + Episode %1 not found + 未找到第 %1 集 + + + ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>More</a> + ... <a href='more' style='color:#3B82F6; text-decoration:none; font-weight:bold;'>更多</a> + + + %1 hr %2 min + %1 小时 %2 分钟 + + + %1 min + %1 分钟 + + + Actor + 演员 + + + Director + 导演 + + + Writer + 编剧 + + + Producer + 制片人 + + + Composer + 作曲 + + + Conductor + 指挥 + + + GuestStar + 特邀嘉宾 + + + Editor + 剪辑 + + + Lyricist + 作词 + + + + Unable to prepare playback + 无法准备播放 + + + + DetailViewModel + + + Version %1 + 版本 %1 + + + + Media service is unavailable + 媒体服务不可用 + + + + Unable to load this item + 无法加载此项目 + + + + DownloadManager + + + Downloaded Media + 下载的媒体 + + + + + Failed to write download file + 写入下载文件失败 + + + + + + Downloading + 下载中 + + + + Completed + 已完成 + + + + Download completed + 下载完成 + + + + Download failed + 下载失败 + + + + + Failed to prepare download: %1 + 准备下载失败:%1 + + + + This media is not available for download + 当前媒体不可下载 + + + + Failed to resolve download address + 解析下载地址失败 + + + + Failed to create download directory + 创建下载目录失败 + + + + + Failed to open download file + 打开下载文件失败 + + + + Download started + 已开始下载 + + + + + + + Pausing + 暂停中 + + + + Paused + 已暂停 + + + + + + + + + + Cancelling + 正在取消 + + + + + Cancelled + 已取消 + + + + + Download cancelled + 下载已取消 + + + + Download paused + 下载已暂停 + + + + Download resumed + 下载已继续 + + + + Download removed + 下载任务已移除 + + + + Switch back to the original server before resuming this download + 请先切换回原始服务器,再继续此下载任务 + + + + The original media source is no longer available + 原始媒体源已不可用 + + + + DownloadManagerDialog + + + + Unknown + 未知 + + + + B + B + + + + KB + KB + + + + MB + MB + + + + GB + GB + + + + TB + TB + + + %1% (%2 / %3) + %1%(%2 / %3) + + + Downloading %1% (%2 / %3) + 下载中 %1%(%2 / %3) + + + %1 downloaded + 已下载 %1 + + + Downloading %1 + 下载中 %1 + + + + %1 %2% (%3 / %4) + %1 %2%(%3 / %4) + + + + %1 %2 + %1 %2 + + + Pausing + 暂停中 + + + + Paused %1% (%2 / %3) + 已暂停 %1%(%2 / %3) + + + + Paused %1 + 已暂停 %1 + + + + Paused + 已暂停 + + + + Waiting... + 等待中... + + + + -- + -- + + + Downloads + 下载 + + + + Download Manager + 下载管理 + + + Total %1 Active %2 Completed %3 Failed %4 + 共 %1 项 进行中 %2 已完成 %3 失败 %4 + + + + %1 records, %2 active, %3 completed, %4 failed + 共 %1 项,进行中 %2 项,已完成 %3 项,失败 %4 项 + + + No download records + 暂无下载记录 + + + + No records yet + 暂无记录 + + + Download tasks will appear here after you start them. + 开始下载后,任务会显示在这里。 + + + + Start a download and the record will appear here. + 开始下载后,记录会显示在这里。 + + + + No. 序号 - Preferred Metadata Language - 首选元数据语言 + Active + 进行中 - Metadata Country Code - 元数据国家/地区代码 + + Completed + 已完成 - Overview - 概述 + + Failed + 失败 - Lock metadata - 锁定元数据 + Download Directory + 下载目录 - Comma separated values - 多个值请用逗号分隔 + Download Location + 下载位置 - yyyy-MM-dd - yyyy-MM-dd + + Saved To + 保存位置 - yyyy-MM-ddTHH:mm:ss - yyyy-MM-ddTHH:mm:ss + Choose where new downloads are saved. + 设置新下载内容的保存位置。 - yyyy-MM-dd HH:mm:ss - yyyy-MM-dd HH:mm:ss + + + Open Directory + 打开目录 - Minutes - 分钟 + + Set Directory + 设置目录 - 0-10 - 0-10 + Current Downloads + 当前下载 - 0-100 - 0-100 + Stopping a task removes the unfinished file immediately. + 停止任务后会立即删除未完成的文件。 - Continuing / Ended - 连载中 / 已完结 + No active downloads + 暂无进行中的下载 - en-US - en-US + + Media + 媒体 - US - US + + Size + 大小 - Cancel - 取消 + + Progress + 进度 - Save - 保存 - - - - - LibraryCard - - Edit - 编辑 + + Started + 开始时间 - Scan - 扫描 + Status + 状态 + Action + 操作 + + + Download History + 下载历史 + + + Select a record to open the media, jump to its folder, or remove it. + 选择一条记录后,可以打开媒体、跳转到目录,或移除记录。 + + + No download history + 暂无下载历史 + + + Result + 结果 + + + + Finished + 完成时间 + + + No download record selected + 未选择下载记录 + + + + + + Open Media + 打开媒体 + + + + Pause Download + 暂停下载 + + + + Resume Download + 继续下载 + + + + + + Open Folder + 打开文件夹 + + + + + + + + Delete Record + 删除记录 + + + + Stop + 停止 + + + + + + Stop Download + 停止下载 + + + + Stop and remove the unfinished file + 停止并删除未完成文件 + + + + Select Download Directory + 选择下载目录 + + + + + The download folder is no longer available. + 下载文件夹已不存在。 + + + + + The downloaded media file is no longer available. + 下载的媒体文件已不存在。 + + + + Also Delete File + 同时删除文件 + + + Also remove the local file when deleting a history record + 删除历史记录时同时删除本地文件 + + + + + Delete download record for "%1"? + 确定要删除“%1”的下载记录吗? + + + Delete download record for "%1" and remove the local file? + 确定要删除“%1”的下载记录并移除本地文件吗? + + + + Cancel active download for "%1" and remove the unfinished file? + 确定要取消“%1”的下载并删除未完成文件吗? + + + + + Delete 删除 - path - 个路径 + + + + + Cancel + 取消 - paths - 个路径 + Delete File + 删除文件 + + + Delete the downloaded file too? + 是否同时删除已下载的文件? + + + Keep File + 保留文件 + + + Server: %1 +Status: %2 +Added: %3 +Finished: %4 +Size: %5 + 服务器:%1 +状态:%2 +添加时间:%3 +完成时间:%4 +大小:%5 + + + Saved To: %1 + 保存到:%1 - - PathRowWidget + EmbyCodecInfoDialog - Browse server directory - 浏览服务器目录 + + Max Bitrate: + 最大码率: - Remove this path - 移除此路径 + + Frame Sizes: + 帧尺寸: - Click + to browse... - 点击 + 浏览目录... + + Frame Rates: + 帧率范围: + + + + Max Instances: + 最大实例数: + + + + Color Formats: + 颜色格式: + + + + Profile + Profile + + + + Max Level + 最高级别 + + + + Max Bitrate + 最大比特率 + + + + Bit Depths + 位深 + + + + Resolutions + 分辨率 + + + + max %1x%2 + 最大 %1x%2 + + + + %1fps...%2fps + %1fps...%2fps + + + + max %1fps + 最大 %1fps + + + + %1fps + %1fps + + + + EmbyCodecParametersDialog + + + Codec Settings + 编解码器设置 + + + + + Loading codec parameters... + 正在加载编解码器参数... + + + + Basic Parameters + 基础参数 + + + + Advanced Parameters + 高级参数 + + + + Cancel + 取消 + + + + Reset to Defaults + 重置为默认值 + + + + + Save + 保存 + + + + No editable parameters available for this codec. + 当前编解码器没有可编辑参数。 + + + + Saving... + 保存中... + + + + + Failed to load codec parameters + 加载编解码器参数失败 + + + + Failed to load codec parameters: %1 + 加载编解码器参数失败:%1 + + + + Codec parameters saved + 编解码器参数已保存 + + + + Failed to save codec parameters: %1 + 保存编解码器参数失败:%1 + + + + ErrorState + + + Something went wrong + 出了点问题 + + + + Try again + 重试 + + + + FavoritesPage + + + Favorites + 收藏 + + + + Refreshing… + 刷新中… + + + + Refresh + 刷新 + + + + Try again + 重试 + + + + Favorite Movies + 收藏的电影 + + + + Favorite Shows + 收藏的剧集 + + + + Favorite Collections + 收藏的合集 + + + + Favorite Playlists + 收藏的播放列表 + + + + Favorite People + 收藏的人物 + + + + Favorite Folders + 收藏的文件夹 + + + + Loading favorites… + 正在加载收藏… + + + + Unable to load favorites + 无法加载收藏 + + + + No favorites yet + 暂无收藏 + + + + Items you mark as favorites will appear here. + 标记为收藏的项目将显示在这里。 + + + + FavoritesView + + Favorite Movies + 收藏的电影 + + + Favorite Shows + 收藏的电视剧 + + + Favorite Collections + 收藏的合集 + + + Favorite Playlists + 收藏的播放列表 + + + Favorite People + 收藏的演员 + + + Favorite Folders + 收藏的文件夹 + + + See All > + 查看全部 > + + + + FavoritesViewModel + + + movies + 电影 + + + + shows + 剧集 + + + + collections + 合集 + + + + playlists + 播放列表 + + + + people + 人物 + + + + folders + 文件夹 + + + + Some favorites could not be loaded: %1 + 部分收藏无法加载:%1 + + + + Favorite Movies + 收藏的电影 + + + + Favorite Shows + 收藏的剧集 + + + + Favorite Collections + 收藏的合集 + + + + Favorite Playlists + 收藏的播放列表 + + + + Favorite People + 收藏的人物 + + + + Favorite Folders + 收藏的文件夹 - FetcherRowWidget + Move up 上移 + Move down 下移 - - ServerBrowserDialog + HomeQuickRouterView - Browse Server Directory - 浏览服务器目录 + + + This action requires administrator privileges + 此操作需要管理员权限 - Cancel - 取消 + + this item + 此项目 - Select - 选择 + + Refreshing metadata for "%1"... + 正在刷新“%1”的元数据… - Failed to load drives - 加载驱动器失败 + + Scanning "%1"... + 正在扫描“%1”… - Access denied or error - 访问被拒绝或发生错误 + + Metadata refresh failed: %1 + 元数据刷新失败:%1 + + + + Scan failed: %1 + 扫描失败:%1 + + + + HomeView + + + Search... + 搜索... + + + + Search + 搜索 + + + + Home + 首页 + + + + Favorites + 收藏 + + + + MEDIA + 媒体库 + + + + + Settings + 设置 + + + + Manage + 管理 + + + + Downloads + 下载 + + + + Cloud Sync (WebDAV) + 云同步(WebDAV) + + + + Logout + 退出 + + + + My Server + 我的服务器 + + + + Administrator + 管理员 + + + + User + 用户 + + + + Refreshing Home... + 正在刷新主页... + + + + Refreshing Favorites... + 正在刷新收藏... + + + This feature is under development + 该功能正在开发中 + + + + Server Management + 服务器管理 + + + + LibraryActionMenu + + Edit Metadata + 编辑元数据 + + + + Edit Images + 编辑图像 + + + + Refresh Metadata + 刷新元数据 + + + + Scan Library Files + 扫描媒体库文件 + + + + LibraryCard + + + Edit + 编辑 + + + + Scan + 扫描 + + + + Delete + 删除 + + + + path + 个路径 + + + + paths + 个路径 - LibraryCreateDialog + Create Library 创建媒体库 + + Cancel 取消 + Create 创建 + Enter library name... 输入媒体库名称... + 🎬 Movies 🎬 电影 + 📺 TV Shows 📺 电视节目 + 🎵 Music 🎵 音乐 + 🎞️ Home Videos 🎞️ 家庭视频 + 📚 Books 📚 图书 + 🎤 Music Videos 🎤 音乐视频 + 📷 Photos 📷 照片 + 📦 Mixed 📦 混合内容 + Media Paths: 媒体路径: + Library Settings 媒体库设置 + Enable real-time monitoring 启用实时监控 + + Automatically detect file changes in library folders 自动检测媒体库文件夹中的文件变化 + Prefer embedded titles over filenames 优先使用文件内嵌标题而非文件名 + + Use titles stored in media file metadata instead of filenames 使用媒体文件元数据中存储的标题,而非文件名 + Preferred metadata language: 首选元数据语言: + + + Server Default 服务器默认 + + English 英语 + + Chinese (Simplified) 中文(简体) - - Chinese (Traditional) - 中文(繁体) - - - Chinese (Hong Kong) - 中文(香港) - - - Japanese - 日语 - + + + Chinese (Traditional) + 中文(繁体) + + + + + Chinese (Hong Kong) + 中文(香港) + + + + + Japanese + 日语 + + + + Korean 韩语 + + French 法语 + + German 德语 + + Spanish 西班牙语 + + Russian 俄语 + + Portuguese 葡萄牙语 + + Italian 意大利语 + Preferred country/region: 首选国家/地区: + United States 美国 + China 中国大陆 + Taiwan 台湾 + Hong Kong 香港 + Japan 日本 + South Korea 韩国 + United Kingdom 英国 + France 法国 + Germany 德国 + + Every 14 days + 每 14 天 + + + Preferred image language: 首选图片语言: + Save metadata as Nfo 将元数据保存为 Nfo 文件 + + Save metadata in NFO format alongside media files 将元数据以 NFO 格式保存到媒体文件旁边 + Nfo metadata reader Nfo 元数据读取器 + + Sort preferred local metadata sources by priority. The first file found will be read. 按优先级排列本地元数据源,首个找到的文件将被读取。 + Allow adult metadata 允许成人内容元数据 + + Allow matching adult titles when searching for metadata on the internet 在互联网上搜索元数据时允许匹配成人内容 + Import collection info from metadata downloaders 从元数据下载器导入合集信息 + + Automatically create collections based on metadata 根据元数据自动创建合集 + Automatically group into collections 自动归入合集 + + Automatically group movies into collections when enough related items exist 当存在足够数量的相关影片时,自动将其归入合集 + Minimum collection size: 最小合集数量: + Automatic metadata refresh: 自动刷新元数据: + Placeholder metadata refresh: 占位元数据刷新: + + + Never 从不 + As a scheduled task 作为计划任务执行 + On library scan and scheduled task 在媒体库扫描时并作为计划任务执行 + Every 2 days 每 2 天 + Every 3 days 每 3 天 + Every 7 days 每 7 天 + + Every 30 days 每 30 天 + + Every 60 days 每 60 天 + + Every 90 days 每 90 天 + + Metadata fetchers: 元数据抓取器: + Series metadata fetchers: 剧集元数据抓取器: + Season metadata fetchers: 季元数据抓取器: + Episode metadata fetchers: 集元数据抓取器: + Images 图片 + Enable photos 启用照片 + + Allow photo items to be included in this library 允许该媒体库包含照片内容 + Import playlists 导入播放列表 + + Import playlist files that are found in the selected folders 导入在所选文件夹中发现的播放列表文件 + Exclude from global search 从全局搜索中排除 + + Hide items in this library from global search results 在全局搜索结果中隐藏此媒体库的条目 + Merge top folders in folder view 在文件夹视图中合并顶层文件夹 + + Flatten the top-level folders when browsing in folder view 在文件夹视图浏览时平铺显示顶层文件夹 + Music folder structure: 音乐文件夹结构: + Other or unstructured 其他或无固定结构 + Perfect artist/album/track 标准 艺术家/专辑/曲目 结构 + Perfect album/track 标准 专辑/曲目 结构 + Save artwork into media folders 将封面图片保存到媒体文件夹 + Save local metadata 保存本地元数据 + + Store downloaded metadata and images alongside media files 将下载的元数据和图片保存到媒体文件旁边 + + Store metadata images alongside media files 将元数据图片存储到媒体文件旁边 + Cache images in server data 将图片缓存到服务器数据目录 + + Store generated and downloaded images in the server data folder 将生成和下载的图片保存到服务器数据目录 + Save metadata as hidden files 将元数据保存为隐藏文件 + + Mark locally saved metadata files as hidden on supported servers 在支持的服务器上将本地保存的元数据文件标记为隐藏 + Download images in advance 提前下载图片 + + Download all available images during library scan 在媒体库扫描期间下载所有可用图片 + Extract chapter images 提取章节缩略图 + + Generate thumbnail images for video chapters 为视频章节生成缩略图 + Generate video preview thumbnails: 生成视频预览缩略图: + Thumbnail interval: 缩略图间隔: + 10 seconds 10 秒 + Chapter markers 章节标记 + Save preview thumbnails into media folders 将预览缩略图保存到媒体文件夹 + + Store generated video preview thumbnail sets alongside media files 将生成的视频预览缩略图集合保存到媒体文件旁边 + Generate chapters for videos 为视频生成章节 + + Create chapter markers automatically for supported videos 为支持的视频自动创建章节标记 + Generated chapter interval: 生成章节间隔: + %1 minutes %1 分钟 + Generate intro video markers: 生成片头标记: + + Image fetchers: 图片抓取器: + Series image fetchers: 剧集图片抓取器: + Season image fetchers: 季图片抓取器: + Episode image fetchers: 集图片抓取器: + Subtitles 字幕 + Subtitle downloaders: 字幕下载器: + Require hash match for subtitle downloads 字幕下载要求哈希匹配 + + Only download subtitles when the provider reports a matching file hash 仅当提供方报告文件哈希匹配时才下载字幕 + Search only for forced subtitles 仅搜索强制字幕 + + Restrict automatic subtitle downloads to forced subtitle tracks 将自动字幕下载限制为强制字幕轨道 + Preferred subtitle language: 首选字幕语言: + Save subtitles into media folders 将字幕保存到媒体文件夹 + + Download subtitles and save them alongside media files 下载字幕并将其保存到媒体文件旁边 + Skip if audio track matches download language 若音轨语言与下载语言匹配则跳过 + + Uncheck to ensure all videos have subtitles regardless of audio language 取消勾选以确保所有视频都有字幕,无论音轨语言如何 + Skip if video has embedded subtitles 若视频已有内嵌字幕则跳过 + + Keeping text versions of subtitles will improve delivery efficiency and reduce likelihood of video transcoding 保留文本格式字幕可提高传输效率,并降低视频转码的可能性 + Stop downloading subtitles older than: 停止下载早于以下时间的字幕: + + No Limit 无限制 + + 14 days 14 天 + + 30 days 30 天 + + 60 days 60 天 + + 90 days 90 天 + + 120 days 120 天 + + 180 days 180 天 + Lyrics 歌词 + Lyrics fetchers: 歌词抓取器: + Preferred lyrics language: 首选歌词语言: + Stop downloading lyrics older than: 停止下载早于以下时间的歌词: + Save lyrics into media folders 将歌词保存到媒体文件夹 + + Store downloaded lyrics files alongside media files 将下载的歌词文件保存到媒体文件旁边 + File Organization 文件组织 + Ignore sample files larger than: 忽略大于以下大小的 sample 文件: + MB MB + Enable Plex .ignore compatibility 启用 Plex .ignore 兼容 + + Honor Plex .ignore files and folders while scanning 扫描时遵循 Plex .ignore 文件和文件夹规则 + Automatically group series 自动归组剧集 + + Try to group episodes and seasons into series automatically 尝试将剧集和季度自动归组到系列中 + Enable multi-part items 启用多分段条目 + + Recognize split video files as a single media item 将拆分的视频文件识别为单个媒体条目 + Multi-version item detection: 多版本条目检测: + Both files and metadata 文件和元数据 + Files only 仅文件 + Metadata only 仅元数据 + None 无 + Playback 播放 + Minimum resume percentage: 最小续播百分比: + If stopped before this point, the item will be marked as unplayed 若在此进度前停止,该条目将被标记为未播放 + Maximum resume percentage: 最大续播百分比: + If stopped after this point, the item will be marked as played 若在此进度后停止,该条目将被标记为已播放 + Minimum resume duration (seconds): 最小续播时长(秒): + s 秒 + Items shorter than this duration cannot be resumed 短于此时长的条目将无法续播 + No Media Paths 未添加媒体路径 + No media paths have been added. The library will be created without any content folders. Do you want to continue? @@ -5443,2146 +4764,6838 @@ Do you want to continue? 是否继续? + Continue 继续 - - QObject + LibraryEditDialog - Books - 图书 + + Edit Library — "%1" + 编辑媒体库 — “%1” + + + + Save + 保存 - - AddToPlaylistDialog + LibraryMetadataEditDialog - Add to Playlist - 添加到播放列表 + + Edit Metadata + 编辑元数据 - Select a playlist - 请选择一个播放列表 + + Basic Information + 基础信息 - Search playlists... - 搜索播放列表... + + Classification + 分类 - New Playlist - 新建播放列表 + + Ratings & Release + 评分与发行 + + External IDs + 外部标识 + + + + Music Metadata + 音乐元数据 + + + + Additional Details + 附加详情 + + + + Metadata Settings + 元数据设置 + + + + Field Locks + 字段锁定 + + + + Path + 路径 + + + + + Name + 名称 + + + + + Original Title + 原始标题 + + + + + Sort Name + 排序名称 + + + + + Tagline + 宣传语 + + + + + Genres + 类型 + + + + + Tags + 标签 + + + + + Studios + 工作室 + + + + Date Added + 加入日期 + + + + + Production Year + 年份 + + + + + Premiere Date + 发行日期 + + + + + End Date + 结束日期 + + + + + Status + 状态 + + + + + Runtime (Minutes) + 时长(分钟) + + + + + Official Rating + 家长分级 + + + + + Custom Rating + 自定义评分 + + + + + Community Rating + 社区评分 + + + + + Critic Rating + 影评人评分 + + + + IMDb Id + IMDb Id + + + + MovieDb Id + MovieDb Id + + + + TheTVDB Id + TheTVDB Id + + + + + Album + 专辑 + + + + + Album Artist + 专辑艺人 + + + + + Artists + 艺人 + + + + + Composers + 作曲家 + + + + + Channel Number + 频道编号 + + + + + Place of Birth + 出生地 + + + + + Parent Index Number + 父级序号 + + + + + Index Number + 序号 + + + + Preferred Metadata Language + 首选元数据语言 + + + + Metadata Country Code + 元数据国家/地区代码 + + + + + Overview + 概述 + + + + Lock metadata + 锁定元数据 + + + + + + + + Comma separated values + 多个值请用逗号分隔 + + + + + yyyy-MM-dd + yyyy-MM-dd + + + yyyy-MM-ddTHH:mm:ss + yyyy-MM-ddTHH:mm:ss + + + + yyyy-MM-dd HH:mm:ss + yyyy-MM-dd HH:mm:ss + + + + Minutes + 分钟 + + + + 0-10 + 0-10 + + + + 0-100 + 0-100 + + + + Continuing / Ended + 连载中 / 已完结 + + + + en-US + en-US + + + + US + US + + + Cancel 取消 - Add - 添加 - - - Loading... - 加载中... - - - No playlists found - 未找到任何播放列表 - - - Failed to load playlists: %1 - 加载播放列表失败:%1 - - - Create Playlist - 创建播放列表 - - - Playlist name - 播放列表名称 - - - Enter playlist name - 请输入播放列表名称 - - - Create - 创建 - - - Playlist created - 播放列表已创建 - - - Failed to create playlist: %1 - 创建播放列表失败:%1 + + Save + 保存 - - PageCollections + LibraryPage - Lists - 列表 + + Date Added + 添加日期 - Refresh - 刷新 + + Date Played + 播放日期 - Create playlist - 创建播放列表 + + Title + 标题 - Select all playlists - 全选播放列表 + + Runtime + 时长 - Clear playlist selection - 清空播放列表选择 + + Premiere Date + 首播日期 - Delete selected playlists - 删除已选播放列表 - - - New Playlist - 新建播放列表 - - - Select All - 全选 - - - Clear Selection - 清空选择 - - - Delete Selected - 删除已选 + + Recent + 最近 + Playlists 播放列表 - No items found - 未找到任何项目 + + Collections + 合集 - No playlists found - 未找到任何播放列表 + + Favorites + 收藏 + + + + Folders + 文件夹 + + + + ♥ Favorite + ♥ 已收藏 + + + + ♡ Favorite + ♡ 收藏 + + + + Refresh + 刷新 + + + + Descending + 降序 + + + + Ascending + 升序 + + + + Poster + 海报 + + + + Tile + 平铺 + + + + List + 列表 + + + + + + This library is empty + 此媒体库为空 + + + + + + Try another filter or refresh the library. + 请尝试其他筛选或刷新媒体库。 + + + + Loading more… + 加载更多… + + + + Load more + 加载更多 + + + + LibraryView + + All + 全部 + + + Movies + 电影 + + + Shows + 剧集 + + + Music + 音乐 + + + Videos + 视频 + + + Recent + 最近添加 + + + Playlists + 播放列表 + + + Lists + 列表 + + + Collections + 合集 + + + Favorites + 我的最爱 + + + Folders + 文件夹 + + + Title + 标题 + + + Date Added + 添加日期 + + + Date Played + 播放日期 + + + Runtime + 播放时长 + + + Premiere Date + 首映日期 + + + 0 Items + 0 个项目 Loading... - 加载中... + 正在加载... - No collections found - 未找到任何合集 - - - %n playlist(s) - - %n 个播放列表 - + %1 Items + %1 个项目 - %1 · %2 - %1 · %2 - - - %n selected - - 已选 %n 项 - - - - %n collection(s) - - %n 个合集 - + %1 / %2 Items + %1 / %2 个项目 + Toggle View Mode + 切换视图模式 + + + Error Loading Items + 加载项目失败 + + + + LibraryViewModel + + + 0 Items + 0 项 + + + + + + All + 全部 + + + + Movies + 电影 + + + + Shows + 剧集 + + + + Music + 音乐 + + + + Videos + 视频 + + + + %1 / %2 Items + %1 / %2 项 + + + + %1 Items + %1 项 + + + + + Loading... + 加载中… + + + + Unable to load library items + 无法加载媒体库项目 + + + + Error Loading Items + 加载项目出错 + + + + LicensesDialog + + + Open Source Licenses + 开源许可证 + + + + LoadingState + + + Loading + 加载中 + + + + LoginPage + + + Connect to Server + 连接服务器 + + + + Select Server + 选择服务器 + + + + Enter your server details to continue + 输入服务器信息以继续 + + + + Choose where you want to watch + 选择要观看的服务器 + + + + + Add New Server + + 添加新服务器 + + + + Edit + 编辑 + + + + Remove + 移除 + + + + Server Address (example.com/emby) + 服务器地址(example.com/emby) + + + + Port + 端口 + + + + Proxy… + 代理… + + + + Ignore SSL certificate verification + 忽略 SSL 证书验证 + + + + Username + 用户名 + + + + Password + 密码 + + + + Hide + 隐藏 + + + + Show + 显示 + + + + Cancel + 取消 + + + + Connecting… + 连接中… + + + + Save & Login + 保存并登录 + + + + Login + 登录 + + + + Cloud Sync + 云同步 + + + + LoginView + + Login + 登录 + + + Select Server + 选择服务器 + + + Connect to Server + 连接到服务器 + + + Server Address (e.g. 192.168.1.1 or domain) + 服务器地址 (例如 192.168.1.1 或域名) + + + Server URL (e.g. example.com, example.com/emby or https://example.com) + 服务器 URL(例如 example.com、example.com/emby 或 https://example.com) + + + Server URL + 服务器 URL + + + Server Address + 服务器地址 + + + Server Address (e.g. example.com/emby or 192.168.1.1/jellyfin) + 服务器地址(例如 example.com/emby 或 192.168.1.1/jellyfin) + + + Port (e.g. 8096) + 端口 (例如 8096) + + + Port + 端口 + + + Ignore SSL certificate verification + 忽略 SSL 证书验证 + + + Proxy settings + 代理设置 + + + Server Proxy + 服务器代理 + + + Configure... + 配置... + + + Using global proxy + 使用全局代理 + + + No proxy + 不使用代理 + + + System proxy + 使用系统代理 + + + Custom (incomplete) + 自定义代理(信息不完整) + + + Server Proxy — %1 + 服务器代理 — %1 + + + Use this only for trusted self-signed or private CA HTTPS servers. + 仅对可信任的自签名或私有 CA HTTPS 服务器使用此选项。 + + + Only for trusted self-signed or private CA servers. + 仅用于可信自签名或私有 CA 服务器。 + + + Username + 用户名 + + + Password + 密码 + + + Show Password + 显示密码 + + + Cancel + 取消 + + + Edit + 编辑 + + + Delete + 删除 + + + Add New Server + 添加新服务器 + + + Add New Server + 添加新服务器 + + + Connect to another server + 连接到其他服务器 + + + Save & Login + 保存并登录 + + + Remove Server + 移除服务器 + + + Are you sure you want to remove this server? +This action cannot be undone. + 您确定要移除此服务器吗? +此操作无法撤销。 + + + Remove + 移除 + + + Server address and username cannot be empty. + 服务器地址和用户名不能为空。 + + + Server URL and username cannot be empty. + 服务器 URL 和用户名不能为空。 + + + Please enter a valid server URL. + 请输入有效的服务器 URL。 + + + Please enter a valid server address. + 请输入有效的服务器地址。 + + + Please enter a valid port number. + 请输入有效的端口号。 + + + Server URL must use http:// or https://. + 服务器 URL 必须使用 http:// 或 https://。 + + + Connecting... + 正在连接... + + + Connection Failed + 连接失败 + + + Failed to connect or session expired. Please log in again. +Error: + 连接失败或会话已过期。请重新登录。 +错误: + + + Cloud Sync (WebDAV) + 云同步(WebDAV) + + + + qEmby + qEmby + + + + LoginViewModel + + + Remove Server + 移除服务器 + + + + Are you sure you want to remove this server? +This action cannot be undone. + 确定要移除此服务器吗? +此操作无法撤销。 + + + + Remove + 移除 + + + + Cancel + 取消 + + + + Please enter a valid server address. + 请输入有效的服务器地址。 + + + + Please enter a valid HTTP or HTTPS server address. + 请输入有效的 HTTP 或 HTTPS 服务器地址。 + + + + Please enter a valid port number. + 请输入有效的端口号。 + + + + Failed to connect or session expired. Please log in again. +Error: %1 + 连接失败或会话已过期。请重新登录。 +错误:%1 + + + + Server address and username cannot be empty. + 服务器地址和用户名不能为空。 + + + + Using global proxy + 使用全局代理 + + + + No proxy + 无代理 + + + + System proxy + 系统代理 + + + + Custom (incomplete) + 自定义(未完成) + + + + LogsViewModel + + + Choose a log file from the list to inspect the latest server output. + 从列表中选择日志文件以查看最新服务器输出。 + + + + Modified %1 + 修改于 %1 + + + + Save Log File + 保存日志文件 + + + + Text Files (*.txt);;All Files (*) + 文本文件 (*.txt);;所有文件 (*) + + + + No files + 没有文件 + + + + %n file(s) + %n 个文件 + + + Failed to load log files: %1 + 加载日志文件失败:%1 + + + Failed to load: %1 加载失败:%1 + + + Log file "%1" saved + 日志文件“%1”已保存 + + + + Failed to save log file: %1 + 保存日志文件失败:%1 + + + + MainWindow + + + Press Back again to logout + 再次点击返回键退出登录 + + + + Refreshing Home... + 正在刷新主页... + + + + Refreshing Favorites... + 正在刷新收藏... + + + + Search... + 搜索... + + + + Search + 搜索 + + + + ManageDashboardPage + + + Dashboard + 仪表盘 + + + + Server Information + 服务器信息 + + + + Server Name + 服务器名称 + + + + Version + 版本 + + + + Operating System + 操作系统 + + + + Address + 地址 + + + + Now Playing + 正在播放 + + + + Nothing is playing + 当前没有播放内容 + + + + Active Sessions + 活动会话 + + + + No active sessions + 没有活动会话 + + + + Activity Log + 活动日志 + + + + No activity records + 没有活动记录 + + + + ManagePage + + + Management + 管理 + + + + Dashboard + 仪表盘 + + + + Libraries + 媒体库 + + + + Lists + 列表 + + + + Transcoding + 转码 + + + + Users + 用户 + + + + Tasks + 任务 + + + + Logs + 日志 + + + + ManageRouteController + + + Select Folder + 选择文件夹 + + + + Select File + 选择文件 + + + + ManageView + + + Management + 管理 + + + + Dashboard + 仪表盘 + + + + Libraries + 媒体库 + + + + Lists + 列表 + + + + Users + 用户 + + + + Tasks + 任务 + + + + Logs + 日志 + + + + Transcoding + 转码 + + + Lists + 列表 + + + Transcoding + 转码 + + + Users + 用户 + + + Tasks + 计划任务 + + + Logs + 日志 + + + + MediaActionMenu + + + Play + 播放 + + + + Play with %1 + 使用 %1 播放 + + + + View Details + 查看详情 + + + + Download + 下载 + + + + Add to Playlist + 添加到播放列表 + + + + Remove from Playlist + 从播放列表中移除 + + + + Add to Favorites + 添加到收藏 + + + + Remove from Favorites + 取消收藏 + + + + Mark as Played + 标记为已播放 + + + + Mark as Unplayed + 标记为未播放 + + + + Remove from Continue Watching + 从继续观看中移除 + + + + Edit Metadata + 编辑元数据 + + + + Edit Images + 编辑图像 + + + + Delete Playlist + 删除播放列表 + + + + Identify + 识别 + + + + Refresh Metadata + 刷新元数据 + + + + Remove Media + 移除媒体 + + + + MediaCard + + + Media + 媒体 + + + + Play %1 + 播放 %1 + + + + Remove %1 from favorites + 将 %1 从收藏中移除 + + + + Add %1 to favorites + 将 %1 加入收藏 + + + + More actions for %1 + %1 的更多操作 + + + + MediaCardDelegate + + + + No Image + 无图片 + + + + %1 min + %1 分钟 + + + + MediaGrid + + + No media + 没有媒体 + + + + Loading media… + 正在加载媒体… + + + + Unable to load media + 无法加载媒体 + + + + MediaIdentifyDialog + + + Unknown Title + 未知标题 + + + + Provider IDs: %1 + 提供方 ID:%1 + + + + Album Artist: %1 + 专辑艺术家:%1 + + + + Artists: %1 + 艺术家:%1 + + + + Identify + 识别 + + + + this item + 此项目 + + + + Search remote metadata matches for "%1" and apply the correct result. + 为“%1”搜索远程元数据候选,并应用正确的匹配结果。 + + + + Enter title or keyword + 输入标题或关键词 + + + + IMDb Id (optional) + IMDb Id(可选) + + + + MovieDb Id (optional) + MovieDb Id(可选) + + + + TheTVDB Id (optional) + TheTVDB Id(可选) + + + + Any + 任意 + + + + Search + 搜索 + + + + + Enter a title or provider ID to search. + 请输入标题或提供方 ID 后开始搜索。 + + + + Cancel + 取消 + + + + Apply Match + 应用匹配 + + + + + + + + + + Preview unavailable + 暂无预览 + + + + + Loading preview... + 正在加载预览... + + + + Searching... + 正在搜索... + + + + No matches found + 未找到匹配结果 + + + + Found %1 matches + 找到 %1 个匹配结果 + + + + Search failed + 搜索失败 + + + + Failed to search metadata: %1 + 搜索元数据失败:%1 + + + + Applying match... + 正在应用匹配... + + + + Metadata updated + 元数据已更新 + + + + Apply failed + 应用失败 + + + + Failed to apply match: %1 + 应用匹配失败:%1 + + + + MediaImageEditDialog + + + Primary + 主图 + + + + Backdrop + 背景图 + + + + Banner + 横幅图 + + + + Thumb + 缩略图 + + + + Logo + Logo + + + + Art + 艺术图 + + + + Disc + 唱片图 + + + + Box + 盒封 + + + + Box Rear + 盒封背面 + + + + Menu + 菜单图 + + + + Screenshot + 截图 + + + + Chapter + 章节图 + + + + Profile + 资料图 + + + + No image + 暂无图像 + + + + %1 image(s) available + %1 张图片可用 + + + + %1 %2 + %1 %2 + + + + %1 image(s) + %1 张图片 + + + + %1 × %2 + %1 × %2 + + + + Index %1 + 索引 %1 + + + + Image Files (%1);;All Files (*) + 图片文件 (%1);;所有文件 (*) + + + + Edit Images + 编辑图像 + + + + + + this item + 此项目 + + + + Manage image types for "%1". Changes are applied immediately. + 管理“%1”的各类图像,修改会立即生效。 + + + + + Loading image information... + 正在加载图像信息... + + + + Image Types + 图像类型 + + + + Drop an image file or image URL here to replace it. + 将图片文件或图片地址拖到这里即可替换。 + + + + Choose Image + 选择图像 + + + + Image URL + 图片地址 + + + + Download + 下载 + + + Close + 关闭 + + + + + Remove Image + 移除图像 + + + Upload Image + 上传图像 + + + + + + + + Preview unavailable + 暂无预览 + + + + Loaded images for %1 type(s) + 已加载 %1 种图像类型 + + + + No images found yet + 暂未找到图像 + + + + Failed to load images + 加载图像失败 + + + + Failed to load images: %1 + 加载图像失败:%1 + + + + No image for this type + 该类型暂无图像 + + + + + Loading preview... + 正在加载预览... + + + + Select Image + 选择图像 + + + + Enter Image URL + 输入图片地址 + + + + Paste an image address for the %1 image of "%2". + 请为“%2”的 %1 图像粘贴一个图片地址。 + + + + https://example.com/image.jpg + https://example.com/image.jpg + + + + Save Image + 保存图像 + + + + + Downloading image... + 正在下载图像... + + + + + Download failed + 下载失败 + + + + + Failed to download image: %1 + 下载图像失败:%1 + + + + Unknown error + 未知错误 + + + + Save failed + 保存失败 + + + + Failed to save image: %1 + 保存图像失败:%1 + + + + + Image downloaded + 图像已下载 + + + + Invalid image URL + 图片地址无效 + + + + Dropped data does not contain a supported image + 拖入的内容不包含受支持的图像 + + + + + + + Failed to read image file + 读取图像文件失败 + + + + Uploading image... + 正在上传图像... + + + + Image updated + 图像已更新 + + + + Upload failed + 上传失败 + + + + Failed to upload image: %1 + 上传图像失败:%1 + + + + Are you sure you want to remove the %1 image from "%2"? + 确定要从“%2”中移除 %1 图像吗? + + + + Remove + 移除 + + + + Cancel + 取消 + + + + Removing image... + 正在移除图像... + + + + Image removed + 图像已移除 + + + + Remove failed + 移除失败 + + + + Failed to remove image: %1 + 移除图像失败:%1 + + + No image uploaded for this type. + 该类型尚未上传图像。 + + + + No image uploaded for this type. +Choose a file, paste an image URL, or drag one onto the preview. + 该类型尚未上传图像。 +请选择文件、粘贴图片地址,或直接拖到预览图上。 + + + + Replace Image + 替换图像 + + + + MediaItemUtils + + + %1 - %2 - %3 + %1 - %2 - %3 + + + + + %1 - %2 + %1 - %2 + + + + MediaList + + + No media + 没有媒体 + + + + Loading media… + 正在加载媒体… + + + + Unable to load media + 无法加载媒体 + + + + MediaListModel + + + Season %1 + 第 %1 季 + + + + %1 min + %1 分钟 + + + + %1 Episodes + %1 集 + + + + MediaSection + + + See all + 查看全部 + + + + MediaService + + Invalid image URL + 图片地址无效 + + + Downloaded data is not a valid image + 下载的数据不是有效的图像 + + + + ModernMessageBox + + + Cancel + 取消 + + + + ModernTagInput + + + Type & Enter... + 输入并回车… + + + + Clear all + 全部清除 + + + + Presets + 预设 + + + + Search... + 搜索… + + + + MpvController + + + Failed to create MPV instance. + 创建 MPV 实例失败。 + + + + Failed to initialize MPV context. + 初始化 MPV 上下文失败。 + + + + MpvSpikeHarness + + + MpvItem spike + MpvItem 验证 + + + + MpvSpikePage + + + Error: + 错误: + + + + position: %1 / %2 paused: %3 + 进度:%1 / %2 暂停:%3 + + + + Load + 加载 + + + + Play + 播放 + + + + Pause + 暂停 + + + + Seek +30s + 快进 +30 秒 + + + + Stop + 停止 + + + + file path or URL + 文件路径或 URL + + + + MpvWidget + + + + 当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。 + 当前环境缺乏必需的 OpenGL 硬件加速支持,视频渲染已禁用。 + + + + OpenGL rendering initialization failed. + OpenGL 渲染初始化失败。 + + + + NetworkManager + + TLS/SSL 证书错误: %1 + TLS/SSL 证书错误: %1 + + + 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 + 如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL 证书验证”。 + + + + NowPlayingCard + + + Direct Play + 直接播放 + + + + Direct Stream + 直接串流 + + + + Transcode + 转码 + + + + OverviewDialog + + + 作品简介 + 作品简介 + + + + PageAbout + + + About + 关于 + + + + qEmby + qEmby + + + + Version %1 + 版本 %1 + + + + © 2026 AlanHJ. All rights reserved. + © 2026 AlanHJ. 保留所有权利。 + + + + GitHub Repository + GitHub 仓库 + + + + Report an Issue + 报告问题 + + + + This project is open-source software built with Qt 6 and modern C++. +Special thanks to the Emby and Jellyfin communities for their fantastic APIs. + 本项目是基于 Qt 6 与现代 C++ 的开源软件。 +特别感谢 Emby 与 Jellyfin 社区提供的优秀 API。 + + + + Open Source Licenses + 开源许可证 + + + + Acknowledgements + 致谢 + + + + People who help qEmby reach more users. + 帮助 qEmby 触达更多用户的贡献者。 + + + + French translation + 法语翻译 + + + + PageAppearance + + + Appearance + 外观 + + + + System Default + 跟随系统 + + + + Light Mode + 浅色模式 + + + + Dark Mode + 深色模式 + + + + Theme Mode + 主题模式 + + + + Switch application theme (takes effect immediately) + 切换应用主题(立即生效) + + + UI Animations + 界面动画 + + + Enable smooth page transitions and hover effects + 启用流畅的页面过渡和悬浮效果 + + + + Small + 小 + + + + Medium + 中 + + + + Large + 大 + + + + Font Size + 字体大小 + + + + Adjust the overall font size of the interface + 调整界面整体字体大小 + + + + Left + 左侧 + + + + Right + 右侧 + + + + Sidebar Position + 侧边栏位置 + + + + Place the navigation sidebar on the left or right side + 将导航侧边栏放置在左侧或右侧 + + + + Pin Sidebar + 固定侧边栏 + + + + Keep the sidebar always visible and integrated into the main layout + 始终显示侧边栏并将其融入主界面布局 + + + + Custom Sidebar + 自定义侧边栏 + + + + Customize which items appear in the navigation sidebar + 自定义导航侧边栏中显示的项目 + + + + Hide Search Bar + 隐藏搜索栏 + + + + Hide Home Item + 隐藏主页项 + + + + Hide Favorites Item + 隐藏收藏项 + + + + Normal Window + 正常窗口 + + + + Maximized + 最大化 + + + + Startup Window State + 启动时窗口状态 + + + + Choose window state when the application launches + 选择应用启动时的窗口状态 + + + + Reduce Animations + 降低动画效果 + + + + Reduce page transitions and hover effects for better performance + 减少页面过渡和悬停动画以提升性能 + + + + Snapshot Navigation + 截图导航模式 + + + + Use static screenshots during page transitions for smoother animation on complex views + 页面切换时使用静态截图替代实时渲染,提升复杂视图的过渡流畅度 + + + + Enable Search History + 启用搜索记录 + + + + Save recent searches locally for quick reuse + 在本地保存最近搜索,便于快速复用 + + + + Enable Search Autocomplete + 启用搜索补全 + + + + + Clear Search History + 清理搜索记录 + + + + Clear All + 全部清理 + + + + Are you sure you want to clear search history for all servers? + +This action cannot be undone. + 确定要清理所有服务器的搜索记录吗? + +此操作无法撤销。 + + + + Clear + 清理 + + + + Search history cleared + 搜索记录已清理 + + + + Cancel + 取消 + + + Save recent searches locally and offer autocomplete in search boxes + 在本地保存最近搜索,并在搜索框中提供自动补全 + + + + PageCollections + + Lists + 列表 + + + Refresh + 刷新 + + + Create playlist + 创建播放列表 + + + Select all playlists + 全选播放列表 + + + Clear playlist selection + 清空播放列表选择 + + + Delete selected playlists + 删除已选播放列表 + + + New Playlist + 新建播放列表 + + + Select All + 全选 + + + Clear Selection + 清空选择 + + + Delete Selected + 删除已选 + + + Playlists + 播放列表 + + + No items found + 未找到任何项目 + + + No playlists found + 未找到任何播放列表 + + + Loading... + 加载中... + + + No collections found + 未找到任何合集 + + + %n playlist(s) + %n 个播放列表 + + %1 · %2 + %1 · %2 + + + %n selected + 已选 %n 项 + + %n collection(s) + %n 个合集 + + Failed to load: %1 + 加载失败:%1 + Create Playlist - 创建播放列表 + 创建播放列表 Playlist name - 播放列表名称 + 播放列表名称 Enter playlist name - 请输入播放列表名称 + 请输入播放列表名称 Create - 创建 + 创建 Playlist created - 播放列表已创建 + 播放列表已创建 Failed to create playlist: %1 - 创建播放列表失败:%1 + 创建播放列表失败:%1 Rename - 重命名 + 重命名 Rename Playlist - 重命名播放列表 + 重命名播放列表 New name: - 新名称: + 新名称: Save - 保存 + 保存 Renamed successfully - 重命名成功 + 重命名成功 Rename failed: %1 - 重命名失败:%1 + 重命名失败:%1 Delete - 删除 + 删除 Delete Playlist - 删除播放列表 + 删除播放列表 Are you sure you want to delete %n selected playlist(s)? This action cannot be undone. - - 确定要删除 %n 个已选播放列表吗? + 确定要删除 %n 个已选播放列表吗? -此操作不可撤销。 - - +此操作不可撤销。 - Are you sure you want to delete playlist "%1"? + Are you sure you want to delete playlist "%1"? This action cannot be undone. - 确定要删除播放列表“%1”吗? + 确定要删除播放列表“%1”吗? 此操作不可撤销。 Cancel - 取消 + 取消 - "%1" deleted - "%1"已删除 + "%1" deleted + "%1"已删除 Delete failed: %1 - 删除失败:%1 + 删除失败:%1 Deleted %1 playlist(s), then failed: %2 - 已删除 %1 个播放列表,随后失败:%2 + 已删除 %1 个播放列表,随后失败:%2 Failed to delete selected playlists: %1 - 删除已选播放列表失败:%1 + 删除已选播放列表失败:%1 Playlist order updated - 播放列表排序已更新 + 播放列表排序已更新 Failed to save playlist order: %1 - 保存播放列表排序失败:%1 + 保存播放列表排序失败:%1 Deleted %n playlist(s) - - 已删除 %n 个播放列表 - - + 已删除 %n 个播放列表 - - CollectionCard + PageDashboard - Rename - 重命名 + + Dashboard + 仪表盘 - Delete - 删除 - - - Playlist - 播放列表 - - - Collection - 合集 - - - item - 个项目 - - - items - 个项目 - - - - - TextInputDialog - - Cancel - 取消 - - - OK - 确定 - - - - - - PageUsers - - Users - 用户 + + Server Information + 服务器信息 + Refresh 刷新 - Add user - 添加用户 + + + Restart Server + 重启服务器 - No users found - 未找到任何用户 + + + Shutdown Server + 关闭服务器 - No users - 无用户 - - - %n user(s) - - %n 位用户 - - - - Admin - 管理员 - - - Disabled - 已禁用 - - - Last login: %1 - 上次登录:%1 - - - Last active: %1 - 上次活跃:%1 - - - Enable user - 启用用户 - - - Disable user - 禁用用户 - - - Cannot disable yourself - 不能禁用自己 - - - User "%1" disabled - 用户"%1"已禁用 - - - User "%1" enabled - 用户"%1"已启用 - - - Failed to load users: %1 - 加载用户失败:%1 - - - Failed to update user: %1 - 更新用户失败:%1 - - - - - AddUserDialog - - Add User - 添加用户 - - - Name - 名称 - - - This will be the name displayed for the user on the server. - 这将作为用户在服务器上的显示名称。 - - - Enter user name - 输入用户名 - - - Copy from user - 从用户复制 + + Server Name + 服务器名称 - Do not copy - 不复制 + + Version + 版本 - Optionally copy settings from an existing user. - 可选地从现有用户复制设置。 + + Operating System + 操作系统 - Allow user to manage server - 允许用户管理服务器 + Architecture + 架构 - Copy Options - 复制选项 - - - User policy - 用户策略 - - - User configuration - 用户配置 - - - Choose which settings to copy from the selected user. - 选择要从所选用户复制的设置。 - - - Library Access - 媒体库访问 - - - Allow access to all libraries - 允许访问所有媒体库 - - - Choose which libraries this user can access. - 选择该用户可以访问的媒体库。 - - - Channel Access - 频道访问 - - - Allow access to all channels - 允许访问所有频道 - - - Choose which channels this user can access. - 选择该用户可以访问的频道。 - - - Create - 创建 - - - Cancel - 取消 - - - User name cannot be empty - 用户名不能为空 - - - User "%1" created - 用户“%1”已创建 - - - User "%1" created, but failed to apply permissions: %2 - 用户“%1”已创建,但应用权限失败:%2 - - - Failed to create user: %1 - 创建用户失败:%1 - - - - - PageTasks - - Tasks - 任务 - - - Refresh - 刷新 - - - Loading... - 加载中... - - - Refresh failed - 刷新失败 - - - All Tasks - 全部任务 - - - No tasks found - 未找到任何任务 - - - No tasks - 无任务 - - - %n task(s) - - %n 项任务 - - - - %n running - - %n 项运行中 - - - - %n system - - %n 项系统任务 - - - - %1 · %2 - %1 · %2 - - - Other - 其他 - - - Scheduled - 已计划 - - - Manual - 手动 - - - System - 系统 - - - Running Now - 正在运行 - - - Tasks that are currently executing. Progress refreshes automatically. - 当前正在执行的任务,进度会自动刷新。 - - - Scheduled Tasks - 计划任务 - - - Tasks with one or more configured triggers. - 已配置一个或多个触发器的任务。 - - - Manual Tasks - 手动任务 - - - Tasks that only run when started manually. - 仅在手动启动时运行的任务。 - - - System Tasks - 系统任务 - - - Hidden or built-in tasks reported by the server. - 服务端返回的隐藏或内建任务。 - - - Details - 详情 - - - Schedule: %1 - 计划:%1 - - - State: %1 - 状态:%1 - - - Last result: %1 - 上次结果:%1 - - - %1 · %2% - %1 · %2% - - - Run task - 运行任务 - - - Stop task - 停止任务 - - - Last: %1 - 上次:%1 - - - %1 — %2% - %1 — %2% - - - Idle - 空闲 - - - Running - 运行中 - - - Cancelling - 正在取消 - - - Completed - 已完成 - - - Failed - 失败 - - - Aborted - 已中止 - - - Cancelled - 已取消 - - - Task "%1" started - 任务"%1"已启动 - - - Task "%1" stopped - 任务"%1"已停止 - - - Failed to load tasks: %1 - 加载任务失败:%1 - - - Failed to run task: %1 - 运行任务失败:%1 - - - Failed to stop task: %1 - 停止任务失败:%1 - - - - - PageLogs - - Logs - 日志 - - - Refresh - 刷新 - - - Log Files - 日志文件 - - - Newest files appear first. - 较新的日志文件会优先显示。 - - - Log Viewer - 日志查看器 - - - Select a log file to inspect its contents - 选择一个日志文件以查看其内容 - - - No log files found - 未找到任何日志文件 - - - All Files - 全部文件 - - - Total Size - 总大小 - - - Latest Update - 最近更新 - - - Selected Size - 当前大小 - - - No files - 无文件 - - - %n file(s) - - %n 个文件 - + + Address + 地址 - Loading... - 加载中... + + Now Playing + 正在播放 - No log file selected - 未选择日志文件 + + Nothing is playing + 当前没有正在播放的内容 - Choose a log file from the list to inspect the latest server output. - 从左侧列表中选择一个日志文件,以查看最新的服务器输出。 + + Active Sessions + 活跃会话 - Modified %1 - 修改于 %1 + + + No active sessions + 暂无活跃会话 - Refresh failed - 刷新失败 + + Activity Log + 活动日志 - Failed to load log files: %1 - 加载日志文件失败:%1 + + No activity records + 暂无活动记录 + Failed to load: %1 加载失败:%1 - Save Log File - 保存日志文件 + + Are you sure you want to restart the server? All active sessions will be terminated. + 确定要重启服务器吗?所有活跃的会话将被终止。 - Text Files (*.txt);;All Files (*) - 文本文件 (*.txt);;所有文件 (*) - - - Log file "%1" saved - 日志文件“%1”已保存 - - - Failed to save log file: %1 - 保存日志文件失败:%1 - - - - - - UserEditDialog - - User Settings - 用户设置 - - - User Settings — %1 - 用户设置 — %1 - - - Name - 名称 - - - This is how the user will appear on your server. - 这就是用户将出现在您的服务器上的方式。 - - - Enter user name - 输入用户名 - - - Administrator - 管理员 - - - Allow remote access - 允许远程访问 - - - Playback - 播放 - - - Allow media playback - 允许媒体回放 - - - Allow audio transcoding - 允许音频转码 - - - Allow video transcoding - 允许视频转码 - - - Features - 功能 - - - Live TV access - 电视直播访问 - - - Live TV recording management - 电视直播录制管理 - - - Allow media deletion - 允许删除媒体 - - - Remote control of other users - 远程控制其他用户 - - - Shared device remote control - 共享设备远程控制 - - - Allow media downloading - 允许媒体下载 - - - Visibility - 可见性 - - - Hide from login screen - 在登录界面隐藏 - - - Hide from login screen (remote) - 在远程登录界面隐藏 - - - Hide from unused devices - 在未使用设备上隐藏 - - - Save - 保存 - - - Profile - 配置 - - - Allow access to all libraries - 允许访问所有媒体库 - - - Access - 访问 - - - Maximum parental rating - 最大家长分级 - - - None - 无限制 - - - Parental Control - 家长控制 - - - New password - 新密码 - - - Enter new password - 输入新密码 - - - Confirm password - 确认密码 - - - Re-enter new password - 再次输入新密码 - - - Save Password - 保存密码 - - - Password - 密码 - - - Failed to load user data: %1 - 加载用户数据失败:%1 - - - Settings saved - 设置已保存 - - - Failed to save: %1 - 保存失败:%1 - - - User name cannot be empty - 用户名不能为空 - - - Passwords do not match - 两次输入的密码不一致 - - - Password updated - 密码已更新 - - - Failed to update password: %1 - 更新密码失败:%1 + + Restart + 重启 + + Cancel 取消 - Emby Connect email - Emby Connect 邮箱 + + Server restart command sent + 已发送服务器重启命令 - Optional connect email - 可选的 Connect 邮箱 + + Restart failed: %1 + 重启失败:%1 - Link this user to an Emby Connect account by email. - 通过邮箱将此用户关联到 Emby Connect 账户。 + + Are you sure you want to shut down the server? This will stop all services and disconnect all clients. + 确定要关闭服务器吗?这将停止所有服务并断开所有客户端的连接。 - Disable user - 禁用用户 + + Shutdown + 关机 - Allow changing image and password - 允许更改头像和密码 + + Server shutdown command sent + 已发送服务器关机命令 - Authentication provider - 身份验证提供程序 - - - Allow video remuxing - 允许视频转封装 - - - Simultaneous stream limit - 同时流数量限制 - - - Unlimited - 无限制 - - - Remote client bitrate limit - 远程客户端码率限制 - - - Mbps - Mbps - - - Feature Access - 功能访问 - - - Allow media conversion - 允许媒体转换 - - - Allow link sharing - 允许链接分享 - - - Downloads - 下载 - - - Allow sync transcoding - 允许同步转码 - - - Subtitles - 字幕 - - - Allow subtitle downloading - 允许下载字幕 - - - Allow subtitle management - 允许管理字幕 - - - Media Deletion - 媒体删除 - - - Allow media deletion from all libraries - 允许从所有媒体库删除媒体 - - - Channel Access - 频道访问 - - - Allow access to all channels - 允许访问所有频道 - - - Device Access - 设备访问 - - - Allow access on all devices - 允许在所有设备上访问 - - - Block unrated items - 屏蔽未分级项目 - - - Books - 图书 - - - Games - 游戏 - - - Channel content - 频道内容 - - - Live TV - 电视直播 - - - Movies - 电影 - - - Music - 音乐 - - - Trailers - 预告片 - - - TV shows - 电视剧 - - - Tag Restrictions - 标签限制 - - - Blocked tags - 已屏蔽标签 - - - Tag restriction mode - 标签限制模式 - - - Block items with these tags - 屏蔽含这些标签的项目 - - - Allow items with these tags - 仅允许含这些标签的项目 - - - Restriction match mode - 限制匹配模式 - - - Match all restrictions - 匹配全部限制 - - - Match any restriction - 匹配任一限制 - - - Access Schedule - 访问计划 - - - Limit when this user can access the server. - 限制该用户可以访问服务器的时间段。 - - - Add - 添加 - - - Remove - 移除 - - - Sunday - 星期日 - - - Monday - 星期一 - - - Tuesday - 星期二 - - - Wednesday - 星期三 - - - Thursday - 星期四 - - - Friday - 星期五 - - - Saturday - 星期六 - - - Schedule start time must be earlier than end time - 访问计划开始时间必须早于结束时间 + + Shutdown failed: %1 + 关机失败:%1 - - - - PageUsers + PageGeneral - Delete user - 删除用户 + + General + 通用 - Cannot delete yourself - 无法删除自己 + + System + 跟随系统 - Delete User - 删除用户 + + 简体中文 + 简体中文 - Are you sure you want to delete user "%1"? This action cannot be undone. - 确定要删除用户 "%1" 吗?此操作无法撤销。 + + English + 英文 + + + + French + 法语 + + + + Language + 语言 + + + + App interface language (requires restart) + 应用界面语言(需要重启生效) + + + + Remember Last Server + 记住上次服务器 + + + + Automatically connect to the last used server on startup + 启动时自动连接到上次使用的服务器 + + + + Close to Tray + 关闭到托盘 + + + + Minimize to system tray instead of exiting the application + 最小化到系统托盘而非退出应用 + + + + Enable Logging + 启用日志 + + + + Record debug information to a log file for troubleshooting + 将调试信息记录到日志文件,便于排查问题 + + + + Log path: %1 + 日志路径: %1 + + + + Size: %1 + 大小: %1 + + + + Open Log + 打开日志 + + + + + Clear Log + 清理日志 + + + + Are you sure you want to clear all log files? + +This will remove the current log and rotated backups. This action cannot be undone. + 确定要清理所有日志文件吗? + +这会删除当前日志和所有轮转备份。此操作无法撤销。 + + + + Clear + 清理 + + + + Cancel + 取消 + + + + Log files cleared + 日志文件已清理 + + + + Open Location + 打开所在位置 + + + + Configure... + 配置... + + + + Network Proxy + 网络代理 + + + + Proxy for the current server (global or server-specific) + 当前服务器代理(全局或服务器专用) + + + + no proxy + 不使用代理 + + + + system proxy + 使用系统代理 + + + + custom (incomplete) + 自定义代理(信息不完整) + + + + %1 %2:%3 + %1 %2:%3 + + + + Proxy for the current server — using global: %1 + 当前服务器代理 — 使用全局:%1 + + + + Proxy for the current server — currently: %1 + 当前服务器代理 — 当前:%1 + + + + Default proxy for all servers — currently: %1 + 所有服务器的默认代理 — 当前:%1 + + + Default proxy for all servers (per-server settings override this) + 所有服务器的默认代理(服务器级设置可覆盖) + + + Default proxy for all servers (per-server settings override this) — currently: no proxy + 所有服务器的默认代理(服务器级设置可覆盖) — 当前:不使用代理 + + + Default proxy for all servers (per-server settings override this) — currently: system proxy + 所有服务器的默认代理(服务器级设置可覆盖) — 当前:使用系统代理 + + + Default proxy for all servers (per-server settings override this) — currently: custom (incomplete) + 所有服务器的默认代理(服务器级设置可覆盖) — 当前:自定义代理(信息不完整) + + + Default proxy for all servers (per-server settings override this) — currently: %1 %2:%3 + 所有服务器的默认代理(服务器级设置可覆盖) — 当前:%1 %2:%3 + + + + PageLibraries + + Libraries + 媒体库 + + + Refresh library list + 刷新媒体库列表 + + + Scan all libraries + 扫描全部媒体库 + + + Create new library + 创建新媒体库 + + + Cancel scan + 取消扫描 + + + No media libraries found + 未找到任何媒体库 + + + Loading... + 加载中... + + + 0 libraries + 0 个媒体库 + + + %n library(s) + %n 个媒体库 + + Library created successfully + 媒体库创建成功 + + + Failed to create library: %1 + 创建媒体库失败:%1 + + + Library updated successfully + 媒体库更新成功 + + + Failed to update library: %1 + 更新媒体库失败:%1 + + + Metadata saved + 元数据已保存 + + + Failed to update metadata: %1 + 更新元数据失败:%1 + + + Delete Library + 删除媒体库 + + + Are you sure you want to delete the library "%1"? + +This will remove the library from the server, but will not delete the actual media files. + 确定要删除媒体库"%1"吗? + +这将从服务器移除该媒体库,但不会删除实际的媒体文件。 Delete - 删除 + 删除 Cancel - 取消 + 取消 - User "%1" deleted - 用户 "%1" 已删除 + Library "%1" deleted + 媒体库"%1"已删除 - Failed to delete user: %1 - 删除用户失败:%1 + Failed to delete library: %1 + 删除媒体库失败:%1 - + + Scanning "%1"... + 正在扫描"%1"... + + + Refreshing metadata for "%1"... + 正在刷新“%1”的元数据... + + + Metadata refresh failed: %1 + 刷新元数据失败:%1 + + + Scan failed: %1 + 扫描失败:%1 + + + Full library scan started + 已开始全库扫描 + + + Scan cancelled + 扫描已取消 + + + Cancel failed: %1 + 取消失败:%1 + + + Library order updated + 媒体库顺序已更新 + + + Failed to save order: %1 + 保存顺序失败:%1 + + + Failed to load libraries: %1 + 加载媒体库失败:%1 + + + Select all libraries + 全选媒体库 + + + Clear library selection + 取消全选 + + + Delete selected libraries + 删除已选媒体库 + + + Delete Selected + 删除已选 + + + %n selected + 已选 %n 个 + + %1 · %2 + %1 · %2 + + + Are you sure you want to delete %n selected library(s)? - +This will remove the libraries from the server, but will not delete the actual media files. + 确定要删除 %n 个已选媒体库吗? + +此操作会从服务器移除媒体库,但不会删除实际媒体文件。 + + 确定要删除 %1 个已选媒体库吗? + +此操作会从服务器移除媒体库,但不会删除实际媒体文件。 + 确定要删除 %1 个已选媒体库吗? + +此操作会从服务器移除媒体库,但不会删除实际媒体文件。 + + + 删除已选 + 删除已选 + + + Deleted %1 library(s), then failed: %2 + 已删除 %1 个媒体库,后续失败:%2 + + + Failed to delete selected libraries: %1 + 删除已选媒体库失败:%1 + + + Deleted %n library(s) + 已删除 %n 个媒体库 + - TaskEditDialog + PageLibrary - Overview - 概览 + + Library + 媒体库 - Cancel - 取消 + Library Display + 媒体库显示 - Task Summary - 任务摘要 + + Show Continue Watching + 显示继续观看 - State - 状态 + + Display unfinished media on the home screen + 在主页显示未看完的媒体内容 - Category - 分类 + + Show Latest Added + 显示最近添加 - Last Result - 上次结果 + + Display recently added media on the home screen + 在主页显示最近添加的媒体内容 - Last Finished - 上次完成时间 + + Show Recommended + 显示推荐 - Schedule - 计划 + + Display recommended media on the home screen + 在主页显示推荐的媒体内容 - Visibility - 可见性 + + Show Media Libraries + 显示媒体库 - Scheduling Notes - 计划说明 + + Display media libraries on the home screen + 在主页显示媒体库入口 - Trigger Rules - 触发规则 + + Show Each Library + 显示每个媒体库 - Loading task details... - 正在加载任务详情... + + Display each library section individually on the home screen + 在主页为每个媒体库独立显示内容区域 - Configured Triggers - 已配置触发器 + + Show Completed Watching + 显示完成观看 - Triggers - 触发器 + + Display completed media on the home screen + 在主页显示已完成观看的媒体内容 - Add Trigger - 添加触发器 + + Continue Watching Request Limit + 继续观看请求数量 - Trigger Type - 触发类型 + + Latest Media Request Limit + 最近更新请求数量 - Daily - 每日 + + Recommended Request Limit + 为你推荐请求数量 - Interval - 间隔 + + Completed Watching Request Limit + 完成观看请求数量 - Weekly - 每周 + + + + + Full list request count. 0 means unlimited; home sections use the same value up to 50. + 全部列表请求数量。0 表示不限制;主页区域使用同一数值但最多 50 个。 - On Startup - 系统启动时 + + + + + Unlimited + 不限制 - Time - 时间 + + Adaptive Images + 自适应图片 - Every - 每隔 + + When a specific image type is missing, automatically use another available type as fallback + 当特定类型的图片缺失时,自动使用其他可用类型的图片替代显示 - 15 minutes - 15 分钟 + + Show Media Tooltips + 显示媒体提示 - 30 minutes - 30 分钟 + + Show media details in tooltips when hovering over media cards + 悬停媒体卡片时,在提示框中显示媒体详细信息 - 45 minutes - 45 分钟 + + Show Favorite Folders + 显示收藏的文件夹 - 1 hour - 1 小时 + + Display favorite folders in the favorites page + 在收藏页面显示收藏的文件夹 - 2 hours - 2 小时 + + Poster Wall + 海报墙 - 3 hours - 3 小时 + + List View + 列表视图 - 4 hours - 4 小时 + + Default Library View + 默认媒体库视图 - 6 hours + + Choose how media items are presented by default + 选择媒体项目的默认展示方式 + + + + Low + 低 + + + + Medium + 中 + + + + High + 高 + + + + Original + 原始 + + + + Image Quality + 图片质量 + + + + Set image download quality (higher uses more bandwidth) + 设置图片下载质量(越高占用带宽越多) + + + + Shimmer Loading Animation + 骨架屏加载动画 + + + + Show shimmer skeleton animation while loading dashboard content + 在主页内容加载期间显示闪光骨架屏占位动画 + + + + Custom Home Section Order + 自定义主页顺序 + + + + Override the default order of the main home screen sections + 覆盖主页主要区块的默认显示顺序 + + + + Cache Management + 缓存管理 + + + + 6 Hours 6 小时 - 8 hours - 8 小时 - - - 12 hours + + 12 Hours 12 小时 - 24 hours - 24 小时 + + + 1 Day + 1 天 - Day of Week - 星期 + + + 3 Days + 3 天 - Sunday - 星期日 + + + 7 Days + 7 天 - Monday - 星期一 + + 14 Days + 14 天 - Tuesday - 星期二 + + 30 Days + 30 天 - Wednesday - 星期三 + + Recommendation Cache Duration + 推荐缓存时间 - Thursday - 星期四 + + Set how long to cache recommended content before refreshing + 设置推荐内容的缓存有效期,过期后自动刷新 - Friday - 星期五 + + + Clear + 清理 - Saturday - 星期六 + + Clear Recommendation Cache + 清理推荐缓存 - Task will run when the server starts. - 任务将在服务器启动时运行。 + + Clear cached recommendations and refresh on next visit + 清除已缓存的推荐内容,下次访问时重新获取 - Time Limit (hours) - 时间限制(小时) + + Image Cache Duration + 图片缓存时间 - Optional - 可选 + + How long to keep downloaded images in local cache + 已下载图片在本地缓存中的保留时间 - Add - 添加 + + Clear Image Cache + 清理图片缓存 - Save - 保存 + + Remove all cached images from disk + 移除磁盘上所有缓存的图片 - Remove - 移除 + + Recommendation cache cleared + 推荐缓存已清理 - Max runtime %1 - 最长运行时间 %1 - - - Read-only - 只读 - - - This is a built-in system task reported by the server. Some properties may be managed by the server. - 这是服务端返回的内建系统任务,部分属性可能由服务端管理。 - - - Built-in trigger types are shown as read-only and will be preserved when you save. - 内建触发器类型会以只读方式显示,并会在保存时保留。 - - - Changes in this dialog only update trigger rules. Start or stop a task from the tasks page. - 此弹窗中的更改只会更新触发规则。任务的启动和停止请在任务页操作。 - - - Built-in server triggers are shown as read-only and will be preserved when you save. - 服务端内建触发器会以只读方式显示,并会在保存时保留。 - - - Add, remove, and save trigger rules for this task. - 可在这里为当前任务添加、移除并保存触发规则。 - - - Please enter a valid time limit. - 请输入有效的时间限制。 - - - This trigger already exists. - 该触发器已存在。 - - - No description provided. - 暂无描述。 - - - Other - 其他 - - - System task - 系统任务 - - - Visible task - 可见任务 - - - Never run - 从未运行 - - - Not available - 不可用 - - - Built-in - 内建 - - - Daily at %1 - 每天 %1 - - - Every %n hour(s) - - 每 %n 小时 - - - - Every %n minute(s) - - 每 %n 分钟 - - - - %1 at %2 - %1 %2 - - - On application startup - 应用程序启动时 - - - Delete trigger - 删除触发器 - - - No triggers configured - 未配置触发器 - - - Last: %1 - 上次:%1 - - - Failed to load task: %1 - 加载任务失败:%1 - - - Triggers saved - 触发器已保存 - - - Failed to save triggers: %1 - 保存触发器失败:%1 - - - - - TaskUtils - - Sunday - 星期日 - - - Monday - 星期一 - - - Tuesday - 星期二 - - - Wednesday - 星期三 - - - Thursday - 星期四 - - - Friday - 星期五 - - - Saturday - 星期六 - - - display configuration changes - 显示配置发生变化 - - - the server wakes from sleep - 服务器从睡眠中唤醒 - - - plugins are updated - 插件被更新 - - - No limit - 不限 - - - %n day(s) - - %n 天 - - - - %n hour(s) - - %n 小时 - - - - %n minute(s) - - %n 分钟 - - - - Less than a minute - 少于 1 分钟 - - - Idle - 空闲 - - - Running - 运行中 - - - Cancelling - 正在取消 - - - Completed - 已完成 - - - Failed - 失败 - - - Aborted - 已中止 - - - Cancelled - 已取消 - - - Daily - 每日 - - - Interval - 间隔 - - - Weekly - 每周 - - - On Startup - 系统启动时 - - - System Event - 系统事件 - - - Daily at %1 - 每天 %1 - - - Every %1 - 每隔 %1 - - - %1 at %2 - %1 %2 - - - On server startup - 服务器启动时 - - - When %1 - 当 %1 时 - - - Manual only - 仅手动运行 - - - +%n more - - 另有 %n 项 - + + Image cache cleared + 图片缓存已清理 - ContextMenuUtils + PageLogs - Undo - 撤销 + Logs + 日志 - Redo - 重做 + Refresh + 刷新 - Cut - 剪切 + Log Files + 日志文件 - Copy - 复制 + Newest files appear first. + 较新的日志文件会优先显示。 - Paste - 粘贴 + Log Viewer + 日志查看器 - Delete - 删除 + Select a log file to inspect its contents + 选择一个日志文件以查看其内容 - Select All - 全选 + No log files found + 未找到任何日志文件 + + + All Files + 全部文件 + + + Total Size + 总大小 + + + Latest Update + 最近更新 + + + Selected Size + 当前大小 + + + No files + 无文件 + + + %n file(s) + %n 个文件 + + Loading... + 加载中... + + + No log file selected + 未选择日志文件 + + + Choose a log file from the list to inspect the latest server output. + 从左侧列表中选择一个日志文件,以查看最新的服务器输出。 + + + Modified %1 + 修改于 %1 + + + Refresh failed + 刷新失败 + + + Failed to load log files: %1 + 加载日志文件失败:%1 + + + Failed to load: %1 + 加载失败:%1 + + + Save Log File + 保存日志文件 + + + Text Files (*.txt);;All Files (*) + 文本文件 (*.txt);;所有文件 (*) + + + Log file "%1" saved + 日志文件“%1”已保存 + + + Failed to save log file: %1 + 保存日志文件失败:%1 - - DownloadManager + PagePlayer - Downloaded Media - 下载的媒体 + Select GPU acceleration API for video rendering + 选择视频渲染的 GPU 加速接口 - Failed to write download file - 写入下载文件失败 + GPU Next (Recommended) + GPU Next (推荐) - Downloading - 下载中 + GPU (Legacy) + GPU (旧版) - Completed - 已完成 + Rendering backend used by the player engine + 播放引擎使用的渲染后端 - Download completed - 下载完成 + + + Player + 播放器 - Download failed - 下载失败 + + + + Auto + 自动 - Failed to prepare download: %1 - 准备下载失败:%1 + + VAAPI + VAAPI - This media is not available for download - 当前媒体不可下载 + + VDPAU + VDPAU - Failed to resolve download address - 解析下载地址失败 + + CUDA (NVIDIA) + CUDA (NVIDIA) - Failed to create download directory - 创建下载目录失败 + + None (Software) + 无 (软件解码) - Failed to open download file - 打开下载文件失败 + + Hardware Decoding + 硬件解码 - Download started - 已开始下载 + + Select GPU acceleration method for video decoding + 选择视频解码的 GPU 加速方式 - Pausing - 暂停中 + + libmpv (Embedded) + libmpv (内嵌) - Paused - 已暂停 + GPU Next + GPU Next - Cancelling - 正在取消 + GPU + GPU - Cancelled - 已取消 + + Video Output Driver + 视频输出驱动 - Download cancelled - 下载已取消 + + Rendering backend for embedded player (Render API mode) + 内嵌播放器渲染后端(Render API 模式) - Download paused - 下载已暂停 + + Audio + 音频 - Download resumed - 下载已继续 + + Display Resample (Default) + 显示重采样(默认) - Download removed - 下载任务已移除 + + Display VSync + 显示垂直同步 - Switch back to the original server before resuming this download - 请先切换回原始服务器,再继续此下载任务 + + Video Sync Mode + 视频同步模式 - The original media source is no longer available - 原始媒体源已不可用 + + Method for synchronizing video frames to display + 视频帧与显示器的同步方式 - - - - DownloadManagerDialog - Unknown - 未知 + + Mobius + Mobius - B - B + + BT.2390 + BT.2390 - KB - KB + + Hable + Hable - MB - MB + + Reinhard + Reinhard - GB - GB + + Tone Mapping + 色调映射 - TB - TB + + HDR to SDR tone mapping algorithm + HDR 到 SDR 色调映射算法 - %1% (%2 / %3) - %1%(%2 / %3) + + Chinese + 中文 - Downloading %1% (%2 / %3) - 下载中 %1%(%2 / %3) + + Chinese (Hong Kong) + 中文(港) - %1 downloaded - 已下载 %1 + + Chinese (Macau) + 中文(澳) - Downloading %1 - 下载中 %1 + + Chinese (Taiwan) + 中文(台) - %1 %2% (%3 / %4) - %1 %2%(%3 / %4) + + English + 英语 - %1 %2 - %1 %2 + + Japanese + 日语 - Pausing - 暂停中 + + Korean + 韩语 - Paused %1% (%2 / %3) - 已暂停 %1%(%2 / %3) + + French + 法语 - Paused %1 - 已暂停 %1 + + German + 德语 - Paused - 已暂停 + + Spanish + 西班牙语 - Waiting... - 等待中... + + Russian + 俄语 - -- - -- + + Preferred Audio Language + 首选音轨语言 - Downloads - 下载 + + + Rules are matched in order; choose presets or type custom keywords and press Enter + 规则会按顺序匹配;可选择预设或输入自定义关键词后按回车 - Download Manager - 下载管理 + Automatically select audio track matching this language + 自动选择匹配该语言的音轨 - Total %1 Active %2 Completed %3 Failed %4 - 共 %1 项 进行中 %2 已完成 %3 失败 %4 + + None + 无 - %1 records, %2 active, %3 completed, %4 failed - 共 %1 项,进行中 %2 项,已完成 %3 项,失败 %4 项 + + Preferred Subtitle Language + 首选字幕语言 - No download records - 暂无下载记录 + Automatically select subtitle track matching this language + 自动选择匹配该语言的字幕 - No records yet - 暂无记录 + + Original + 原始 - Download tasks will appear here after you start them. - 开始下载后,任务会显示在这里。 + + 4K + 4K - Start a download and the record will appear here. - 开始下载后,记录会显示在这里。 + + 1080p + 1080p - No. - 序号 + + 720p + 720p - Active - 进行中 + + 480p + 480p - Completed - 已完成 + + Remux + Remux - Failed - 失败 + + Bitrate High to Low + 码率从高到低 - Download Directory - 下载目录 + + Bitrate Low to High + 码率从低到高 - Download Location - 下载位置 + + File Size Large to Small + 文件大小从大到小 - Saved To - 保存位置 + + File Size Small to Large + 文件大小从小到大 - Choose where new downloads are saved. - 设置新下载内容的保存位置。 + + Date New to Old + 日期从新到旧 - Open Directory - 打开目录 + + Date Old to New + 日期从旧到新 - Set Directory - 设置目录 + + Preferred Video Version + 首选视频版本 - Current Downloads - 当前下载 + + Choose keyword or sort rules in order; type custom keywords and press Enter + 按顺序选择关键词或排序规则;输入自定义关键词后按回车 - Stopping a task removes the unfinished file immediately. - 停止任务后会立即删除未完成的文件。 + + Continuous Playback + 连续播放 - No active downloads - 暂无进行中的下载 + + Automatically play the next episode or media when playback ends + 播放结束后自动播放下一集或下一个媒体 - Media - 媒体 + + 3 Seconds + 3 秒 - Size - 大小 + + 5 Seconds + 5 秒 - Progress - 进度 + + 10 Seconds + 10 秒 - Started - 开始时间 + + 15 Seconds + 15 秒 - Status - 状态 + + 30 Seconds + 30 秒 - Action - 操作 + + Seek Step + 快进步长 - Download History - 下载历史 + + Number of seconds to skip when pressing forward or rewind + 按下前进或后退时跳过的秒数 - Select a record to open the media, jump to its folder, or remove it. - 选择一条记录后,可以打开媒体、跳转到目录,或移除记录。 + + Long Press Left/Right + 长按左右键 - No download history - 暂无下载历史 + + Enable long press actions for left/right keys during playback + 启用播放时左右键的长按操作 - Result - 结果 + + Long Press Trigger Time + 长按触发时长 - Finished - 完成时间 + + Set how long left/right keys must be held before the long press action starts + 设置左右键按住多久后开始触发长按动作 - No download record selected - 未选择下载记录 + + Long Press Mode + 长按模式 - Open Media - 打开媒体 + + Choose whether holding left/right keys performs time jump seek or temporary speed playback + 选择长按左右键时执行时间跳转还是临时倍速播放 - Pause Download - 暂停下载 + + Mouse Edge Long Press + 鼠标左右区域长按 - Resume Download - 继续下载 + + Hold the left or right side of the playback area for speed rewind or fast forward + 在播放界面的左侧或右侧按住鼠标以进行倍速快退或快进 - Open Folder - 打开文件夹 + + Time Jump Playback + 时间跳转播放 - Delete Record - 删除记录 + + Speed Playback + 倍速播放 - Stop - 停止 + + 300 ms + 300 毫秒 - Stop Download - 停止下载 + + 500 ms + 500 毫秒 - Stop and remove the unfinished file - 停止并删除未完成文件 + + 800 ms + 800 毫秒 - Select Download Directory - 选择下载目录 + + 1000 ms + 1000 毫秒 - The download folder is no longer available. - 下载文件夹已不存在。 + Long Press Seek + 长按快进 - The downloaded media file is no longer available. - 下载的媒体文件已不存在。 + Hold forward/rewind buttons to continuously seek with acceleration + 长按前进/后退按钮以加速连续快进 - Also Delete File - 同时删除文件 + + Independent Player Window + 独立播放窗口 - Also remove the local file when deleting a history record - 删除历史记录时同时删除本地文件 + + Open video in a separate detached window + 在独立的分离窗口中打开视频 - Delete download record for "%1"? - 确定要删除“%1”的下载记录吗? + + Volume Normalization + 音量标准化 - Delete download record for "%1" and remove the local file? - 确定要删除“%1”的下载记录并移除本地文件吗? + + Automatically adjust volume to prevent sudden loud noises + 自动调节音量以防止突然的大声噪音 - Cancel active download for "%1" and remove the unfinished file? - 确定要取消“%1”的下载并删除未完成文件吗? + + Use Custom mpv.conf + 使用自定义 mpv.conf - Delete - 删除 + + Load settings from an external mpv.conf configuration file + 从外部 mpv.conf 配置文件加载设置 - Cancel - 取消 + + Config path: %1 + 配置文件路径: %1 - Delete File - 删除文件 + + Open File + 打开文件 - Delete the downloaded file too? - 是否同时删除已下载的文件? + + 6 Hours + 6 小时 - Keep File - 保留文件 + + 12 Hours + 12 小时 - Server: %1 -Status: %2 -Added: %3 -Finished: %4 -Size: %5 - 服务器:%1 -状态:%2 -添加时间:%3 -完成时间:%4 -大小:%5 + + 1 Day + 1 天 - Saved To: %1 - 保存到:%1 + + 3 Days + 3 天 - - - - PlayerView + + 7 Days + 7 天 + + + + Clear + 清除 + + + + External Player + 外置播放器 + + + + Enable External Player + 启用外置播放器 + + + + Supported players on this platform: %1 + 当前平台支持的播放器:%1 + + + + Custom... + 自定义... + + + + Re-detect installed players + 重新检测已安装的播放器 + + + + Select External Player + 选择外置播放器 + + + Executable Files (*.exe) + 可执行文件 (*.exe) + + + + All Files (*) + 所有文件 (*) + + + + Arguments + 启动参数 + + + + e.g., --fullscreen --volume=100 + 例如 --fullscreen --volume=100 + + + + Quick Play with External Player + 快捷播放使用外置播放器 + + + + Use external player for quick play actions on cards and menus + 卡片和菜单上的快捷播放操作将使用外置播放器 + + + + Direct Stream + 直链播放 + + + + Bypass server proxy and stream directly from source. May not work on external networks + 绕过服务器代理,直接从源地址播放。在外网环境下可能无法使用 + + + + URL Path Replacement one rule per line: source => target + URL路径替换 每行一条规则:源路径 => 目标路径 + + + + e.g., http://192.168.2.1:19798/path/ => W:/mount/ + 例如 http://192.168.2.1:19798/path/ => W:/mount/ + + + + Click to Pause + 单击暂停 + + + + Single click on the video area to toggle play/pause + 在视频画面上单击鼠标左键以切换播放/暂停 + + + + Auto Skip Intro + 自动跳过片头 + + + + Automatically skip the intro sequence when playback starts + 播放开始时自动跳过片头片段 + + + + Auto Skip Outro + 自动跳过片尾 + + + + Automatically skip the ending credits during playback + 播放时自动跳过片尾演职员表 + + + Danmaku 弹幕 + + Enable Danmaku + 启用弹幕 + + + + Display streaming danmaku in the embedded player + 在内置播放器中显示流式弹幕 + + + + Danmaku Renderer + 弹幕渲染方式 + + + + Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer + 选择通过 ASS 字幕轨还是 qEmby 自适应原生渲染器来显示弹幕 + + + + ASS Subtitle Track + ASS 字幕轨渲染 + + + + Native Smooth Renderer + 原生丝滑渲染 + + + + Auto Load Danmaku + 自动加载弹幕 + + + + Automatically search and load danmaku when playback starts + 播放开始时自动搜索并加载弹幕 + + + + Auto Match Danmaku + 自动匹配弹幕 + + + + Automatically choose the best danmaku match when no cached manual result exists + 当没有已缓存的手动匹配结果时,自动选择最合适的弹幕条目 + + + + DandanPlay Open API + 弹弹Play 开放 API + + + + Danmaku Server + 弹幕服务器 + + + + Manage + 管理 + + + + Prefer Online + 优先在线 + + + + Prefer Local + 优先本地 + + + + Online Only + 仅在线 + + + + Local Only + 仅本地 + + + + Danmaku Source + 弹幕来源 + + + + Choose whether local danmaku files or online danmaku should be preferred during matching + 选择在匹配弹幕时优先使用本地弹幕文件还是在线弹幕结果 + + + + Local Danmaku Folder + 本地弹幕文件夹 + + + + Place .ass, .json, or .xml danmaku files here. Matching uses the current media title and manual keyword, and the selected danmaku source mode decides whether local files or online results are preferred first. +Folder: %1 + 将 .ass、.json 或 .xml 弹幕文件放到这里。匹配时会使用当前媒体标题和手动匹配关键词,并按所选的弹幕来源策略决定优先使用本地文件还是在线结果。 +文件夹:%1 + + + + Open Folder + 打开文件夹 + + + Current server: %1 +Address: %2 + 当前服务器:%1 +地址:%2 + + + + Current server: %1 + 当前服务器:%1 + + + + Address: %1 + 地址:%1 + + + + Supported content: Anime only + 支持内容:仅动画 + + + + Description: %1 + 说明:%1 + + + + App ID and App Secret are required for official DandanPlay Open API access + 使用官方弹弹Play 开放 API 必须提供 App ID 和 App Secret + + + Danmaku Provider + 弹幕服务 + + + Select which danmaku service to query for matches and comments + 选择用于匹配和获取弹幕的服务 + + + + Load Related Comments + 加载关联弹幕 + + + + Include related third-party comment sources when the provider supports it + 当服务支持时,一并加载关联的第三方弹幕来源 + + + + Dual Subtitle Mode + 双字幕模式 + + + + Keep regular subtitles visible alongside danmaku when possible + 在条件允许时让普通字幕与弹幕同时显示 + + + + Danmaku Opacity + 弹幕透明度 + + + + Control the transparency of rendered danmaku + 控制弹幕渲染后的透明度 + + + + Danmaku Size + 弹幕大小 + + + Danmaku Font Weight + 弹幕字重 + + + Adjust the font weight used for danmaku text + 调整弹幕文字使用的字重 + + + Danmaku Outline + 弹幕描边 + + + Control the outline thickness applied to danmaku text + 控制弹幕文字描边的粗细 + + + Danmaku Shadow + 弹幕阴影 + + + Control the shadow offset applied to danmaku text + 控制弹幕文字阴影的偏移量 + + + + Scale the font size used for danmaku rendering + 调整弹幕渲染使用的字体大小 + + + + Display Area + 显示区域 + + + + Limit how much of the screen height danmaku can occupy + 限制弹幕可占用的屏幕高度比例 + + + + Danmaku Density + 弹幕密度 + + + + Reduce or increase the number of simultaneous lanes + 减少或增加同时显示的弹幕轨道数量 + + + + Danmaku Speed + 弹幕速度 + + + + Adjust how quickly scrolling danmaku travels across the screen + 调整滚动弹幕在屏幕上的移动速度 + + + + Danmaku Offset + 弹幕偏移 + + + + Shift danmaku timing earlier or later to align with playback + 将弹幕时间整体提前或延后,以便和播放画面对齐 + + + + Hide Scrolling Danmaku + 隐藏滚动弹幕 + + + + Hide standard right-to-left scrolling comments + 隐藏标准从右向左滚动的弹幕 + + + + Hide Top Danmaku + 隐藏顶部弹幕 + + + + Hide fixed danmaku positioned at the top of the screen + 隐藏固定显示在顶部的弹幕 + + + + Hide Bottom Danmaku + 隐藏底部弹幕 + + + + Hide fixed danmaku positioned at the bottom of the screen + 隐藏固定显示在底部的弹幕 + + + + Blocked Keywords + 屏蔽关键词 + + + + Filter out danmaku containing these keywords + 过滤掉包含这些关键词的弹幕 + + + Provider Base URL + 服务地址 + + + Set a custom Danmaku API endpoint for official or compatible services + 设置官方或兼容弹幕服务使用的 API 地址 + + + Danmaku App ID + 弹幕 App ID + + + Optional + 可选 + + + Optional App ID for authenticated DandanPlay Open API requests + 用于已鉴权弹弹Play 开放 API 请求的可选 App ID + + + Optional App ID used by the selected danmaku server + 当前所选弹幕服务器使用的可选 App ID + + + Danmaku App Secret + 弹幕 App Secret + + + Optional App Secret used to sign DandanPlay Open API requests + 用于签名弹弹Play 开放 API 请求的可选 App Secret + + + Optional App Secret used by the selected danmaku server + 当前所选弹幕服务器使用的可选 App Secret + + + + Danmaku Cache Duration + 弹幕缓存时间 + + + + Reuse online match results, cached comment payloads, and generated ASS files for this long + 在这段时间内复用在线匹配结果、已缓存的弹幕数据和生成的 ASS 文件 + + + + Clear Danmaku Cache + 清理弹幕缓存 + + + + Remove cached online matches, cached comment payloads, and generated ASS files + 移除已缓存的在线匹配结果、弹幕数据和生成的 ASS 文件 + + + + Danmaku cache cleared + 弹幕缓存已清理 + + + Slow + 慢 + + + Normal + 正常 + + + Fast + 快 + + + + Right Sidebar + 右侧边栏 + + + + Bottom HUD + 底部 HUD + + + + Media Switcher Position + 媒体切换位置 + + + + Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD + 选择最近电影与电视剧季集切换显示在右侧边栏还是底部 HUD + + + + PageTasks + + Tasks + 任务 + + + Refresh + 刷新 + + + Loading... + 加载中... + + + Refresh failed + 刷新失败 + + + All Tasks + 全部任务 + + + No tasks found + 未找到任何任务 + + + No tasks + 无任务 + + + %n task(s) + %n 项任务 + + %n running + %n 项运行中 + + %n system + %n 项系统任务 + + %1 · %2 + %1 · %2 + + + Other + 其他 + + + Scheduled + 已计划 + + + Manual + 手动 + + + System + 系统 + + + Running Now + 正在运行 + + + Tasks that are currently executing. Progress refreshes automatically. + 当前正在执行的任务,进度会自动刷新。 + + + Scheduled Tasks + 计划任务 + + + Tasks with one or more configured triggers. + 已配置一个或多个触发器的任务。 + + + Manual Tasks + 手动任务 + + + Tasks that only run when started manually. + 仅在手动启动时运行的任务。 + + + System Tasks + 系统任务 + + + Hidden or built-in tasks reported by the server. + 服务端返回的隐藏或内建任务。 + + + Details + 详情 + + + Schedule: %1 + 计划:%1 + + + State: %1 + 状态:%1 + + + Last result: %1 + 上次结果:%1 + + + %1 · %2% + %1 · %2% + + + Run task + 运行任务 + + + Stop task + 停止任务 + + + Last: %1 + 上次:%1 + + + %1 — %2% + %1 — %2% + + + Idle + 空闲 + + + Running + 运行中 + + + Cancelling + 正在取消 + + + Completed + 已完成 + + + Failed + 失败 + + + Aborted + 已中止 + + + Cancelled + 已取消 + + + Task "%1" started + 任务"%1"已启动 + + + Task "%1" stopped + 任务"%1"已停止 + + + Failed to load tasks: %1 + 加载任务失败:%1 + + + Failed to run task: %1 + 运行任务失败:%1 + + + Failed to stop task: %1 + 停止任务失败:%1 + + + + PageTranscoding + + Transcoding + 转码 + + + Refresh + 刷新 + + + Save + 保存 + + + Loading... + 加载中... + + + Server + 服务端 + + + Acceleration + 加速 + + + Tone Mapping + 色调映射 + + + Temp Path + 临时路径 + + + General + 常规 + + + Paths, threads and core transcoding switches. + 路径、线程和核心转码开关。 + + + Temporary Path + 临时目录 + + + Encoding Threads + 编码线程 + + + Auto + 自动 + + + Enable Fallback Font + 启用后备字体 + + + Fallback Font Path + 后备字体路径 + + + Audio VBR + 音频 VBR + + + FFmpeg Path + FFmpeg 路径 + + + Extraction Threads + 抽取线程 + + + Subtitle Extraction + 字幕提取 + + + Quality & Cleanup + 质量与清理 + + + Quality, buffering and segment retention. + 质量、缓冲与分片保留设置。 + + + H.264 CRF + H.264 CRF + + + Downmix Boost + 混音增益 + + + Enable Throttling + 启用限速 + + + H.265 CRF + H.265 CRF + + + None + 无 + + + Dave750 + Dave750 + + + Nightmode + 夜间模式 + + + NightmodeDialogue + NightmodeDialogue + + + Dolby Pro Logic + Dolby Pro Logic + + + Dolby Pro Logic II + Dolby Pro Logic II + + + RFC7845 + RFC7845 + + + AC-4 + AC-4 + + + Stereo Downmix + 立体声下混 + + + Very Slow + 很慢 + + + Slower + 更慢 + + + Slow + 慢 + + + Medium + 中等 + + + Fast + 快 + + + Faster + 更快 + + + Very Fast + 很快 + + + Super Fast + 超快 + + + Ultra Fast + 极速 + + + Encoder Preset + 编码预设 + + + Max Muxing Queue + 最大复用队列 + + + Throttle Delay + 限速延迟 + + + Enable Segment Deletion + 启用分片清理 + + + Segment Keep Seconds + 分片保留秒数 + + + YADIF + YADIF + + + BWDIF + BWDIF + + + Bob + Bob + + + Deinterlace Method + 去隔行方式 + + + Double-Rate Deinterlace + 双倍帧率去隔行 + + + Throttle Buffer + 限速缓冲区 + + + Throttle Hysteresis + 限速滞后 + + + Throttling Method + 限速方式 + + + Hardware Acceleration + 硬件加速 + + + Devices, decode paths and hardware encoder toggles. + 设备、解码路径以及硬件编码开关。 + + + Enable Hardware Encoding + 启用硬件编码 + + + QSV + QSV + + + NVENC + NVENC + + + AMF + AMF + + + VAAPI + VAAPI + + + VideoToolbox + VideoToolbox + + + V4L2M2M + V4L2M2M + + + RKMPP + RKMPP + + + VAAPI Device + VAAPI 设备 + + + QSV Device + QSV 设备 + + + Hardware Decoding Codecs + 硬件解码编解码器 + + + Prefer Native Decoder + 优先系统原生解码器 + + + Enhanced NVDEC + 增强 NVDEC + + + Intel Low Power H.264 + Intel 低功耗 H.264 + + + Intel Low Power HEVC + Intel 低功耗 HEVC + + + Allow HEVC Encode + 允许 HEVC 编码 + + + Allow AV1 Encode + 允许 AV1 编码 + + + Codec Paths + 编解码路径 + + + HDR conversion and tone mapping controls. + HDR 转换与色调映射控制。 + + + Enable Tonemapping + 启用色调映射 + + + Enable VPP Tonemapping + 启用 VPP 色调映射 + + + Enable VideoToolbox Tonemapping + 启用 VideoToolbox 色调映射 + + + Algorithm + 算法 + + + BT.2390 + BT.2390 + + + Hable + Hable + + + Reinhard + Reinhard + + + Mobius + Mobius + + + Clip + Clip + + + Linear + Linear + + + Gamma + Gamma + + + Mode + 模式 + + + MAX + MAX + + + RGB + RGB + + + LUM + LUM + + + ITP + ITP + + + Range + 范围 + + + TV + TV + + + PC + PC + + + Desaturation + 去饱和 + + + Peak + 峰值 + + + Parameter + 参数 + + + VPP Brightness + VPP 亮度 + + + VPP Contrast + VPP 对比度 + + + Hardware Tone Mapping + 硬件色调映射 + + + Software Tone Mapping + 软件色调映射 + + + Advanced + 高级 + + + Codec-level and extraction-specific options. + 编解码级别和抽帧相关的高级选项。 + + + 10-bit HEVC Decode + 10 位 HEVC 解码 + + + 10-bit VP9 Decode + 10 位 VP9 解码 + + + 10-bit HEVC Rext + 10 位 HEVC Rext + + + 12-bit HEVC Rext + 12 位 HEVC Rext + + + Keyframe Extensions + 关键帧扩展名 + + + No transcoding configuration available + 没有可用的转码配置 + + + Refresh failed + 刷新失败 + + + Failed to load transcoding settings + 加载转码设置失败 + + + Failed to load transcoding settings: %1 + 加载转码设置失败:%1 + + + Saving... + 保存中... + + + Transcoding settings saved + 转码设置已保存 + + + Failed to save transcoding settings: %1 + 保存转码设置失败:%1 + + + Jellyfin + Jellyfin + + + Emby + Emby + + + Unknown + 未知 + + + Disabled + 已禁用 + + + Enabled + 已启用 + + + Mode %1 + 模式 %1 + + + VPP + VPP + + + Mixed + 混合 + + + Hardware + 硬件 + + + Software + 软件 + + + Default + 默认 + + + Custom + 自定义 + + + %1 · %n visible setting(s) + %1 · 显示 %n 项设置 + + %n codec path(s) + %n 个编解码路径 + + +%1 more + 另有 %1 项 + + + Decoder + 解码器 + + + Core transcoding switches used by the current server. + 当前服务端实际使用的核心转码开关。 + + + Transcoding Temp Path + 转码临时目录 + + + Enable Subtitle Extraction + 启用字幕提取 + + + Downmix Audio Boost + 音频下混增益 + + + Hardware transcoding mode, decoder preference and codec support. + 硬件转码模式、首选解码器以及编解码支持设置。 + + + Hardware Acceleration Type + 硬件加速类型 + + + Prefer System Native Decoder + 优先系统原生解码器 + + + Allow HEVC Encoding + 允许 HEVC 编码 + + + Allow AV1 Encoding + 允许 AV1 编码 + + + No + 否 + + + Yes + 是 + + + Preferred Hardware Decoder + 首选硬件解码器 + + + Preferred Hardware Encoder + 首选硬件编码器 + + + HDR conversion and tone mapping behavior. + HDR 转换与色调映射行为。 + + + Hardware + Software + 硬件 + 软件 + + + Automatic + 自动 + + + System Native + 系统原生 + + + 10-bit Decoding + 10 位解码 + + + AV1 + AV1 + + + By Segment Request + 按分段请求 + + + Codec Information + 编解码器信息 + + + Drag to Reorder + 拖动调整顺序 + + + Custom Order + 自定义顺序 + + + Encoding Formats + 编码格式 + + + H264 + H264 + + + HEVC + HEVC + + + HEVC 10-bit + HEVC 10 位 + + + HEVC RExt 12-bit + HEVC RExt 12 位 + + + HEVC RExt 8/10-bit + HEVC RExt 8/10 位 + + + HEVC RExt Decoding + HEVC RExt 解码 + + + Hardware Decoding + 硬件解码 + + + Hardware Encoding + 硬件编码 + + + MPEG1 + MPEG1 + + + MPEG2 + MPEG2 + + + MPEG4 + MPEG4 + + + Metadata Keyframe Extensions + 按需关键帧元数据扩展名 + + + Move Down + 下移 + + + Move Up + 上移 + + + OK + 确定 + + + Paths and Threads + 路径与线程 + + + Preferred Hardware Decoders + 首选硬件解码器 + + + Preferred Hardware Encoders + 首选硬件编码器 + + + Quality and Processing + 质量与处理 + + + Software Encoders + 软件编码器 + + + Settings + 设置 + + + Throttling and Segments + 节流与分段 + + + VC1 + VC1 + + + VP8 + VP8 + + + VP9 + VP9 + + + VP9 10-bit + VP9 10 位 + + + VPP Tone Mapping + VPP 色调映射 + + + + Select Folder + 选择文件夹 + + + + Select File + 选择文件 + + + + PageUsers + + Users + 用户 + + + Refresh + 刷新 + + + Add user + 添加用户 + + + No users found + 未找到任何用户 + + + No users + 无用户 + + + %n user(s) + %n 位用户 + + Admin + 管理员 + + + Disabled + 已禁用 + + + Last login: %1 + 上次登录:%1 + + + Last active: %1 + 上次活跃:%1 + + + Enable user + 启用用户 + + + Disable user + 禁用用户 + + + Cannot disable yourself + 不能禁用自己 + + + User "%1" disabled + 用户"%1"已禁用 + + + User "%1" enabled + 用户"%1"已启用 + + + Failed to load users: %1 + 加载用户失败:%1 + + + Failed to update user: %1 + 更新用户失败:%1 + + + Delete user + 删除用户 + + + Cannot delete yourself + 无法删除自己 + + + Delete User + 删除用户 + + + Are you sure you want to delete user "%1"? This action cannot be undone. + 确定要删除用户 "%1" 吗?此操作无法撤销。 + + + Delete + 删除 + + + Cancel + 取消 + + + User "%1" deleted + 用户 "%1" 已删除 + + + Failed to delete user: %1 + 删除用户失败:%1 + + + + PathRowWidget + + + Browse server directory + 浏览服务器目录 + + + + Remove this path + 移除此路径 + + + + Click + to browse... + 点击 + 浏览目录... + + + + PlaybackManager + + + External player not found, falling back to internal player + 未找到外部播放器,回退到内置播放器 + + + + External player not found + 未找到外部播放器 + + + + Failed to launch external player + 启动外部播放器失败 + + + + Launching %1... + 正在启动 %1... + + + + PlayerDanmakuController + + + Danmaku Disabled + 弹幕已关闭 + + + + Danmaku Enabled + 弹幕已开启 + + + + + Danmaku Loaded (%1) + 弹幕已加载(%1) + + + + + Danmaku Loaded + 弹幕已加载 + + + + + Danmaku Loaded from %1 (%2) + 已从 %1 加载弹幕(%2) + + + + + Danmaku Loaded from %1 + 已从 %1 加载弹幕 + + + + No matching danmaku found + 未找到匹配的弹幕 + + + + + No danmaku available + 没有可用弹幕 + + + + + Failed to load danmaku + 加载弹幕失败 + + + + Unsupported danmaku file + 不支持的弹幕文件 + + + + Danmaku Shown + 弹幕已显示 + + + + Danmaku Hidden + 弹幕已隐藏 + + + + PlayerDanmakuIdentifyDialog + + Local File 本地文件 + DandanPlay 弹弹Play + Unknown Source 未知来源 - No danmaku loaded - 未加载弹幕 - - - Loading danmaku... - 正在加载弹幕... - - - Unknown Danmaku - 未知弹幕 - - + %1 comments %1 条弹幕 + Comment count unavailable 弹幕条数未知 - Current: %1 | %2 | %3 - 当前:%1 | %2 | %3 + + + Unknown Danmaku + 未知弹幕 + + Source: %1 + 来源:%1 + + + Server: %1 服务器:%1 + + Danmaku Server: %1 + 弹幕服务器:%1 + + + + Provider: %1 + 来源类型:%1 + + + + Title: %1 + 标题:%1 + + + + Series: %1 + 系列:%1 + + + + Comments: %1 + 弹幕条数:%1 + + + + Unknown + 未知 + + + + Score: %1 + 匹配分数:%1 + + + + Episode: S%1E%2 + 剧集:S%1E%2 + + + + Match Reason: %1 + 匹配依据:%1 + + + + Target: %1 + 目标:%1 + + + + Search Danmaku + 搜索弹幕 + + + + this video + 当前视频 + + + + Search danmaku sources for "%1" and choose the best match. + 为“%1”搜索弹幕来源,并选择最合适的匹配结果。 + + + + Enter title or keyword + 输入标题或关键词 + + + + Search + 搜索 + + + Search for local or online danmaku sources. + 搜索本地或在线弹幕来源。 + + + + Search local files and enabled danmaku servers. + 搜索本地文件和已启用的弹幕服务器。 + + + + Danmaku Details + 弹幕详情 + + + + + Select a danmaku source to view details. + 选择一个弹幕来源以查看详情。 + + + + Cancel + 取消 + + + + Load Danmaku + 加载弹幕 + + + Searching... + 搜索中... + + + + Searching local files and enabled danmaku servers... + 正在搜索本地文件和已启用的弹幕服务器... + + + + No matches found + 未找到匹配结果 + + + + Found %1 matches + 找到 %1 个匹配结果 + + + + Found %1 matches from %2 danmaku servers + 从 %2 个弹幕服务器找到 %1 个匹配结果 + + + + Search failed + 搜索失败 + + + + Failed to search danmaku: %1 + 搜索弹幕失败:%1 + + + + Filter results (title, series, server) + 在结果中筛选(标题、系列、服务器) + + + + Showing %1 of %2 matches for "%3" + 已筛选出 %1 / %2 条匹配「%3」的结果 + + + + No results match "%1" (%2 total) + 没有匹配「%1」的结果(共 %2 条) + + + + PlayerDanmakuSettingsDialog + + + Danmaku Settings + 弹幕设置 + + + + Adjust danmaku rendering without leaving playback. Changes are saved and applied immediately. + 无需离开播放页即可调整弹幕渲染设置。修改会立即保存并实时生效。 + + + ASS Subtitle Track + ASS 字幕轨渲染 + + + Native Smooth Renderer + 原生丝滑渲染 + + + Danmaku Renderer + 弹幕渲染方式 + + + Switch between the ASS subtitle track pipeline and qEmby's adaptive native danmaku renderer. + 在 ASS 字幕轨管线与 qEmby 自适应原生弹幕渲染器之间切换。 + + + Enable Danmaku + 开启弹幕 + + + Display streaming danmaku in the embedded player + 在内置播放器中显示流式弹幕 + + + Dual Subtitle Mode + 双字幕模式 + + + Keep regular subtitles visible alongside danmaku when possible + 尽量在显示弹幕时保留常规字幕 + + + + Danmaku Opacity + 弹幕透明度 + + + Control the transparency of rendered danmaku + 控制渲染后弹幕的透明度 + + + + Danmaku Size + 弹幕大小 + + + + Danmaku Font Weight + 弹幕字重 + + + + Danmaku Outline + 弹幕描边 + + + + Danmaku Shadow + 弹幕阴影 + + + + Display Area + 显示区域 + + + Limit how much of the screen height danmaku can occupy + 限制弹幕可占用的屏幕高度 + + + + Danmaku Density + 弹幕密度 + + + Reduce or increase the number of simultaneous lanes + 减少或增加同时显示的弹幕轨道数量 + + + + Danmaku Speed + 弹幕速度 + + + Adjust how quickly scrolling danmaku travels across the screen + 调整滚动弹幕在屏幕上的移动速度 + + + + Danmaku Offset + 弹幕偏移 + + + Shift danmaku timing earlier or later to align with playback + 将弹幕时间整体提前或延后以匹配播放进度 + + + + Hide Scrolling Danmaku + 隐藏滚动弹幕 + + + Hide standard right-to-left scrolling comments + 隐藏标准的从右向左滚动弹幕 + + + + Hide Top Danmaku + 隐藏顶部弹幕 + + + Hide fixed danmaku positioned at the top of the screen + 隐藏固定显示在屏幕顶部的弹幕 + + + + Hide Bottom Danmaku + 隐藏底部弹幕 + + + Hide fixed danmaku positioned at the bottom of the screen + 隐藏固定显示在屏幕底部的弹幕 + + + + Blocked Keywords + 屏蔽关键词 + + + 40% + 40% + + + 55% + 55% + + + 72% + 72% + + + 85% + 85% + + + 100% + 100% + + + 80% + 80% + + + 120% + 120% + + + 140% + 140% + + + Low + 低 + + + Medium + 中 + + + High + 高 + + + Slow + 慢 + + + Normal + 正常 + + + Fast + 快 + + + -1000 ms + -1000 毫秒 + + + -500 ms + -500 毫秒 + + + 0 ms + 0 毫秒 + + + +500 ms + +500 毫秒 + + + +1000 ms + +1000 毫秒 + + + Close + 关闭 + + + + PlayerLongPressHandler + + + + Speed: %1X + 速度:%1X + + + + PlayerMediaSwitcherPanel + + + Continue Watching + 继续观看 + + + + Seasons + 季 + + + + + Episodes + 剧集 + + + Loading... + 加载中... + + + + No active media found. + 未找到继续观看的媒体。 + + + + No episodes found. + 未找到剧集。 + + + S%1E%2 %3 + S%1E%2 %3 + + + Episode %1 %2 + 第 %1 集 %2 + + + %1 - S%2E%3 - %4 + %1 - S%2E%3 - %4 + + + %1 - %2 + %1 - %2 + + + %1 - %2 - %3 + %1 - %2 - %3 + + + Season %1 + 第 %1 季 + + + + PlayerPage + + + + + Speed %1× + 速度 %1× + + + + File + 文件 + + + + Position + 位置 + + + + Speed + 速度 + + + + Volume + 音量 + + + + Muted + 已静音 + + + + Video + 视频 + + + + Software (CPU) + 软件(CPU) + + + + Decoder + 解码器 + + + + VO + VO + + + + relay + 中继 + + + + Cache + 缓存 + + + + + Danmaku + 弹幕 + + + + « Seek + « 快退 + + + + Seek » + 快进 » + + + + Buffering… + 缓冲中… + + + + Mute + 静音 + + + + Back + 返回 + + + + Loading… + 加载中… + + + + Now + 当前 + + + + Buffered + 已缓冲 + + + + Not buffered + 未缓冲 + + + + + Audio + 音轨 + + + + Subs + 字幕 + + + + 弹幕 + 弹幕 + + + + Episodes + 选集 + + + + Disable Audio + 禁用音轨 + + + + Audio off + 音轨已关闭 + + + + Subtitles + 字幕 + + + + Disable Subtitles + 禁用字幕 + + + + Subtitles off + 字幕已关闭 + + + + Subtitle settings… + 字幕设置… + + + + Playback Speed + 播放速度 + + + + Disable danmaku + 禁用弹幕 + + + + Enable danmaku + 启用弹幕 + + + + Danmaku on + 弹幕已开启 + + + + Danmaku off + 弹幕已关闭 + + + + Hide danmaku + 隐藏弹幕 + + + + Show danmaku + 显示弹幕 + + + + Reload + 重新加载 + + + + Reloading danmaku… + 正在重新加载弹幕… + + + + Identify… + 识别… + + + + Danmaku settings… + 弹幕设置… + + + + No status + 无状态 + + + + PlayerSubtitleSettingsDialog + + + Auto + 自动 + + + + Subtitle Settings + 字幕设置 + + + + Adjust subtitle rendering without leaving playback. Changes are saved and applied immediately. + 无需离开播放页即可调整字幕渲染设置。修改会立即保存并实时生效。 + + + + Subtitle Font + 字幕字体 + + + + + Choose the font family used for subtitle rendering + 选择字幕渲染使用的字体 + + + + Subtitle Timing + 字幕时间 + + + + Subtitle Size + 字幕大小 + + + + Subtitle Position + 字幕位置 + + + + Subtitle Scale + 字幕缩放 + + + + Outline Size + 描边大小 + + + + Shadow Offset + 阴影偏移 + + + + PlayerView + + + + + + Continue Watching + 继续观看 + + + + + + Episodes + 剧集 + + + + No episodes found. + 未找到剧集。 + + + + No active media found. + 未找到继续观看的媒体。 + + + + Network Speed + 实时网速 + + + + Network Speed Enabled + 网速显示已开启 + + + + Network Speed Disabled + 网速显示已隐藏 + + + + Statistics + 统计信息 + + + + File + 文件 + + + + Session + 会话 + + + + Item ID + 媒体 ID + + + + Media Source ID + 媒体源 ID + + + + Play Session ID + 播放会话 ID + + + + Cache + 缓存 + + + + Playback + 播放 + + + + Refresh Rate + 刷新率 + + + + Dropped Frames + 丢帧 + + + + Output + 输出 + + + + Decoder + 解码器 + + + + Color + 色彩 + + + + Channels + 声道 + + + + Sample Rate + 采样率 + + + + Device + 设备 + + + + Subtitle + 字幕 + + + + Speed + 速度 + + + + A-V + 音视频 + + + + Software (CPU) + 软解 (CPU) + + + + %1-bit + %1 位 + + + + AO %1 + AO %1 + + + + %1 ch + %1 声道 + + + + + Chapter + 章节 + + + + Size + 大小 + + + + Video + 视频 + + + + Audio + 音频 + + + + Frame Rate + 帧率 + + + + Resolution + 分辨率 + + + + Format + 格式 + + + + + Bitrate + 码率 + + + + STRM Direct Play + STRM直连 + + + + STRM Direct Play: ON (Reloading...) + STRM 直连播放: 开启 (重新加载中...) + + + + STRM Direct Play: OFF (Reloading...) + STRM 直连播放: 关闭 (重新加载中...) + + + + Speed: %1X + 播放速度: %1X + + + + Disable Audio + 关闭音频 + + + + Track %1 + 音轨 %1 + + + + Audio: %1 + 音轨: %1 + + + No Audio Tracks + 无可用音轨 + + + + Audio Disabled + 音频已关闭 + + + + Disable Subtitles + 关闭字幕 + + + + Subtitle %1 + 字幕 %1 + + + + Subtitle: %1 + 字幕: %1 + + + + Subtitles Disabled + 字幕已关闭 + + + + + Load Local Subtitle File + 加载本地字幕文件 + + + + Subtitle Loaded: %1 + 已加载字幕:%1 + + + + Player is not ready + 播放器尚未就绪 + + + + Subtitle Files (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;SubRip (*.srt);;Advanced SubStation Alpha (*.ass *.ssa);;WebVTT (*.vtt);;MicroDVD / SubViewer (*.sub);;VobSub (*.idx);;SAMI (*.smi);;PGS / HDMV (*.sup);;All Files (*) + 字幕文件 (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;SubRip (*.srt);;高级 SubStation Alpha (*.ass *.ssa);;WebVTT (*.vtt);;MicroDVD / SubViewer (*.sub);;VobSub (*.idx);;SAMI (*.smi);;PGS / HDMV (*.sup);;所有文件 (*) + + + + External Sub %1 + 外挂字幕 %1 + + + + Forward %1s + 前进 %1 秒 + + + + Rewind %1s + 后退 %1 秒 + + + + Scale: Fit + 画幅: 适应屏幕 + + + + Scale: Crop + 画幅: 铺满裁剪 + + + + Scale: Stretch + 画幅: 强制拉伸 + + + + Scale: Original + 画幅: 原始比例 + + + + Muted + 已静音 + + + + + + Volume: %1% + 音量: %1% + + + + %1% + %1% + + + + Back + 返回 + + + + Minimize + 最小化 + + + + Maximize + 最大化 + + + + Close + 关闭 + + + + Rewind + 快退 + + + + Play/Pause + 播放/暂停 + + + + Forward + 快进 + + + + Mute/Unmute + 静音/取消静音 + + + + Playback Speed + 播放速度 + + + + Audio Track + 音轨 + + + + Subtitles + 字幕 + + + + Settings + 设置 + + + + Aspect Ratio + 画幅比例 + + + + Fullscreen + 全屏 + + + + + + + Danmaku + 弹幕 + + + + Local File + 本地文件 + + + + DandanPlay + 弹弹Play + + + + Unknown Source + 未知来源 + + + + + No danmaku loaded + 未加载弹幕 + + + + Loading danmaku... + 正在加载弹幕... + + + + + Unknown Danmaku + 未知弹幕 + + + + + %1 comments + %1 条弹幕 + + + + + Comment count unavailable + 弹幕条数未知 + + + + Current: %1 | %2 | %3 + 当前:%1 | %2 | %3 + + + + Server: %1 + 服务器:%1 + + + Danmaku Source: %1 Provider: %2 @@ -7593,6 +11606,7 @@ Comments: %3 弹幕条数:%3 + Danmaku Source: %1 Server: %2 @@ -7605,1394 +11619,3873 @@ Comments: %4 弹幕条数:%4 + Reload Danmaku 重新加载弹幕 Manual Match Danmaku - 手动匹配弹幕 + 手动匹配弹幕 + Hide Danmaku 隐藏弹幕 + Show Danmaku 显示弹幕 + + Danmaku Settings 弹幕设置 + + Subtitle Settings 字幕设置 + + Load Local Danmaku File 加载本地弹幕文件 + Search Danmaku 搜索弹幕 + + Danmaku (Loading...) 弹幕(加载中...) + + Danmaku (Hidden) 弹幕(已隐藏) Danmaku (Loaded) - 弹幕(已加载) + 弹幕(已加载) Danmaku (%1) - 弹幕(%1) + 弹幕(%1) Manual Danmaku Match - 手动匹配弹幕 + 手动匹配弹幕 + + Danmaku is unavailable for the current media 当前媒体暂不支持弹幕 + Danmaku Files (*.ass *.json *.xml);;ASS Files (*.ass);;JSON Files (*.json);;XML Files (*.xml) 弹幕文件 (*.ass *.json *.xml);;ASS 文件 (*.ass);;JSON 文件 (*.json);;XML 文件 (*.xml) Enter a keyword to search danmaku for the current video. - 输入一个关键词,为当前视频搜索弹幕。 + 输入一个关键词,为当前视频搜索弹幕。 Series name or episode title - 剧名或剧集标题 + 剧名或剧集标题 Search - 搜索 + 搜索 - - - - PlayerDanmakuController - - Danmaku Disabled - 弹幕已关闭 - - - Danmaku Enabled - 弹幕已开启 - - - Danmaku Loaded (%1) - 弹幕已加载(%1) - - - Danmaku Loaded - 弹幕已加载 - - - Danmaku Loaded from %1 (%2) - 已从 %1 加载弹幕(%2) - - - Danmaku Loaded from %1 - 已从 %1 加载弹幕 - - - No matching danmaku found - 未找到匹配的弹幕 - - - No danmaku available - 没有可用弹幕 - - - Failed to load danmaku - 加载弹幕失败 - - - Unsupported danmaku file - 不支持的弹幕文件 - - - Danmaku Shown - 弹幕已显示 - - - Danmaku Hidden - 弹幕已隐藏 - - - - - PlayerDanmakuIdentifyDialog - - Local File - 本地文件 - - - DandanPlay - 弹弹Play - - - Unknown Source - 未知来源 - - - %1 comments - %1 条弹幕 - - - Comment count unavailable - 弹幕条数未知 - - - Unknown Danmaku - 未知弹幕 - - - Source: %1 - 来源:%1 - - - Server: %1 - 服务器:%1 - - - Danmaku Server: %1 - 弹幕服务器:%1 - - - Provider: %1 - 来源类型:%1 - - - Title: %1 - 标题:%1 - - - Series: %1 - 系列:%1 - - - Comments: %1 - 弹幕条数:%1 - - - Unknown - 未知 - - - Score: %1 - 匹配分数:%1 - - - Episode: S%1E%2 - 剧集:S%1E%2 - - - Match Reason: %1 - 匹配依据:%1 - - - Target: %1 - 目标:%1 - - - Search Danmaku - 搜索弹幕 - - - this video - 当前视频 - - - Search danmaku sources for "%1" and choose the best match. - 为“%1”搜索弹幕来源,并选择最合适的匹配结果。 - - - Enter title or keyword - 输入标题或关键词 - - - Search - 搜索 - - - Search for local or online danmaku sources. - 搜索本地或在线弹幕来源。 - - - Search local files and enabled danmaku servers. - 搜索本地文件和已启用的弹幕服务器。 - - - Danmaku Details - 弹幕详情 - - - Select a danmaku source to view details. - 选择一个弹幕来源以查看详情。 - - - Cancel - 取消 - - - Load Danmaku - 加载弹幕 - - - Searching... - 搜索中... - - - Searching local files and enabled danmaku servers... - 正在搜索本地文件和已启用的弹幕服务器... - - - No matches found - 未找到匹配结果 - - - Found %1 matches - 找到 %1 个匹配结果 - - - Found %1 matches from %2 danmaku servers - 从 %2 个弹幕服务器找到 %1 个匹配结果 - - - Search failed - 搜索失败 - - - Failed to search danmaku: %1 - 搜索弹幕失败:%1 - - - Filter results (title, series, server) - 在结果中筛选(标题、系列、服务器) - - - Showing %1 of %2 matches for "%3" - 已筛选出 %1 / %2 条匹配「%3」的结果 - - - No results match "%1" (%2 total) - 没有匹配「%1」的结果(共 %2 条) - - - - - PlayerDanmakuSettingsDialog - - Danmaku Settings - 弹幕设置 - - - Adjust danmaku rendering without leaving playback. Changes are saved and applied immediately. - 无需离开播放页即可调整弹幕渲染设置。修改会立即保存并实时生效。 - - - ASS Subtitle Track - ASS 字幕轨渲染 - - - Native Smooth Renderer - 原生丝滑渲染 - - - Danmaku Renderer - 弹幕渲染方式 - - - Switch between the ASS subtitle track pipeline and qEmby's adaptive native danmaku renderer. - 在 ASS 字幕轨管线与 qEmby 自适应原生弹幕渲染器之间切换。 - - - Enable Danmaku - 开启弹幕 - - - Display streaming danmaku in the embedded player - 在内置播放器中显示流式弹幕 - - - Dual Subtitle Mode - 双字幕模式 - - - Keep regular subtitles visible alongside danmaku when possible - 尽量在显示弹幕时保留常规字幕 - - - Danmaku Opacity - 弹幕透明度 - - - Control the transparency of rendered danmaku - 控制渲染后弹幕的透明度 - - - Danmaku Size - 弹幕大小 - - - Danmaku Font Weight - 弹幕字重 - - - Danmaku Outline - 弹幕描边 - - - Danmaku Shadow - 弹幕阴影 - - - Display Area - 显示区域 - - - Limit how much of the screen height danmaku can occupy - 限制弹幕可占用的屏幕高度 - - - Danmaku Density - 弹幕密度 - - - Reduce or increase the number of simultaneous lanes - 减少或增加同时显示的弹幕轨道数量 - - - Danmaku Speed - 弹幕速度 - - - Adjust how quickly scrolling danmaku travels across the screen - 调整滚动弹幕在屏幕上的移动速度 - - - Danmaku Offset - 弹幕偏移 - - - Shift danmaku timing earlier or later to align with playback - 将弹幕时间整体提前或延后以匹配播放进度 - - - Hide Scrolling Danmaku - 隐藏滚动弹幕 - - - Hide standard right-to-left scrolling comments - 隐藏标准的从右向左滚动弹幕 - - - Hide Top Danmaku - 隐藏顶部弹幕 - - - Hide fixed danmaku positioned at the top of the screen - 隐藏固定显示在屏幕顶部的弹幕 - - - Hide Bottom Danmaku - 隐藏底部弹幕 - - - Hide fixed danmaku positioned at the bottom of the screen - 隐藏固定显示在屏幕底部的弹幕 - - - Blocked Keywords - 屏蔽关键词 - - - 40% - 40% - - - 55% - 55% - - - 72% - 72% - - - 85% - 85% - - - 100% - 100% - - - 80% - 80% - - - 120% - 120% - - - 140% - 140% - - - Low - 低 - - - Medium - 中 - - - High - 高 - - - Slow - 慢 - - - Normal - 正常 - - - Fast - 快 - - - -1000 ms - -1000 毫秒 - - - -500 ms - -500 毫秒 - - - 0 ms - 0 毫秒 - - - +500 ms - +500 毫秒 - - - +1000 ms - +1000 毫秒 - - - Close - 关闭 - - - - - DandanplayProvider - - DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings. - 弹弹Play 开放 API 现在要求提供 App ID 和 App Secret。请在弹幕服务器设置中完成配置。 - - - - - DanmakuServerDialog - - Danmaku Servers - 弹幕服务器 - - - Manage multiple danmaku servers here. Click a server card to edit it and choose the active server used for matching and loading danmaku. - 在这里管理多个弹幕服务器。点击某个服务器卡片即可编辑它,并选择实际用于匹配和加载弹幕的当前服务器。 - - - Current Server - 当前服务器 - - - Servers - 服务器列表 - - - Each item shows the danmaku server name, address, app credentials, and description. The built-in official DandanPlay server supports anime only. - 每个列表项会显示弹幕服务器名称、地址、App 凭据和说明。内置的官方弹弹Play 服务器仅支持动画。 - - - Add - 添加 - - - Remove - 删除 - - - Name - 名称 - - - e.g., Official DandanPlay - 例如:官方弹弹Play - - - Server URL - 服务器地址 - - - Edit Selected Server - 编辑当前服务器 - - - 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. - 修改当前所选弹幕服务器的名称、地址、说明和 App 凭据。官方弹弹Play 接口要求提供 App ID 和 App Secret,且仅支持动画。 - - - Description - 说明 - - - e.g., Supports anime only - 例如:仅支持动画 - - - App ID - App ID - - - App Secret - App Secret - - - Required for official DandanPlay - 官方弹弹Play 必填 - - - Cancel - 取消 - - - Save - 保存 - - - Danmaku Server %1 - 弹幕服务器 %1 - - - Remove Danmaku Server - 删除弹幕服务器 - - - Remove "%1" from the danmaku server list? - 要将“%1”从弹幕服务器列表中删除吗? - - - At least one danmaku server is required. - 至少需要保留一个弹幕服务器。 - - - At least one enabled danmaku server is required. - 至少需要保留一个已启用的弹幕服务器。 - - - OK - 确定 - - - Every danmaku server must have a name. - 每个弹幕服务器都必须填写名称。 - - - Every danmaku server must have a server URL. - 每个弹幕服务器都必须填写服务器地址。 - - - Server URL must be a valid http or https address. - 服务器地址必须是合法的 http 或 https 地址。 - - - - - DanmakuServerListItemWidget - - Remove Server - 删除服务器 - - - At least one server is required. - 至少需要保留一个服务器。 - - - App ID: %1 - App ID:%1 - - - Anime only - 仅支持动画 - - - App Secret: %1 - App Secret:%1 - - - - - PagePlayer - - Danmaku - 弹幕 - - - Enable Danmaku - 启用弹幕 - - - Display streaming danmaku in the embedded player - 在内置播放器中显示流式弹幕 - - - Danmaku Renderer - 弹幕渲染方式 - - - Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer - 选择通过 ASS 字幕轨还是 qEmby 自适应原生渲染器来显示弹幕 - - - ASS Subtitle Track - ASS 字幕轨渲染 - - - Native Smooth Renderer - 原生丝滑渲染 - - - Auto Load Danmaku - 自动加载弹幕 - - - Automatically search and load danmaku when playback starts - 播放开始时自动搜索并加载弹幕 - - - Auto Match Danmaku - 自动匹配弹幕 - - - Automatically choose the best danmaku match when no cached manual result exists - 当没有已缓存的手动匹配结果时,自动选择最合适的弹幕条目 - - - DandanPlay Open API - 弹弹Play 开放 API - - - Danmaku Server - 弹幕服务器 - - - Manage - 管理 - - - Prefer Online - 优先在线 - - - Prefer Local - 优先本地 - - - Online Only - 仅在线 - - - Local Only - 仅本地 - - - Danmaku Source - 弹幕来源 - - - Choose whether local danmaku files or online danmaku should be preferred during matching - 选择在匹配弹幕时优先使用本地弹幕文件还是在线弹幕结果 - - - Local Danmaku Folder - 本地弹幕文件夹 - - - Place .ass, .json, or .xml danmaku files here. Matching uses the current media title and manual keyword, and the selected danmaku source mode decides whether local files or online results are preferred first. -Folder: %1 - 将 .ass、.json 或 .xml 弹幕文件放到这里。匹配时会使用当前媒体标题和手动匹配关键词,并按所选的弹幕来源策略决定优先使用本地文件还是在线结果。 -文件夹:%1 - - - Open Folder - 打开文件夹 - - - Current server: %1 -Address: %2 - 当前服务器:%1 -地址:%2 - - - Current server: %1 - 当前服务器:%1 - - - Address: %1 - 地址:%1 - - - Supported content: Anime only - 支持内容:仅动画 - - - Description: %1 - 说明:%1 - - - App ID and App Secret are required for official DandanPlay Open API access - 使用官方弹弹Play 开放 API 必须提供 App ID 和 App Secret - - - Danmaku Provider - 弹幕服务 - - - Select which danmaku service to query for matches and comments - 选择用于匹配和获取弹幕的服务 - - - Load Related Comments - 加载关联弹幕 - - - Include related third-party comment sources when the provider supports it - 当服务支持时,一并加载关联的第三方弹幕来源 - - - Dual Subtitle Mode - 双字幕模式 - - - Keep regular subtitles visible alongside danmaku when possible - 在条件允许时让普通字幕与弹幕同时显示 - - - Danmaku Opacity - 弹幕透明度 - - - Control the transparency of rendered danmaku - 控制弹幕渲染后的透明度 - - - Danmaku Size - 弹幕大小 - - - Danmaku Font Weight - 弹幕字重 - - - Adjust the font weight used for danmaku text - 调整弹幕文字使用的字重 - - - Danmaku Outline - 弹幕描边 - - - Control the outline thickness applied to danmaku text - 控制弹幕文字描边的粗细 - - - Danmaku Shadow - 弹幕阴影 - - - Control the shadow offset applied to danmaku text - 控制弹幕文字阴影的偏移量 - - - Scale the font size used for danmaku rendering - 调整弹幕渲染使用的字体大小 - - - Display Area - 显示区域 - - - Limit how much of the screen height danmaku can occupy - 限制弹幕可占用的屏幕高度比例 - - - Danmaku Density - 弹幕密度 - - - Reduce or increase the number of simultaneous lanes - 减少或增加同时显示的弹幕轨道数量 - - - Danmaku Speed - 弹幕速度 - - - Adjust how quickly scrolling danmaku travels across the screen - 调整滚动弹幕在屏幕上的移动速度 - - - Danmaku Offset - 弹幕偏移 - - - Shift danmaku timing earlier or later to align with playback - 将弹幕时间整体提前或延后,以便和播放画面对齐 - - - Hide Scrolling Danmaku - 隐藏滚动弹幕 - - - Hide standard right-to-left scrolling comments - 隐藏标准从右向左滚动的弹幕 - - - Hide Top Danmaku - 隐藏顶部弹幕 - - - Hide fixed danmaku positioned at the top of the screen - 隐藏固定显示在顶部的弹幕 - - - Hide Bottom Danmaku - 隐藏底部弹幕 - - - Hide fixed danmaku positioned at the bottom of the screen - 隐藏固定显示在底部的弹幕 - - - Blocked Keywords - 屏蔽关键词 - - - Filter out danmaku containing these keywords - 过滤掉包含这些关键词的弹幕 - - - Provider Base URL - 服务地址 - - - Set a custom Danmaku API endpoint for official or compatible services - 设置官方或兼容弹幕服务使用的 API 地址 - - - Danmaku App ID - 弹幕 App ID - - - Optional - 可选 - - - Optional App ID for authenticated DandanPlay Open API requests - 用于已鉴权弹弹Play 开放 API 请求的可选 App ID - - - Optional App ID used by the selected danmaku server - 当前所选弹幕服务器使用的可选 App ID - - - Danmaku App Secret - 弹幕 App Secret - - - Optional App Secret used to sign DandanPlay Open API requests - 用于签名弹弹Play 开放 API 请求的可选 App Secret - - - Optional App Secret used by the selected danmaku server - 当前所选弹幕服务器使用的可选 App Secret - - - Danmaku Cache Duration - 弹幕缓存时间 - - - Reuse online match results, cached comment payloads, and generated ASS files for this long - 在这段时间内复用在线匹配结果、已缓存的弹幕数据和生成的 ASS 文件 - - - Clear Danmaku Cache - 清理弹幕缓存 - - - Remove cached online matches, cached comment payloads, and generated ASS files - 移除已缓存的在线匹配结果、弹幕数据和生成的 ASS 文件 - - - Danmaku cache cleared - 弹幕缓存已清理 - - - Slow - 慢 - - - Normal - 正常 - - - Fast - 快 - - - - - PlayerSubtitleSettingsDialog - - Auto - 自动 - - - Subtitle Settings - 字幕设置 - - - Adjust subtitle rendering without leaving playback. Changes are saved and applied immediately. - 无需离开播放页即可调整字幕渲染设置。修改会立即保存并实时生效。 - - - Subtitle Font - 字幕字体 - - - Choose the font family used for subtitle rendering - 选择字幕渲染使用的字体 - - - Subtitle Timing - 字幕时间 - - - Subtitle Size - 字幕大小 - - - Subtitle Position - 字幕位置 - - - Subtitle Scale - 字幕缩放 - - - Outline Size - 描边大小 - - - Shadow Offset - 阴影偏移 - - - - - MediaListModel - - Season %1 - 第 %1 季 - - - %1 min - %1 分钟 - - - %1 Episodes - %1 集 - - - - - PagePlayer - - Right Sidebar - 右侧边栏 - - - Bottom HUD - 底部 HUD - - - Media Switcher Position - 媒体切换位置 - - - Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD - 选择最近电影与电视剧季集切换显示在右侧边栏还是底部 HUD - - - - - PlayerView Seasons & Episodes - 季与剧集 + 季与剧集 + Switch season and episode 切换季和剧集 + Switch media 切换媒体 + Media Switcher 媒体切换 + + Loading... 加载中... + S%1E%2 %3 S%1E%2 %3 %1 - S%2E%3 - %4 - %1 - S%2E%3 - %4 + %1 - S%2E%3 - %4 %1 - %2 - %1 - %2 + %1 - %2 + Previous 上一个 + Next 下一个 + No previous episode 没有上一集 + No next episode 没有下一集 + No previous media 没有上一个媒体 + No next media 没有下一个媒体 - - PlayerMediaSwitcherPanel + PlayerViewModel + + Track + 音轨 + + + + Subtitle + 字幕 + + + + Loading danmaku… + 正在加载弹幕… + + + + Danmaku off + 弹幕已关闭 + + + + No danmaku + 无弹幕 + + + + Danmaku + 弹幕 + + + + %1 · %2 comments + %1 · %2 条弹幕 + + + + Episodes + 选集 + + + Continue Watching 继续观看 - Seasons - 季 + + No previous item + 没有上一项 - Episodes - 剧集 + + No next item + 没有下一项 - Loading... - 加载中... - - - No active media found. - 未找到继续观看的媒体。 - - - No episodes found. - 未找到剧集。 - - - S%1E%2 %3 - S%1E%2 %3 - - - Episode %1 %2 - 第 %1 集 %2 - - - %1 - S%2E%3 - %4 - %1 - S%2E%3 - %4 - - - %1 - %2 - %1 - %2 - - - %1 - %2 - %3 - %1 - %2 - %3 - - - Season %1 - 第 %1 季 + + Failed to switch media + 切换媒体失败 - - WebdavClient + ProxySettingsDialog - Authentication failed. Please check the username and password. - 身份验证失败,请检查用户名和密码。 + + Proxy Settings + 代理设置 - Access denied. The account does not have permission for this operation. - 访问被拒绝,当前账号没有执行此操作的权限。 + + Server Proxy Settings + 服务器代理设置 - Resource not found on the WebDAV server. - WebDAV 服务器上未找到该资源。 + + Use Global Proxy + 使用全局代理 - The WebDAV server does not allow this method on this resource. - WebDAV 服务器不允许对该资源执行此方法。 + + When enabled, this server uses the global proxy settings. Disable to configure a proxy specific to this server. + 开启后,此服务器将使用全局代理设置。关闭以为该服务器单独配置代理。 - Conflict. The parent directory may not exist on the WebDAV server. - 冲突。WebDAV 服务器上父目录可能不存在。 + + These settings apply as the default proxy for all servers, unless a server overrides them. + 这些设置作为所有服务器的默认代理,除非服务器另行覆盖。 - Precondition failed (HTTP 412). - 前置条件失败(HTTP 412)。 + + Proxy Mode + 代理模式 - The resource is locked by another client. - 该资源正被其他客户端锁定。 + + Do not use proxy + 不使用代理 - WebDAV server is temporarily unavailable. Please try again later. - WebDAV 服务器暂时不可用,请稍后再试。 + + Use system proxy + 使用系统代理 - Insufficient storage on the WebDAV server. - WebDAV 服务器存储空间不足。 + + Custom proxy + 自定义代理 - Could not reach the WebDAV server. - 无法连接到 WebDAV 服务器。 + + Type + 类型 - Network error: %1 - 网络错误:%1 + + Host + 主机 - Unexpected response from the WebDAV server. - WebDAV 服务器返回了意外的响应。 + + e.g. 127.0.0.1 + 例如:127.0.0.1 - %1 failed (HTTP %2): %3 - %1 失败(HTTP %2):%3 + + Port + 端口 - %1 failed: %2 - %1 失败:%2 + + 1 - 65535 + 1 - 65535 - Cannot download: relative path is empty. - 无法下载:相对路径为空。 + + Username + 用户名 - Cannot upload: relative path is empty. - 无法上传:相对路径为空。 + + + Optional + 可选 - Refused to delete the WebDAV root directory itself. - 已拒绝删除 WebDAV 根目录本身。 - - - - - WebdavSyncService - - Internal error: WebDAV profile store is unavailable. - 内部错误:WebDAV 配置存储不可用。 + + Password + 密码 - WebDAV profile is not configured. Please save a profile first. - 尚未配置 WebDAV 信息,请先保存配置。 - - - WebDAV profile is incomplete. Please fill in URL, username and password. - WebDAV 配置不完整,请填写服务器 URL、用户名和密码。 - - - Passphrase cannot be empty. - 密码短语不能为空。 - - - Passphrase is required for encrypted snapshots. - 加密快照需要密码短语。 - - - There is no local configuration to upload. - 当前没有可上传的本地配置。 - - - Failed to encrypt the snapshot. - 加密快照失败。 - - - Selected file is not a valid qEmby snapshot. - 所选文件不是有效的 qEmby 快照。 - - - Failed to decrypt the snapshot. The passphrase may be wrong or the file is corrupted. - 解密快照失败,密码短语可能错误或文件已损坏。 - - - Snapshot is not a valid qEmby configuration bundle. - 该快照不是有效的 qEmby 配置包。 - - - Refused to delete a file that is not a qEmby snapshot. - 已拒绝删除非 qEmby 快照的文件。 - - - - - WebdavSyncDialog - - Cloud Sync (WebDAV) - 云同步(WebDAV) - - - Server Configuration - 服务器配置 - - - Server URL: - 服务器 URL: - - - WebDAV username - WebDAV 用户名 - - - Username: - 用户名: - - - WebDAV password - WebDAV 密码 - - - Show Password - 显示密码 - - - Hide Password - 隐藏密码 - - - Password: - 密码: - - - Root directory: - 根目录: - - - Ignore SSL certificate errors - 忽略 SSL 证书错误 - - - HTTPS: - HTTPS: + + Bypass for local addresses + 本地地址直连 + Test Connection 测试连接 - Save Profile - 保存配置 + + Save + 保存 - Upload Current Configuration - 上传当前配置 + + Cancel + 取消 - Encrypt save - 加密保存 + + Invalid Proxy + 代理配置无效 - Custom tag: - 自定义标记: + + Please enter the proxy host. + 请输入代理主机地址。 - Snapshot tag - 快照标记 + + Please enter a valid port (1 - 65535). + 请输入有效端口(1 - 65535)。 - Upload Now - 立即上传 + + Testing... + 测试中... - Snapshots on Server - 服务器上的快照 + + ✓ Success (HTTP %1, %2 ms) + ✓ 成功(HTTP %1,%2 ms) + + ✗ Failed (%1) + ✗ 失败(%1) + + + + + ✗ %1 + ✗ %1 + + + + Probe URL used to verify the proxy connection. Leave empty to use the default. + 用于验证代理连接的探测地址。留空将使用内置默认地址。 + + + + Invalid test URL. + 测试地址无效。 + + + + Editing global proxy. Fill in the form below and save to apply it globally. + 正在编辑全局代理。在下方填写并保存即可应用为全局代理。 + + + + Loaded global proxy settings below. You can review or modify them before saving. + 已将全局代理设置加载到下方,可在保存前进行查看或修改。 + + + + QObject + + Books + 图书 + + + + + %1 min + %1 分钟 + + + + %1 h %2 min + %1 小时 %2 分钟 + + + + Season %1 + 第 %1 季 + + + + S%1 E%2 + S%1 E%2 + + + + Episode %1 + 第 %1 集 + + + + + Size: %1 + 大小:%1 + + + + Router + + + Navigation is not available. + 导航不可用。 + + + + Navigation error + 导航错误 + + + + The page could not be opened. + 无法打开页面。 + + + + Page migration in progress + 页面迁移进行中 + + + + This page is not available in the QML shell yet. + 此页面尚未在 QML 外壳中提供。 + + + + Page not found + 页面未找到 + + + + Unknown route: %1 + 未知路由:%1 + + + + The route is empty or invalid. + 路由为空或无效。 + + + + Page failed to load + 页面加载失败 + + + + The page component could not be created. + 无法创建页面组件。 + + + + Back + 返回 + + + + Home + 主页 + + + + SearchHistoryPopup + + Search History + 搜索记录 + + + Recent + 最近 + + + Count + 次数 + + + + Show More + 展开更多 + + + + Show Less + 收起 + + + + Sort by recent searches + 按最近搜索排序 + + + + Sort by most searched + 按热度排序 + + + + Clear search history + 清空搜索记录 + + + + Confirm clear all history + 再次点击清空全部记录 + + + + SearchPage + + + All + 全部 + + + + Movies + 电影 + + + + Shows + 剧集 + + + + Collections + 合集 + + + + People + 人物 + + + + Poster view + 海报视图 + + + + Wide view + 宽幅视图 + + + + Searching… + 搜索中… + + + + Search failed + 搜索失败 + + + + No matching media + 没有匹配的媒体 + + + + SearchView + + Search Results + 搜索结果 + + + All + 全部 + + + Movies + 电影 + + + Shows + 剧集 + + + Collections + 合集 + + + People + 人物 + + + Toggle View Mode + 切换视图模式 + + + 0 Results + 0 个结果 + + + Search: "%1" + 搜索: "%1" + + + Searching... + 正在搜索... + + + %1 Results + %1 个结果 + + + + SearchViewModel + + + 0 Results + 0 个结果 + + + + Search Results + 搜索结果 + + + + Search: "%1" + 搜索:“%1” + + + + Searching... + 搜索中… + + + + %1 Results + %1 个结果 + + + + Unable to load search results + 无法加载搜索结果 + + + + Error Loading Results + 加载结果出错 + + + + SeasonPage + + + Episode Number + 集数 + + + + Title + 标题 + + + + Premiere Date + 首播日期 + + + + Runtime + 时长 + + + + More… + 更多… + + + + ♥ Favorite + ♥ 已收藏 + + + + ♡ Favorite + ♡ 收藏 + + + + Remove season from favorites + 从收藏中移除本季 + + + + Add season to favorites + 将本季加入收藏 + + + Refresh 刷新 + + Episodes + 选集 + + + + Descending + 降序 + + + + Ascending + 升序 + + + + No episodes + 没有剧集 + + + + This season does not have any available episodes. + 本季没有可用剧集。 + + + + SeasonView + + Episode Number + 集数 + + + Title + 标题 + + + Premiere Date + 首映日期 + + + Runtime + 时长 + + + Loading... + 正在加载... + + + Unknown Series + 未知剧集 + + + Error Loading Episodes + 加载剧集失败 + + + ▶ Play + ▶ 播放 + + + ▶ Continue E%1 + ▶ 继续播放 第%1集 + + + 0 Episodes + 0 集 + + + %1 Episodes + %1 集 + + + + SeasonViewModel + + + 0 Episodes + 0 集 + + + + + + Play + 播放 + + + + + Loading... + 加载中… + + + + + Unknown Series + 未知剧集 + + + + + %1 Episodes + %1 集 + + + + + Error Loading Episodes + 加载剧集出错 + + + + + Unable to load this season's episodes. + 无法加载本季剧集。 + + + + Continue E%1 + 继续 E%1 + + + + Continue + 继续 + + + + ServerBrowserDialog + + + Browse Server Directory + 浏览服务器目录 + + + + Cancel + 取消 + + + + Select + 选择 + + + + Failed to load drives + 加载驱动器失败 + + + + Access denied or error + 访问被拒绝或发生错误 + + + + SettingsAboutPage + + + About + 关于 + + + + qEmby + qEmby + + + + Version %1 + 版本 %1 + + + + © 2026 AlanHJ. All rights reserved. + © 2026 AlanHJ. 保留所有权利。 + + + + GitHub Repository + GitHub 仓库 + + + + Report an Issue + 报告问题 + + + + This project is open-source software built with Qt 6 and modern C++. +Special thanks to the Emby and Jellyfin communities for their fantastic APIs. + 本项目是基于 Qt 6 与现代 C++ 的开源软件。 +特别感谢 Emby 与 Jellyfin 社区提供的优秀 API。 + + + + Acknowledgements + 致谢 + + + + People who help qEmby reach more users. + 帮助 qEmby 触达更多用户的人。 + + + + French translation + 法语翻译 + + + + Open Source Licenses + 开源许可证 + + + + SettingsAppearancePage + + + Appearance + 外观 + + + + Theme Mode + 主题模式 + + + + Switch application theme (takes effect immediately) + 切换应用主题(立即生效) + + + + System Default + 跟随系统 + + + + Light Mode + 浅色模式 + + + + Dark Mode + 深色模式 + + + + Font Size + 字体大小 + + + + Adjust the overall font size of the interface + 调整界面整体字号 + + + + Small + 小 + + + + Medium + 中等 + + + + Large + 大 + + + + Sidebar Position + 侧边栏位置 + + + + Place the navigation sidebar on the left or right side + 将导航侧边栏放在左侧或右侧 + + + + Left + 左侧 + + + + Right + 右侧 + + + + Pin Sidebar + 固定侧边栏 + + + + Keep the sidebar always visible and integrated into the main layout + 保持侧边栏始终可见并融入主布局 + + + + Custom Sidebar + 自定义侧边栏 + + + + Customize which items appear in the navigation sidebar + 自定义导航侧边栏显示的项目 + + + + Custom Sidebar Options + 自定义侧边栏选项 + + + + Hide Search Bar + 隐藏搜索栏 + + + + Hide Home Item + 隐藏主页项 + + + + Hide Favorites Item + 隐藏收藏项 + + + + Startup Window State + 启动窗口状态 + + + + Choose window state when the application launches + 选择应用启动时的窗口状态 + + + + Normal Window + 普通窗口 + + + + Maximized + 最大化 + + + + Reduce Animations + 减少动画 + + + + Reduce page transitions and hover effects for better performance + 减少页面切换与悬停效果以提升性能 + + + + Snapshot Navigation + 快照导航 + + + + Use static screenshots during page transitions for smoother animation on complex views + 在复杂页面切换时使用静态截图以获得更流畅的动画 + + + + Enable Search History + 启用搜索历史 + + + + Save recent searches locally for quick reuse + 在本地保存最近搜索以便快速复用 + + + + Search History Options + 搜索历史选项 + + + + Enable Search Autocomplete + 启用搜索自动完成 + + + + SettingsGeneralPage + + + General + 通用 + + + + Language + 语言 + + + + App interface language (requires restart) + 应用界面语言(需重启) + + + + System Default + 跟随系统 + + + + 简体中文 + 简体中文 + + + + English + 英语 + + + + French + 法语 + + + + Remember Last Server + 记住上次服务器 + + + + Automatically connect to the last used server on startup + 启动时自动连接上次使用的服务器 + + + + Close to Tray + 关闭到托盘 + + + + Minimize to system tray instead of exiting the application + 最小化到系统托盘而不是退出应用 + + + + Network Proxy + 网络代理 + + + + Proxy for the current server + 当前服务器的代理 + + + + Configure proxy settings for Emby/Jellyfin connections + 配置 Emby/Jellyfin 连接的代理设置 + + + + Configure... + 配置… + + + + Enable Logging + 启用日志 + + + + Record debug information to a log file for troubleshooting + 将调试信息写入日志文件以便排查问题 + + + + SettingsLibraryPage + + + Library + 媒体库 + + + + Show Continue Watching + 显示继续观看 + + + + Display unfinished media on the home screen + 在主页显示未看完的媒体 + + + + Show Latest Added + 显示最新添加 + + + + Display recently added media on the home screen + 在主页显示最近添加的媒体 + + + + Show Recommended + 显示推荐 + + + + Display recommended media on the home screen + 在主页显示推荐媒体 + + + + Show Media Libraries + 显示媒体库 + + + + Display media libraries on the home screen + 在主页显示媒体库 + + + + Show Each Library + 显示各媒体库分区 + + + + Display each library section individually on the home screen + 在主页分别显示每个媒体库分区 + + + + Show Completed Watching + 显示已看完 + + + + Display completed media on the home screen + 在主页显示已看完的媒体 + + + + Continue Watching Request Limit + 继续观看请求上限 + + + + + + + Full list request count. 0 means unlimited; home sections use the same value up to 50. + 完整列表请求数量。0 表示不限;主页分区使用相同值,最高 50。 + + + + + + + 0 = Unlimited + 0 = 不限 + + + + Latest Media Request Limit + 最新媒体请求上限 + + + + Recommended Request Limit + 推荐请求上限 + + + + Completed Watching Request Limit + 已看完请求上限 + + + + Adaptive Images + 自适应图片 + + + + When a specific image type is missing, automatically use another available type as fallback + 当特定图片类型缺失时,自动使用其他可用类型作为回退 + + + + Show Media Tooltips + 显示媒体提示 + + + + Show media details in tooltips when hovering over media cards + 悬停在媒体卡片上时显示详情提示 + + + + Show Favorite Folders + 显示收藏文件夹 + + + + Display favorite folders in the favorites page + 在收藏页显示收藏文件夹 + + + + Default Library View + 默认媒体库视图 + + + + Choose how media items are presented by default + 选择媒体项目的默认展示方式 + + + + Poster Wall + 海报墙 + + + + List View + 列表视图 + + + + Image Quality + 图片质量 + + + + Set image download quality (higher uses more bandwidth) + 设置图片下载质量(越高占用带宽越多) + + + + Low + 低 + + + + Medium + 中等 + + + + High + 高 + + + + Original + 原始 + + + + Shimmer Loading Animation + 闪烁加载动画 + + + + Show shimmer skeleton animation while loading dashboard content + 加载仪表盘内容时显示骨架闪烁动画 + + + + + Custom Home Section Order + 自定义主页分区顺序 + + + + Override the default order of the main home screen sections + 覆盖主页各分区的默认顺序 + + + + Drag-and-drop section reordering will be available in a future update. + 拖拽调整分区顺序将在后续更新中提供。 + + + + Cache Management + 缓存管理 + + + + Recommendation Cache Duration + 推荐缓存时长 + + + + Set how long to cache recommended content before refreshing + 设置推荐内容刷新前的缓存时长 + + + + 6 Hours + 6 小时 + + + + 12 Hours + 12 小时 + + + + + 1 Day + 1 天 + + + + + 3 Days + 3 天 + + + + + 7 Days + 7 天 + + + + Clear Recommendation Cache + 清除推荐缓存 + + + + Clear cached recommendations and refresh on next visit + 清除已缓存的推荐,并在下次访问时刷新 + + + + + Cache + 缓存 + + + + + Clear + 清除 + + + + Image Cache Duration + 图片缓存时长 + + + + How long to keep downloaded images in local cache + 下载的图片在本地缓存中保留多久 + + + + 14 Days + 14 天 + + + + 30 Days + 30 天 + + + + Clear Image Cache + 清除图片缓存 + + + + Remove all cached images from disk + 从磁盘移除所有缓存图片 + + + + SettingsPage + + + Settings + 设置 + + + + General + 通用 + + + + Appearance + 外观 + + + + Library + 媒体库 + + + + Player + 播放器 + + + + About + 关于 + + + + SettingsPlayerPage + + + + Player + 播放器 + + + + Hardware Decoding + 硬件解码 + + + + Select GPU acceleration method for video decoding + 选择视频解码的 GPU 加速方式 + + + + + Auto + 自动 + + + + VAAPI + VAAPI + + + + VDPAU + VDPAU + + + + CUDA (NVIDIA) + CUDA(NVIDIA) + + + + None (Software) + 无(软件) + + + + Video Output Driver + 视频输出驱动 + + + + Rendering backend for embedded player (Render API mode) + 内嵌播放器的渲染后端(Render API 模式) + + + + libmpv (Embedded) + libmpv(内嵌) + + + + Video Sync Mode + 视频同步模式 + + + + Method for synchronizing video frames to display + 将视频帧与显示同步的方法 + + + + Audio + 音轨 + + + + Display Resample (Default) + 显示重采样(默认) + + + + Display VSync + 显示垂直同步 + + + + Tone Mapping + 色调映射 + + + + HDR to SDR tone mapping algorithm + HDR 到 SDR 色调映射算法 + + + + Mobius + Mobius + + + + BT.2390 + BT.2390 + + + + Hable + Hable + + + + Reinhard + Reinhard + + + + Preferred Audio Language + 首选音轨语言 + + + + + Rules are matched in order; choose presets or type custom keywords and press Enter + 规则按顺序匹配;选择预设或输入自定义关键词后按回车 + + + + e.g., chi,eng,jpn + 例如:chi,eng,jpn + + + + Preferred Subtitle Language + 首选字幕语言 + + + + e.g., chi,eng + 例如:chi,eng + + + + Preferred Video Version + 首选视频版本 + + + + Choose keyword or sort rules in order; type custom keywords and press Enter + 按顺序选择关键词或排序规则;输入自定义关键词后按回车 + + + + e.g., original,2160p,1080p + 例如:original,2160p,1080p + + + + Continuous Playback + 连续播放 + + + + Automatically play the next episode or media when playback ends + 播放结束时自动播放下一集或下一项媒体 + + + + Long Press Left/Right + 长按左右键 + + + + Enable long press actions for left/right keys during playback + 播放时启用左右键长按操作 + + + + Long Press Options + 长按选项 + + + + Seek Step + 跳转步长 + + + + Number of seconds to skip when pressing forward or rewind + 快进或快退时跳过的秒数 + + + + 3 Seconds + 3 秒 + + + + 5 Seconds + 5 秒 + + + + 10 Seconds + 10 秒 + + + + 15 Seconds + 15 秒 + + + + 30 Seconds + 30 秒 + + + + Long Press Trigger Time + 长按触发时间 + + + + Set how long left/right keys must be held before the long press action starts + 设置左右键需按住多久才触发长按操作 + + + + 300 ms + 300 毫秒 + + + + 500 ms + 500 毫秒 + + + + 800 ms + 800 毫秒 + + + + 1000 ms + 1000 毫秒 + + + + Long Press Mode + 长按模式 + + + + Choose whether holding left/right keys performs time jump seek or temporary speed playback + 选择长按左右键是时间跳转还是临时变速播放 + + + + Time Jump Playback + 时间跳转播放 + + + + Speed Playback + 变速播放 + + + + Mouse Edge Long Press + 鼠标边缘长按 + + + + Hold the left or right side of the playback area for speed rewind or fast forward + 按住播放区域左侧或右侧进行变速快退或快进 + + + + Media Switcher Position + 媒体切换器位置 + + + + Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD + 选择最近影片与季/集切换显示在右侧边栏还是底部 HUD + + + + Right Sidebar + 右侧边栏 + + + + Bottom HUD + 底部 HUD + + + + Click to Pause + 单击暂停 + + + + Single click on the video area to toggle play/pause + 单击视频区域切换播放/暂停 + + + + Auto Skip Intro + 自动跳过片头 + + + + Automatically skip the intro sequence when playback starts + 播放开始时自动跳过片头 + + + + Auto Skip Outro + 自动跳过片尾 + + + + Automatically skip the ending credits during playback + 播放时自动跳过片尾字幕 + + + + Independent Player Window + 独立播放器窗口 + + + + Open video in a separate detached window + 在独立窗口中打开视频 + + + + Volume Normalization + 音量标准化 + + + + Automatically adjust volume to prevent sudden loud noises + 自动调整音量以避免突然过大的声音 + + + + Use Custom mpv.conf + 使用自定义 mpv.conf + + + + Load settings from an external mpv.conf configuration file + 从外部 mpv.conf 配置文件加载设置 + + + + mpv.conf + mpv.conf + + + + Config path: ~/<config>/mpv/mpv.conf + 配置路径:~/<config>/mpv/mpv.conf + + + + Open File + 打开文件 + + + + Danmaku + 弹幕 + + + + Enable Danmaku + 启用弹幕 + + + + Display streaming danmaku in the embedded player + 在内嵌播放器中显示流式弹幕 + + + + Danmaku Renderer + 弹幕渲染器 + + + + Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer + 选择通过 ASS 字幕轨还是 qEmby 自适应原生渲染器绘制弹幕 + + + + ASS Subtitle Track + ASS 字幕轨 + + + + Native Smooth Renderer + 原生流畅渲染器 + + + + Danmaku Source + 弹幕来源 + + + + Choose whether local danmaku files or online danmaku should be preferred during matching + 匹配时优先使用本地弹幕文件还是在线弹幕 + + + + Prefer Local + 优先本地 + + + + Prefer Online + 优先在线 + + + + Online Only + 仅在线 + + + + Local Only + 仅本地 + + + + Auto Load Danmaku + 自动加载弹幕 + + + + Automatically search and load danmaku when playback starts + 播放开始时自动搜索并加载弹幕 + + + + Auto Match Danmaku + 自动匹配弹幕 + + + + Automatically choose the best danmaku match when no cached manual result exists + 没有缓存的手动结果时自动选择最佳弹幕匹配 + + + + Load Related Comments + 加载相关评论 + + + + Include related third-party comment sources when the provider supports it + 在提供方支持时包含相关第三方评论源 + + + + Dual Subtitle Mode + 双字幕模式 + + + + Keep regular subtitles visible alongside danmaku when possible + 尽可能在显示弹幕时保留普通字幕 + + + + Danmaku Opacity + 弹幕不透明度 + + + + Control the transparency of rendered danmaku + 控制渲染弹幕的透明度 + + + + Danmaku Size + 弹幕大小 + + + + Scale the font size used for danmaku rendering + 缩放弹幕渲染使用的字号 + + + + Display Area + 显示区域 + + + + Limit how much of the screen height danmaku can occupy + 限制弹幕可占用的屏幕高度 + + + + Danmaku Density + 弹幕密度 + + + + Reduce or increase the number of simultaneous lanes + 减少或增加同时显示的弹幕轨道数 + + + + Danmaku Speed + 弹幕速度 + + + + Adjust how quickly scrolling danmaku travels across the screen + 调整滚动弹幕划过屏幕的速度 + + + + Danmaku Offset + 弹幕时间偏移 + + + + Shift danmaku timing earlier or later to align with playback + 将弹幕时间提前或延后以对齐播放 + + + + Hide Scrolling Danmaku + 隐藏滚动弹幕 + + + + Hide standard right-to-left scrolling comments + 隐藏从右到左的标准滚动弹幕 + + + + Hide Top Danmaku + 隐藏顶部弹幕 + + + + Hide fixed danmaku positioned at the top of the screen + 隐藏固定在屏幕顶部的弹幕 + + + + Hide Bottom Danmaku + 隐藏底部弹幕 + + + + Hide fixed danmaku positioned at the bottom of the screen + 隐藏固定在屏幕底部的弹幕 + + + + Blocked Keywords + 屏蔽关键词 + + + + Filter out danmaku containing these keywords + 过滤包含这些关键词的弹幕 + + + + Enter keywords separated by commas + 输入关键词,用逗号分隔 + + + + Danmaku Cache Duration + 弹幕缓存时长 + + + + Reuse online match results, cached comment payloads, and generated ASS files for this long + 在此时长内复用在线匹配结果、缓存评论数据与生成的 ASS 文件 + + + + 6 Hours + 6 小时 + + + + 12 Hours + 12 小时 + + + + 1 Day + 1 天 + + + + 3 Days + 3 天 + + + + 7 Days + 7 天 + + + + External Player + 外部播放器 + + + + Enable External Player + 启用外部播放器 + + + + Supported players on this platform: %1 + 此平台支持的播放器:%1 + + + + Choose which external player to launch + 选择要启动的外部播放器 + + + + MPV + MPV + + + + VLC + VLC + + + + MPC-Qt + MPC-Qt + + + + Arguments + 参数 + + + + Additional command-line arguments passed to the external player + 传递给外部播放器的额外命令行参数 + + + + e.g., --fullscreen --volume=100 + 例如:--fullscreen --volume=100 + + + + Quick Play with External Player + 使用外部播放器快速播放 + + + + Use external player for quick play actions on cards and menus + 卡片与菜单上的快速播放使用外部播放器 + + + + Direct Stream + 直接串流 + + + + Bypass server proxy and stream directly from source. May not work on external networks + 绕过服务器代理,直接从来源串流。外网可能不可用 + + + + SettingsView + + + Settings + 设置 + + + + General + 通用 + + + + Appearance + 外观 + + + + Player + 播放器 + + + + Library + 媒体库 + + + + About + 关于 + + + + SideBar + + + Search... + 搜索… + + + + Home + 主页 + + + + Favorites + 收藏 + + + + MEDIA + 媒体 + + + + Settings + 设置 + + + + Manage + 管理 + + + + Logout + 退出登录 + + + + SidebarViewModel + + + My Server + 我的服务器 + + + + SplitPlayerButton + + + Play with %1 + 使用 %1 播放 + + + + TaskEditDialog + + + Overview + 概览 + + + + + Cancel + 取消 + + + + Task Summary + 任务摘要 + + + + State + 状态 + + + + Category + 分类 + + + + Last Result + 上次结果 + + + + Last Finished + 上次完成时间 + + + + Schedule + 计划 + + + + Visibility + 可见性 + + + + Scheduling Notes + 计划说明 + + + + Trigger Rules + 触发规则 + + + + Loading task details... + 正在加载任务详情... + + + + Configured Triggers + 已配置触发器 + + + + Triggers + 触发器 + + + + + + Add Trigger + 添加触发器 + + + + Trigger Type + 触发类型 + + + + Daily + 每日 + + + + Interval + 间隔 + + + + Weekly + 每周 + + + + On Startup + 系统启动时 + + + + + Time + 时间 + + + + Every + 每隔 + + + + 15 minutes + 15 分钟 + + + + 30 minutes + 30 分钟 + + + + 45 minutes + 45 分钟 + + + + 1 hour + 1 小时 + + + + 2 hours + 2 小时 + + + + 3 hours + 3 小时 + + + + 4 hours + 4 小时 + + + + 6 hours + 6 小时 + + + + 8 hours + 8 小时 + + + + 12 hours + 12 小时 + + + + 24 hours + 24 小时 + + + + Day of Week + 星期 + + + + Sunday + 星期日 + + + + Monday + 星期一 + + + + Tuesday + 星期二 + + + + Wednesday + 星期三 + + + + Thursday + 星期四 + + + + Friday + 星期五 + + + + Saturday + 星期六 + + + + Task will run when the server starts. + 任务将在服务器启动时运行。 + + + + Time Limit (hours) + 时间限制(小时) + + + + Optional + 可选 + + + Add + 添加 + + + + Save + 保存 + + + + Remove + 移除 + + + + Max runtime %1 + 最长运行时间 %1 + + + + Read-only + 只读 + + + + This is a built-in system task reported by the server. Some properties may be managed by the server. + 这是服务端返回的内建系统任务,部分属性可能由服务端管理。 + + + + Built-in trigger types are shown as read-only and will be preserved when you save. + 内建触发器类型会以只读方式显示,并会在保存时保留。 + + + + Changes in this dialog only update trigger rules. Start or stop a task from the tasks page. + 此弹窗中的更改只会更新触发规则。任务的启动和停止请在任务页操作。 + + + + Built-in server triggers are shown as read-only and will be preserved when you save. + 服务端内建触发器会以只读方式显示,并会在保存时保留。 + + + + Add, remove, and save trigger rules for this task. + 可在这里为当前任务添加、移除并保存触发规则。 + + + + Please enter a valid time limit. + 请输入有效的时间限制。 + + + + This trigger already exists. + 该触发器已存在。 + + + + No description provided. + 暂无描述。 + + + + Other + 其他 + + + + System task + 系统任务 + + + + Visible task + 可见任务 + + + + Never run + 从未运行 + + + + Not available + 不可用 + + + + Built-in + 内建 + + + Daily at %1 + 每天 %1 + + + Every %n hour(s) + 每 %n 小时 + + Every %n minute(s) + 每 %n 分钟 + + %1 at %2 + %1 %2 + + + On application startup + 应用程序启动时 + + + Delete trigger + 删除触发器 + + + + No triggers configured + 未配置触发器 + + + Last: %1 + 上次:%1 + + + + Failed to load task: %1 + 加载任务失败:%1 + + + + Triggers saved + 触发器已保存 + + + + Failed to save triggers: %1 + 保存触发器失败:%1 + + + + TaskUtils + + Sunday + 星期日 + + + Monday + 星期一 + + + Tuesday + 星期二 + + + Wednesday + 星期三 + + + Thursday + 星期四 + + + Friday + 星期五 + + + Saturday + 星期六 + + + display configuration changes + 显示配置发生变化 + + + the server wakes from sleep + 服务器从睡眠中唤醒 + + + plugins are updated + 插件被更新 + + + No limit + 不限 + + + %n day(s) + %n 天 + + %n hour(s) + %n 小时 + + %n minute(s) + %n 分钟 + + Less than a minute + 少于 1 分钟 + + + Idle + 空闲 + + + Running + 运行中 + + + Cancelling + 正在取消 + + + Completed + 已完成 + + + Failed + 失败 + + + Aborted + 已中止 + + + Cancelled + 已取消 + + + Daily + 每日 + + + Interval + 间隔 + + + Weekly + 每周 + + + On Startup + 系统启动时 + + + System Event + 系统事件 + + + Daily at %1 + 每天 %1 + + + Every %1 + 每隔 %1 + + + %1 at %2 + %1 %2 + + + On server startup + 服务器启动时 + + + When %1 + 当 %1 时 + + + Manual only + 仅手动运行 + + + +%n more + 另有 %n 项 + + + TasksViewModel + + + Running Now + 正在运行 + + + + System Tasks + 系统任务 + + + + Scheduled Tasks + 计划任务 + + + + Manual Tasks + 手动任务 + + + + Tasks that are currently executing. Progress refreshes automatically. + 当前正在执行的任务。进度会自动刷新。 + + + + Hidden or built-in tasks reported by the server. + 服务器报告的隐藏或内置任务。 + + + + Tasks with one or more configured triggers. + 配置了一个或多个触发器的任务。 + + + + Tasks that only run when started manually. + 仅在手动启动时运行的任务。 + + + + Other + 其他 + + + + System + 系统 + + + + Scheduled + 已计划 + + + + Manual + 手动 + + + + State: %1 + 状态:%1 + + + + Last result: %1 + 上次结果:%1 + + + + Failed to load tasks: %1 + 加载任务失败:%1 + + + + Task "%1" stopped + 任务“%1”已停止 + + + + Task "%1" started + 任务“%1”已启动 + + + + Failed to stop task: %1 + 停止任务失败:%1 + + + + Failed to run task: %1 + 运行任务失败:%1 + + + + No tasks + 没有任务 + + + + %n task(s) + %n 项任务 + + + %n running + %n 项运行中 + + + %n system + %n 项系统任务 + + + TextInputDialog + + + Cancel + 取消 + + + + OK + 确定 + + + + ThemedDialog + + + Confirm + 确认 + + + + Cancel + 取消 + + + + Destructive action + 危险操作 + + + + TitleBar + + + Search... + 搜索… + + + + ToastHost + + + Success + 成功 + + + + Warning + 警告 + + + + Error + 错误 + + + + Information + 信息 + + + + %1: %2 + %1:%2 + + + + Dismiss notification + 关闭通知 + + + + TranscodingViewModel + + + Saving… + 保存中… + + + + Loading… + 加载中… + + + + Unavailable + 不可用 + + + + Not loaded + 未加载 + + + + Jellyfin encoding configuration + Jellyfin 编码配置 + + + + Emby encoding configuration + Emby 编码配置 + + + + Failed to load transcoding settings: %1 + 加载转码设置失败:%1 + + + + Transcoding settings saved + 转码设置已保存 + + + + Failed to save transcoding settings: %1 + 保存转码设置失败:%1 + + + + TrayManager + + + Show qEmby + 显示 qEmby + + + + Quit + 退出 + + + + UserDashboardViewModel + + + Continue Watching + 继续观看 + + + + Latest Media + 最新媒体 + + + + Recommended + 推荐 + + + + Completed Watching + 已看完 + + + + Unable to load the home page + 无法加载主页 + + + + Unable to load this library + 无法加载此媒体库 + + + + UserEditDialog + + + User Settings + 用户设置 + + + + + User Settings — %1 + 用户设置 — %1 + + + + Name + 名称 + + + + This is how the user will appear on your server. + 这就是用户将出现在您的服务器上的方式。 + + + + Enter user name + 输入用户名 + + + + Administrator + 管理员 + + + + Allow remote access + 允许远程访问 + + + + Playback + 播放 + + + + Allow media playback + 允许媒体回放 + + + + Allow audio transcoding + 允许音频转码 + + + + Allow video transcoding + 允许视频转码 + + + Features + 功能 + + + + Live TV access + 电视直播访问 + + + + Live TV recording management + 电视直播录制管理 + + + Allow media deletion + 允许删除媒体 + + + + Remote control of other users + 远程控制其他用户 + + + + Shared device remote control + 共享设备远程控制 + + + + Allow media downloading + 允许媒体下载 + + + + Visibility + 可见性 + + + + Hide from login screen + 在登录界面隐藏 + + + + Hide from login screen (remote) + 在远程登录界面隐藏 + + + + Hide from unused devices + 在未使用设备上隐藏 + + + + + Save + 保存 + + + + Profile + 配置 + + + + Allow access to all libraries + 允许访问所有媒体库 + + + + + Access + 访问 + + + + Maximum parental rating + 最大家长分级 + + + + + None + 无限制 + + + + Parental Control + 家长控制 + + + + New password + 新密码 + + + + Enter new password + 输入新密码 + + + + Confirm password + 确认密码 + + + + Re-enter new password + 再次输入新密码 + + + + Save Password + 保存密码 + + + + Password + 密码 + + + + Failed to load user data: %1 + 加载用户数据失败:%1 + + + + Settings saved + 设置已保存 + + + + Failed to save: %1 + 保存失败:%1 + + + + User name cannot be empty + 用户名不能为空 + + + + Passwords do not match + 两次输入的密码不一致 + + + + Password updated + 密码已更新 + + + + Failed to update password: %1 + 更新密码失败:%1 + + + + Cancel + 取消 + + + + Emby Connect email + Emby Connect 邮箱 + + + + Optional connect email + 可选的 Connect 邮箱 + + + + Link this user to an Emby Connect account by email. + 通过邮箱将此用户关联到 Emby Connect 账户。 + + + + Disable user + 禁用用户 + + + + Allow changing image and password + 允许更改头像和密码 + + + + Authentication provider + 身份验证提供程序 + + + + Allow video remuxing + 允许视频转封装 + + + + Simultaneous stream limit + 同时流数量限制 + + + + Unlimited + 无限制 + + + + Remote client bitrate limit + 远程客户端码率限制 + + + + Mbps + Mbps + + + + Feature Access + 功能访问 + + + + Allow media conversion + 允许媒体转换 + + + + Allow link sharing + 允许链接分享 + + + + Downloads + 下载 + + + + Allow sync transcoding + 允许同步转码 + + + + Subtitles + 字幕 + + + + Allow subtitle downloading + 允许下载字幕 + + + + Allow subtitle management + 允许管理字幕 + + + + Media Deletion + 媒体删除 + + + + Allow media deletion from all libraries + 允许从所有媒体库删除媒体 + + + + Channel Access + 频道访问 + + + + Allow access to all channels + 允许访问所有频道 + + + + Device Access + 设备访问 + + + + Allow access on all devices + 允许在所有设备上访问 + + + + Block unrated items + 屏蔽未分级项目 + + + + Books + 图书 + + + + Games + 游戏 + + + + Channel content + 频道内容 + + + + Live TV + 电视直播 + + + + Movies + 电影 + + + + Music + 音乐 + + + + Trailers + 预告片 + + + + TV shows + 电视剧 + + + + Tag Restrictions + 标签限制 + + + + Blocked tags + 已屏蔽标签 + + + + Tag restriction mode + 标签限制模式 + + + + Block items with these tags + 屏蔽含这些标签的项目 + + + + Allow items with these tags + 仅允许含这些标签的项目 + + + + Restriction match mode + 限制匹配模式 + + + + Match all restrictions + 匹配全部限制 + + + + Match any restriction + 匹配任一限制 + + + + Access Schedule + 访问计划 + + + + Limit when this user can access the server. + 限制该用户可以访问服务器的时间段。 + + + + Add + 添加 + + + + Remove + 移除 + + + + Sunday + 星期日 + + + + Monday + 星期一 + + + + Tuesday + 星期二 + + + + Wednesday + 星期三 + + + + Thursday + 星期四 + + + + Friday + 星期五 + + + + Saturday + 星期六 + + + + Schedule start time must be earlier than end time + 访问计划开始时间必须早于结束时间 + + + + UsersViewModel + + + Failed to load users: %1 + 加载用户失败:%1 + + + + User "%1" enabled + 用户“%1”已启用 + + + + User "%1" disabled + 用户“%1”已禁用 + + + + Failed to update user: %1 + 更新用户失败:%1 + + + + User "%1" deleted + 用户“%1”已删除 + + + + Failed to delete user: %1 + 删除用户失败:%1 + + + + WebdavClient + + Authentication failed. Please check the username and password. + 身份验证失败,请检查用户名和密码。 + + + Access denied. The account does not have permission for this operation. + 访问被拒绝,当前账号没有执行此操作的权限。 + + + Resource not found on the WebDAV server. + WebDAV 服务器上未找到该资源。 + + + The WebDAV server does not allow this method on this resource. + WebDAV 服务器不允许对该资源执行此方法。 + + + Conflict. The parent directory may not exist on the WebDAV server. + 冲突。WebDAV 服务器上父目录可能不存在。 + + + Precondition failed (HTTP 412). + 前置条件失败(HTTP 412)。 + + + The resource is locked by another client. + 该资源正被其他客户端锁定。 + + + WebDAV server is temporarily unavailable. Please try again later. + WebDAV 服务器暂时不可用,请稍后再试。 + + + Insufficient storage on the WebDAV server. + WebDAV 服务器存储空间不足。 + + + Could not reach the WebDAV server. + 无法连接到 WebDAV 服务器。 + + + Network error: %1 + 网络错误:%1 + + + Unexpected response from the WebDAV server. + WebDAV 服务器返回了意外的响应。 + + + %1 failed (HTTP %2): %3 + %1 失败(HTTP %2):%3 + + + %1 failed: %2 + %1 失败:%2 + + + Cannot download: relative path is empty. + 无法下载:相对路径为空。 + + + Cannot upload: relative path is empty. + 无法上传:相对路径为空。 + + + Refused to delete the WebDAV root directory itself. + 已拒绝删除 WebDAV 根目录本身。 + + + + WebdavPassphrasePrompt + + + + Show Password + 显示密码 + + + + Hide Password + 隐藏密码 + + + + Toggle passphrase visibility + 切换密码短语可见性 + + + + Cancel + 取消 + + + + OK + 确定 + + + + Sync passphrase + 同步密码短语 + + + + WebdavSyncDialog + + + Cloud Sync (WebDAV) + 云同步(WebDAV) + + + + Server Configuration + 服务器配置 + + + + Server URL: + 服务器 URL: + + + + WebDAV username + WebDAV 用户名 + + + + Username: + 用户名: + + + + WebDAV password + WebDAV 密码 + + + + + Show Password + 显示密码 + + + + Hide Password + 隐藏密码 + + + + Password: + 密码: + + + + Root directory: + 根目录: + + + + Ignore SSL certificate errors + 忽略 SSL 证书错误 + + + + HTTPS: + HTTPS: + + + + + Test Connection + 测试连接 + + + + Save Profile + 保存配置 + + + + Upload Current Configuration + 上传当前配置 + + + + Encrypt save + 加密保存 + + + + Custom tag: + 自定义标记: + + + + Snapshot tag + 快照标记 + + + + Upload Now + 立即上传 + + + + Snapshots on Server + 服务器上的快照 + + + + Refresh + 刷新 + + + Device 设备 + Version 版本 + OS 操作系统 + Created 创建时间 + Size 大小 + File Name 文件名 + Device: %1 Version: %2 OS: %3 @@ -9007,38 +15500,47 @@ File Name: %6 文件名:%6 + Apply strategy: 应用策略: + Replace 覆盖 + Merge 合并 + Local wins 本地优先 + Delete Selected 删除所选 + Apply to Local 应用到本地 + Last sync: %1 上次同步:%1 + Last sync: never 上次同步:从未 + This device will be uploaded as: %1 本机将被上传为: @@ -9046,144 +15548,171 @@ File Name: %6 Not tested - 未测试 + 未测试 + + Invalid profile 配置无效 + Please fill in the server URL, username and password before testing. 测试前请先填写服务器 URL、用户名和密码。 + Please fill in the server URL, username and password before saving. 保存前请先填写服务器 URL、用户名和密码。 + Internal error 内部错误 + Profile store is unavailable. 配置存储不可用。 + Testing connection... 正在测试连接... Connecting... - 正在连接... + 正在连接... + Connection OK 连接成功 WebDAV connection succeeded - WebDAV 连接成功 + WebDAV 连接成功 + Failed 失败 Connection failed - 连接失败 + 连接失败 Server replied but the response is not valid WebDAV. - 服务器有响应,但不是有效的 WebDAV 应答。 + 服务器有响应,但不是有效的 WebDAV 应答。 + Profile saved 配置已保存 + Save failed 保存失败 + qEmby could not write to %1. qEmby 无法写入 %1。 + Profile not configured 尚未配置 + Please save the WebDAV profile before uploading. 上传前请先保存 WebDAV 配置。 + Encrypt Snapshot 加密快照 + Enter a passphrase to encrypt this snapshot. You will need the same passphrase to restore it on another device. 请输入用于加密本快照的密码短语。 还原到其他设备时需要使用相同的密码短语。 + Upload 上传 + Uploading snapshot... 正在上传快照... Uploaded: %1 - 已上传:%1 + 已上传:%1 Upload failed - 上传失败 + 上传失败 + Loading snapshot list... 正在加载快照列表... Failed to list snapshots - 无法获取快照列表 + 无法获取快照列表 Decrypt Snapshot - 解密快照 + 解密快照 Enter the passphrase used when this snapshot was created. - 请输入创建此快照时使用的密码短语。 + 请输入创建此快照时使用的密码短语。 + Apply 应用 + + Cancel 取消 + Replace local config entirely 完全覆盖本地配置 + Merge (cloud wins on conflict) 合并(冲突时云端优先) + Local wins (only fill missing keys) 本地优先(仅补齐本地缺失项) + Confirm apply 确认应用 - Apply snapshot "%1" with strategy: + + Apply snapshot "%1" with strategy: %2 This will overwrite parts of your local configuration. Continue? @@ -9193,27 +15722,30 @@ This will overwrite parts of your local configuration. Continue? 此操作会覆盖本地配置的部分内容。是否继续? + Downloading snapshot... 正在下载快照... Snapshot applied - 快照已应用 + 快照已应用 Apply failed - 应用失败 + 应用失败 Bundle decoded but failed to write to local. - 已成功解析配置包,但写入本地失败。 + 已成功解析配置包,但写入本地失败。 + Delete snapshot 删除快照 - Delete "%1" from the WebDAV server? + + Delete "%1" from the WebDAV server? This cannot be undone. 是否从 WebDAV 服务器删除 “%1”? @@ -9221,49 +15753,69 @@ This cannot be undone. 此操作不可撤销。 + Delete 删除 + Deleting snapshot... 正在删除快照... Snapshot deleted - 快照已删除 + 快照已删除 Delete failed - 删除失败 + 删除失败 - - WebdavPassphrasePrompt + WebdavSyncService - Show Password - 显示密码 + Internal error: WebDAV profile store is unavailable. + 内部错误:WebDAV 配置存储不可用。 - Hide Password - 隐藏密码 + WebDAV profile is not configured. Please save a profile first. + 尚未配置 WebDAV 信息,请先保存配置。 - Toggle passphrase visibility - 切换密码短语可见性 + WebDAV profile is incomplete. Please fill in URL, username and password. + WebDAV 配置不完整,请填写服务器 URL、用户名和密码。 - Cancel - 取消 + Passphrase cannot be empty. + 密码短语不能为空。 - OK - 确定 + Passphrase is required for encrypted snapshots. + 加密快照需要密码短语。 - Sync passphrase - 同步密码短语 + There is no local configuration to upload. + 当前没有可上传的本地配置。 + + + Failed to encrypt the snapshot. + 加密快照失败。 + + + Selected file is not a valid qEmby snapshot. + 所选文件不是有效的 qEmby 快照。 + + + Failed to decrypt the snapshot. The passphrase may be wrong or the file is corrupted. + 解密快照失败,密码短语可能错误或文件已损坏。 + + + Snapshot is not a valid qEmby configuration bundle. + 该快照不是有效的 qEmby 配置包。 + + + Refused to delete a file that is not a qEmby snapshot. + 已拒绝删除非 qEmby 快照的文件。 - diff --git a/src/qEmbyApp/views/admin/activitylogdelegate.cpp b/src/qEmbyApp/views/admin/activitylogdelegate.cpp deleted file mode 100644 index cfcebbd..0000000 --- a/src/qEmbyApp/views/admin/activitylogdelegate.cpp +++ /dev/null @@ -1,162 +0,0 @@ -#include "activitylogdelegate.h" -#include "../../managers/thememanager.h" - -#include -#include -#include -#include -#include - - -ActivityLogThemeHelper::ActivityLogThemeHelper(QWidget *parent) - : QWidget(parent), - m_cardBg(51, 65, 85, 102), - m_borderColor(71, 85, 105, 77), - m_nameColor(241, 245, 249), - m_overviewColor(148, 163, 184), - m_timeColor(100, 116, 139) -{ - hide(); -} - - -ActivityLogDelegate::ActivityLogDelegate(QObject* parent) - : QStyledItemDelegate(parent) -{ -} - -void ActivityLogDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const -{ - - if (!m_themeHelper && option.widget) { - m_themeHelper = new ActivityLogThemeHelper(const_cast(option.widget)); - m_themeHelper->setObjectName("activity-log-theme"); - option.widget->style()->polish(m_themeHelper); - } - - - QColor cardBg = m_themeHelper ? m_themeHelper->cardBg() : QColor(51, 65, 85, 102); - QColor borderColor = m_themeHelper ? m_themeHelper->borderColor() : QColor(71, 85, 105, 77); - QColor nameColor = m_themeHelper ? m_themeHelper->nameColor() : QColor(241, 245, 249); - QColor overviewColor= m_themeHelper ? m_themeHelper->overviewColor(): QColor(148, 163, 184); - QColor timeColor = m_themeHelper ? m_themeHelper->timeColor() : QColor(100, 116, 139); - - bool isDark = ThemeManager::instance()->isDarkMode(); - - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - - - QRect rect = option.rect.adjusted(12, 4, -12, -4); - - - if (option.state & QStyle::State_MouseOver) { - cardBg = cardBg.lighter(isDark ? 120 : 97); - } - - painter->setPen(QPen(borderColor, 1)); - painter->setBrush(cardBg); - painter->drawRoundedRect(rect, 8, 8); - - int leftPad = 12; - int rightPad = 12; - int topPad = 8; - - - QString name = index.data(NameRole).toString(); - QString overview = index.data(OverviewRole).toString(); - QString date = index.data(DateRole).toString(); - QString severity = index.data(SeverityRole).toString(); - - int contentLeft = rect.left() + leftPad; - int contentRight = rect.right() - rightPad; - int contentWidth = contentRight - contentLeft; - - - QString timeStr = formatExactTime(date); - QFont timeFont = option.font; - timeFont.setPixelSize(11); - timeFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"}); - QFontMetrics timeFm(timeFont); - int timeWidth = timeFm.horizontalAdvance(timeStr) + 4; - - painter->setFont(timeFont); - painter->setPen(timeColor); - painter->drawText(QRect(contentRight - timeWidth, rect.top() + topPad, timeWidth, 20), - Qt::AlignRight | Qt::AlignVCenter, timeStr); - - - QColor sevColor = severityColor(severity); - int dotSize = 6; - int dotX = contentLeft + 1; - int dotY = rect.top() + topPad + 7; - painter->setPen(Qt::NoPen); - painter->setBrush(sevColor); - painter->drawEllipse(dotX, dotY, dotSize, dotSize); - - int textLeft = contentLeft + dotSize + 10; - int nameMaxWidth = contentRight - timeWidth - textLeft - 12; - - - QFont nameFont = option.font; - nameFont.setPixelSize(12); - nameFont.setWeight(QFont::Normal); - nameFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"}); - QFontMetrics nameFm(nameFont); - QString elidedName = nameFm.elidedText(name, Qt::ElideRight, nameMaxWidth); - - painter->setFont(nameFont); - painter->setPen(nameColor); - painter->drawText(QRect(textLeft, rect.top() + topPad, nameMaxWidth, 20), - Qt::AlignLeft | Qt::AlignVCenter, elidedName); - - - if (!overview.isEmpty()) { - QFont overviewFont = option.font; - overviewFont.setPixelSize(11); - overviewFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"}); - QFontMetrics overviewFm(overviewFont); - QString elidedOverview = overviewFm.elidedText(overview, Qt::ElideRight, contentWidth - dotSize - 10); - - painter->setFont(overviewFont); - painter->setPen(overviewColor); - painter->drawText(QRect(textLeft, rect.top() + topPad + 22, contentWidth - dotSize - 10, 18), - Qt::AlignLeft | Qt::AlignVCenter, elidedOverview); - } - - painter->restore(); -} - -QSize ActivityLogDelegate::sizeHint(const QStyleOptionViewItem& option, - const QModelIndex& index) const -{ - Q_UNUSED(option) - QString overview = index.data(OverviewRole).toString(); - - return QSize(200, overview.isEmpty() ? 44 : 56); -} - -QString ActivityLogDelegate::formatExactTime(const QString& isoDate) const -{ - if (isoDate.isEmpty()) return {}; - - QDateTime dt = QDateTime::fromString(isoDate, Qt::ISODate); - if (!dt.isValid()) { - dt = QDateTime::fromString(isoDate, "yyyy-MM-ddTHH:mm:ss.zzzzzzzZ"); - } - if (!dt.isValid()) return isoDate; - - dt.setTimeZone(QTimeZone::UTC); - return dt.toLocalTime().toString("yyyy-MM-dd HH:mm:ss"); -} - -QColor ActivityLogDelegate::severityColor(const QString& severity) const -{ - if (severity.compare("Error", Qt::CaseInsensitive) == 0) - return QColor("#EF4444"); - if (severity.compare("Warn", Qt::CaseInsensitive) == 0 || - severity.compare("Warning", Qt::CaseInsensitive) == 0) - return QColor("#F59E0B"); - return QColor("#3B82F6"); -} diff --git a/src/qEmbyApp/views/admin/activitylogdelegate.h b/src/qEmbyApp/views/admin/activitylogdelegate.h deleted file mode 100644 index f30b5d2..0000000 --- a/src/qEmbyApp/views/admin/activitylogdelegate.h +++ /dev/null @@ -1,69 +0,0 @@ -#ifndef ACTIVITYLOGDELEGATE_H -#define ACTIVITYLOGDELEGATE_H - -#include -#include -#include - - -class ActivityLogThemeHelper : public QWidget { - Q_OBJECT - Q_PROPERTY(QColor cardBg READ cardBg WRITE setCardBg) - Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor) - Q_PROPERTY(QColor nameColor READ nameColor WRITE setNameColor) - Q_PROPERTY(QColor overviewColor READ overviewColor WRITE setOverviewColor) - Q_PROPERTY(QColor timeColor READ timeColor WRITE setTimeColor) - -public: - explicit ActivityLogThemeHelper(QWidget *parent = nullptr); - - QColor cardBg() const { return m_cardBg; } - void setCardBg(const QColor &c) { m_cardBg = c; } - - QColor borderColor() const { return m_borderColor; } - void setBorderColor(const QColor &c) { m_borderColor = c; } - - QColor nameColor() const { return m_nameColor; } - void setNameColor(const QColor &c) { m_nameColor = c; } - - QColor overviewColor() const { return m_overviewColor; } - void setOverviewColor(const QColor &c) { m_overviewColor = c; } - - QColor timeColor() const { return m_timeColor; } - void setTimeColor(const QColor &c) { m_timeColor = c; } - -private: - QColor m_cardBg; - QColor m_borderColor; - QColor m_nameColor; - QColor m_overviewColor; - QColor m_timeColor; -}; - -class ActivityLogDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - explicit ActivityLogDelegate(QObject* parent = nullptr); - - - enum Roles { - NameRole = Qt::UserRole + 1, - OverviewRole, - DateRole, - SeverityRole, - TypeRole - }; - - void paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const override; - QSize sizeHint(const QStyleOptionViewItem& option, - const QModelIndex& index) const override; - -private: - QString formatExactTime(const QString& isoDate) const; - QColor severityColor(const QString& severity) const; - - mutable ActivityLogThemeHelper* m_themeHelper = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/librarycard.cpp b/src/qEmbyApp/views/admin/librarycard.cpp deleted file mode 100644 index 2dcb929..0000000 --- a/src/qEmbyApp/views/admin/librarycard.cpp +++ /dev/null @@ -1,402 +0,0 @@ -#include "librarycard.h" -#include "librarygrid.h" -#include "../../components/libraryactionmenu.h" -#include "../../managers/thememanager.h" -#include "../../tokens/ColorTokens.h" -#include "../../utils/hoveranimator.h" -#include "../../utils/tweenedvalue.h" - -#include -#include -#include -#include -#include -#include - -namespace { -QColor lerpColor(const QColor& from, const QColor& to, qreal t) -{ - return QColor(qRound(from.red() + (to.red() - from.red()) * t), - qRound(from.green() + (to.green() - from.green()) * t), - qRound(from.blue() + (to.blue() - from.blue()) * t), - qRound(from.alpha() + (to.alpha() - from.alpha()) * t)); -} -} // namespace - - - - -LibraryCard::LibraryCard(const VirtualFolder& folder, int index, QWidget* parent) - : QFrame(parent), m_folder(folder), m_folderIdx(index) -{ - setFixedSize(LibraryGrid::CardWidth, LibraryGrid::CardHeight); - setCursor(Qt::OpenHandCursor); - setMouseTracking(true); - - - 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_btnEdit = makeBtn("LibCardEditBtn"); - m_btnScan = makeBtn("LibCardScanBtn"); - m_btnDelete = makeBtn("LibCardDeleteBtn"); - - m_btnEdit->setToolTip(tr("Edit")); - m_btnScan->setToolTip(tr("Scan")); - m_btnDelete->setToolTip(tr("Delete")); - - m_hover = new HoverAnimator(this); - m_hover->setCallback([this](qreal) { update(); }); - - m_scanTween = new TweenedValue(this); - connect(m_scanTween, &TweenedValue::valueChanged, this, - [this](qreal) { update(); }); - - updateButtonLayout(); - - connect(m_btnEdit, &QPushButton::clicked, this, [this]() { emit editClicked(m_folderIdx); }); - connect(m_btnScan, &QPushButton::clicked, this, [this]() { emit scanClicked(m_folderIdx); }); - connect(m_btnDelete, &QPushButton::clicked, this, [this]() { emit deleteClicked(m_folderIdx); }); -} - -void LibraryCard::updateButtonLayout() -{ - const int buttonSize = 24; - const int buttonSpacing = 4; - const int totalWidth = 3 * buttonSize + 2 * buttonSpacing; - const int startX = width() - totalWidth - 8; - const int buttonY = height() - buttonSize - 6; - - m_btnEdit->move(startX, buttonY); - m_btnScan->move(startX + buttonSize + buttonSpacing, buttonY); - m_btnDelete->move(startX + 2 * (buttonSize + buttonSpacing), buttonY); -} - -void LibraryCard::setImage(const QPixmap& pixmap) -{ - m_image = pixmap; - update(); -} - -void LibraryCard::setSelected(bool selected) -{ - if (m_selected == selected) { - return; - } - - m_selected = selected; - update(); -} - -void LibraryCard::setScanProgress(int percent) -{ - const bool wasActive = m_scanProgress >= 0; - m_scanProgress = qBound(0, percent, 100); - // First appearance snaps; later server updates glide the arc. - if (wasActive) { - m_scanTween->setTarget(m_scanProgress); - } else { - m_scanTween->snapTo(m_scanProgress); - } - update(); -} - -void LibraryCard::clearScanProgress() -{ - m_scanProgress = -1; - m_scanTween->snapTo(0); - update(); -} - -LibraryContextMenuRequest LibraryCard::showContextMenu(const QPoint& globalPos) -{ - LibraryActionMenu menu(m_folder, isScanActive(), this); - return menu.execRequest(globalPos); -} - -void LibraryCard::dispatchContextMenuRequest( - const LibraryContextMenuRequest& request) -{ - if (!request.isValid()) { - return; - } - - switch (request.action) { - case LibraryContextMenuAction::None: - return; - case LibraryContextMenuAction::EditImages: - emit editImagesRequested(m_folderIdx); - return; - case LibraryContextMenuAction::RefreshMetadata: - emit refreshMetadataRequested(m_folderIdx); - return; - case LibraryContextMenuAction::ScanLibraryFiles: - emit scanLibraryFilesRequested(m_folderIdx); - return; - } -} - -void LibraryCard::contextMenuEvent(QContextMenuEvent* event) -{ - dispatchContextMenuRequest(showContextMenu(event->globalPos())); - event->accept(); -} - -void LibraryCard::paintEvent(QPaintEvent* event) -{ - Q_UNUSED(event) - QPainter p(this); - p.setRenderHint(QPainter::Antialiasing, true); - - bool isDark = ThemeManager::instance()->isDarkMode(); - int w = width(), h = height(); - - - using namespace ColorTokens; - QColor cardBg = isDark ? withAlpha(Slate::k700, 140) : withAlpha(Qt::white, 245); - QColor border = isDark ? withAlpha(Slate::k600, 100) : Slate::k200; - QColor nameFg = isDark ? Slate::k100 : Slate::k900; - QColor typeFg = isDark ? Slate::k400 : Slate::k500; - const QColor accent = Blue::k500; - - const qreal hp = m_hover->progress(); - if (hp > 0.0 && !m_selected && !m_dragging) { - const QColor hoverBorder = withAlpha(isDark ? Blue::k400 : Blue::k500, 190); - const QColor hoverBg = isDark ? withAlpha(Slate::k700, 190) : QColor(Qt::white); - border = lerpColor(border, hoverBorder, hp); - cardBg = lerpColor(cardBg, hoverBg, hp); - } - - if (m_selected) { - cardBg = isDark ? withAlpha(Slate::k800, 220) : withAlpha(Blue::k50, 245); - border = withAlpha(isDark ? Blue::k400 : Blue::k500, 220); - } - - 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 ? withAlpha(Slate::k500, 160) - : withAlpha(Slate::k400, 180)), - 1.5)); - p.setBrush(m_selected ? accent - : (isDark ? withAlpha(Slate::k900, 180) - : withAlpha(Qt::white, 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); - } - - - int imgAreaH = 120; - QRectF imgRect(4, 4, w - 8, imgAreaH); - - if (!m_image.isNull()) { - QPainterPath clipPath; - clipPath.addRoundedRect(imgRect, 9, 9); - p.save(); - p.setClipPath(clipPath); - QPixmap scaled = m_image.scaled( - imgRect.size().toSize() * devicePixelRatioF(), - Qt::KeepAspectRatioByExpanding, - Qt::SmoothTransformation); - int xOff = (scaled.width() / devicePixelRatioF() - imgRect.width()) / 2; - int yOff = (scaled.height() / devicePixelRatioF() - imgRect.height()) / 2; - p.drawPixmap(imgRect.toRect(), scaled, - QRect(xOff * devicePixelRatioF(), yOff * devicePixelRatioF(), - imgRect.width() * devicePixelRatioF(), - imgRect.height() * devicePixelRatioF())); - p.restore(); - } else { - QLinearGradient grad(imgRect.topLeft(), imgRect.bottomRight()); - grad.setColorAt(0, isDark ? Slate::k800 : Slate::k100); - grad.setColorAt(1, isDark ? Slate::k700 : Slate::k200); - QPainterPath clipPath; - clipPath.addRoundedRect(imgRect, 9, 9); - p.save(); - p.setClipPath(clipPath); - p.fillRect(imgRect, grad); - - QFont iconFont = font(); - iconFont.setPixelSize(32); - p.setFont(iconFont); - p.setPen(withAlpha(isDark ? Slate::k400 : Slate::k500, 80)); - p.drawText(imgRect, Qt::AlignCenter, "📁"); - p.restore(); - } - - - int textTop = imgAreaH + 10; - 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_folder.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 = m_folder.collectionTypeDisplayName(); - if (m_folder.locations.size() > 0) { - typeStr += QString(" · %1 %2").arg(m_folder.locations.size()) - .arg(m_folder.locations.size() > 1 ? tr("paths") : tr("path")); - } - - p.setFont(typeFont); - p.setPen(typeFg); - p.drawText(QRect(textLeft, textTop + 20, textWidth, 16), Qt::AlignLeft | Qt::AlignVCenter, typeStr); - - - if (m_scanProgress >= 0) { - - p.save(); - QPainterPath overlayPath; - overlayPath.addRoundedRect(QRectF(0.5, 0.5, w - 1, h - 1), 12, 12); - p.setClipPath(overlayPath); - p.fillRect(rect(), QColor(0, 0, 0, isDark ? 140 : 120)); - p.restore(); - - - int arcSize = 36; - QRectF arcRect((w - arcSize) / 2.0, (h - arcSize) / 2.0 - 10, arcSize, arcSize); - - - QPen bgPen(QColor(255, 255, 255, 50), 3, Qt::SolidLine, Qt::RoundCap); - p.setPen(bgPen); - p.setBrush(Qt::NoBrush); - p.drawEllipse(arcRect); - - - QPen arcPen(QColor(255, 255, 255, 220), 3, Qt::SolidLine, Qt::RoundCap); - p.setPen(arcPen); - - - // Arc glides via the tween; the % text shows the target value. - int spanAngle = -static_cast(m_scanTween->value() * 3.6 * 16); - p.drawArc(arcRect, 90 * 16, spanAngle); - - - QFont pctFont = font(); - pctFont.setPixelSize(10); - pctFont.setWeight(QFont::DemiBold); - pctFont.setFamilies({"Segoe UI", "Microsoft YaHei", "sans-serif"}); - p.setFont(pctFont); - p.setPen(QColor(255, 255, 255, 210)); - p.drawText(arcRect, Qt::AlignCenter, QString("%1%").arg(m_scanProgress)); - } -} - -void LibraryCard::resizeEvent(QResizeEvent* event) -{ - QFrame::resizeEvent(event); - updateButtonLayout(); -} - -void LibraryCard::enterEvent(QEnterEvent* event) -{ - QFrame::enterEvent(event); - m_hover->setHovered(true); -} - -void LibraryCard::leaveEvent(QEvent* event) -{ - QFrame::leaveEvent(event); - m_hover->setHovered(false); -} - -void LibraryCard::mousePressEvent(QMouseEvent* event) -{ - if (event->button() == Qt::LeftButton) { - if (!qobject_cast(childAt(event->position().toPoint()))) { - m_pressCandidate = true; - m_dragStartPos = event->globalPosition().toPoint(); - setCursor(Qt::OpenHandCursor); - event->accept(); - return; - } - } - - QFrame::mousePressEvent(event); -} - -void LibraryCard::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()) { - m_dragging = true; - setCursor(Qt::ClosedHandCursor); - raise(); - if (auto* grid = qobject_cast(parentWidget())) { - grid->onCardDragStart(this, globalPos); - } - } - } - - if (m_dragging) { - if (auto* grid = qobject_cast(parentWidget())) { - grid->onCardDragMove(this, globalPos); - } - event->accept(); - return; - } - } - - QFrame::mouseMoveEvent(event); -} - -void LibraryCard::mouseReleaseEvent(QMouseEvent* event) -{ - if (event->button() == Qt::LeftButton && m_pressCandidate) { - const bool wasDragging = m_dragging; - m_pressCandidate = false; - - if (wasDragging) { - m_dragging = false; - setCursor(Qt::OpenHandCursor); - if (auto* grid = qobject_cast(parentWidget())) { - grid->onCardDragEnd(this); - } - event->accept(); - return; - } - - toggleSelected(); - event->accept(); - return; - } - - QFrame::mouseReleaseEvent(event); -} - -void LibraryCard::toggleSelected() -{ - m_selected = !m_selected; - update(); - emit selectionChanged(m_folder.itemId, m_selected); -} diff --git a/src/qEmbyApp/views/admin/librarycard.h b/src/qEmbyApp/views/admin/librarycard.h deleted file mode 100644 index 05243a8..0000000 --- a/src/qEmbyApp/views/admin/librarycard.h +++ /dev/null @@ -1,83 +0,0 @@ -#ifndef LIBRARYCARD_H -#define LIBRARYCARD_H - -#include -#include -#include -#include "../../components/librarycontextmenurequest.h" -#include - -class HoverAnimator; -class LibraryGrid; -class TweenedValue; -class QContextMenuEvent; -class QEnterEvent; -class QMouseEvent; -class QPaintEvent; -class QResizeEvent; - - - - -class LibraryCard : public QFrame { - Q_OBJECT - Q_PROPERTY(QPoint pos READ pos WRITE move) - -public: - explicit LibraryCard(const VirtualFolder& folder, int index, QWidget* parent = nullptr); - - int folderIndex() const { return m_folderIdx; } - void setFolderIndex(int idx) { m_folderIdx = idx; } - const VirtualFolder& folder() const { return m_folder; } - QString itemId() const { return m_folder.itemId; } - bool isSelected() const { return m_selected; } - void setImage(const QPixmap& pixmap); - void setSelected(bool selected); - - - void setScanProgress(int percent); - void clearScanProgress(); - bool isScanActive() const { return m_scanProgress >= 0; } - -signals: - void editClicked(int index); - void scanClicked(int index); - void deleteClicked(int index); - void editImagesRequested(int index); - void refreshMetadataRequested(int index); - void scanLibraryFilesRequested(int index); - void selectionChanged(const QString& itemId, bool selected); - -protected: - void contextMenuEvent(QContextMenuEvent* event) override; - void paintEvent(QPaintEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - void mousePressEvent(QMouseEvent* event) override; - void mouseMoveEvent(QMouseEvent* event) override; - void mouseReleaseEvent(QMouseEvent* event) override; - void enterEvent(QEnterEvent* event) override; - void leaveEvent(QEvent* event) override; - -private: - void updateButtonLayout(); - void toggleSelected(); - LibraryContextMenuRequest showContextMenu(const QPoint& globalPos); - void dispatchContextMenuRequest(const LibraryContextMenuRequest& request); - - VirtualFolder m_folder; - int m_folderIdx; - QPixmap m_image; - bool m_selected = false; - bool m_dragging = false; - bool m_pressCandidate = false; - QPoint m_dragStartPos; - int m_scanProgress = -1; - - QPushButton* m_btnEdit; - QPushButton* m_btnScan; - QPushButton* m_btnDelete; - HoverAnimator* m_hover = nullptr; - TweenedValue* m_scanTween = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/librarygrid.cpp b/src/qEmbyApp/views/admin/librarygrid.cpp deleted file mode 100644 index 72c51ad..0000000 --- a/src/qEmbyApp/views/admin/librarygrid.cpp +++ /dev/null @@ -1,296 +0,0 @@ -#include "librarygrid.h" -#include "librarycard.h" - -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/motionprefs.h" - - -static void stopPosAnimations(LibraryCard* card) -{ - const auto children = card->children(); - for (auto* child : children) { - if (auto* anim = qobject_cast(child)) { - if (anim->propertyName() == "pos") { - anim->stop(); - anim->deleteLater(); - } - } - } -} - - - - -LibraryGrid::LibraryGrid(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); - } - }); -} - -void LibraryGrid::setCards(const QList& cards) -{ - for (auto* card : m_cards) { - card->deleteLater(); - } - m_cards = cards; - for (auto* card : m_cards) { - card->setParent(this); - card->show(); - } - relayout(false); -} - -int LibraryGrid::columnsForWidth(int w) const -{ - if (w <= 0) return 1; - const int contentWidth = qMax(0, w - LeftPadding - RightPadding); - int cols = (contentWidth + CardSpacing) / (CardWidth + CardSpacing); - return qMax(1, cols); -} - -int LibraryGrid::contentWidth() const -{ - return qMax(0, width() - LeftPadding - RightPadding); -} - -int LibraryGrid::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 LibraryGrid::columnX(int column, int columns) const -{ - int x = LeftPadding; - for (int i = 0; i < column; ++i) { - x += cardWidthForColumn(i, columns) + CardSpacing; - } - return x; -} - -int LibraryGrid::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 LibraryGrid::posForIndex(int index, int columns) const -{ - int col = index % columns; - int row = index / columns; - return QPoint(columnX(col, columns), - row * (CardHeight + CardSpacing)); -} - -int LibraryGrid::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() / (CardHeight + CardSpacing)); - const int idx = row * columns + col; - return qBound(0, idx, m_cards.size() - 1); -} - -QScrollArea* LibraryGrid::parentScrollArea() const -{ - QWidget* current = parentWidget(); - while (current) { - if (auto* scrollArea = qobject_cast(current)) { - return scrollArea; - } - current = current->parentWidget(); - } - - return nullptr; -} - -int LibraryGrid::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 LibraryGrid::updateDragPosition(LibraryCard* card, const QPoint& globalPos) -{ - QPoint localPos = mapFromGlobal(globalPos) - QPoint(card->width() / 2, card->height() / 2); - card->move(localPos); - - 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; - - for (int i = 0; i < m_cards.size(); ++i) { - m_cards[i]->setFolderIndex(i); - } - - relayout(true); - } -} - -void LibraryGrid::relayout(bool animate) -{ - m_columns = columnsForWidth(width()); - int rows = m_cards.isEmpty() ? 0 : (m_cards.size() + m_columns - 1) / m_columns; - int totalH = rows * (CardHeight + 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; - card->setFixedSize(cardWidthForColumn(col, m_columns), CardHeight); - if (card == m_dragCard) continue; - - QPoint target = posForIndex(i, m_columns); - if (animate && !MotionPrefs::reduceMotion() && card->pos() != target) { - - stopPosAnimations(card); - - auto* anim = new QPropertyAnimation(card, "pos", card); - anim->setDuration(AnimationTokens::DurationMs::kNormal); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - anim->setEndValue(target); - anim->start(QAbstractAnimation::DeleteWhenStopped); - } else { - - stopPosAnimations(card); - card->move(target); - } - } -} - -void LibraryGrid::resizeEvent(QResizeEvent* event) -{ - QWidget::resizeEvent(event); - relayout(false); -} - -void LibraryGrid::onCardDragStart(LibraryCard* card, const QPoint& globalPos) -{ - m_dragCard = card; - m_dragOriginalIndex = m_cards.indexOf(card); - m_dragCurrentIndex = m_dragOriginalIndex; - m_lastDragGlobalPos = globalPos; - - - for (auto* c : m_cards) { - stopPosAnimations(c); - } - - card->raise(); - card->setStyleSheet("border: 2px solid rgba(59, 130, 246, 0.6); border-radius: 12px;"); - m_autoScrollTimer->start(); -} - -void LibraryGrid::onCardDragMove(LibraryCard* card, const QPoint& globalPos) -{ - m_lastDragGlobalPos = globalPos; - updateDragPosition(card, globalPos); -} - -void LibraryGrid::onCardDragEnd(LibraryCard* card) -{ - card->setStyleSheet(""); - m_autoScrollTimer->stop(); - - - LibraryCard* dragCard = m_dragCard; - int finalIndex = m_dragCurrentIndex; - bool changed = (m_dragOriginalIndex != m_dragCurrentIndex); - - m_dragCard = nullptr; - m_dragOriginalIndex = -1; - m_dragCurrentIndex = -1; - - - stopPosAnimations(dragCard); - - QPoint target = posForIndex(finalIndex, m_columns); - if (MotionPrefs::reduceMotion()) { - dragCard->move(target); - } else { - auto* anim = new QPropertyAnimation(dragCard, "pos", dragCard); - anim->setDuration(AnimationTokens::DurationMs::kFast); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - anim->setEndValue(target); - anim->start(QAbstractAnimation::DeleteWhenStopped); - } - - if (changed) { - QStringList newOrder; - for (auto* c : m_cards) { - newOrder.append(c->folder().itemId); - } - emit orderChanged(newOrder); - } -} diff --git a/src/qEmbyApp/views/admin/librarygrid.h b/src/qEmbyApp/views/admin/librarygrid.h deleted file mode 100644 index 362795d..0000000 --- a/src/qEmbyApp/views/admin/librarygrid.h +++ /dev/null @@ -1,62 +0,0 @@ -#ifndef LIBRARYGRID_H -#define LIBRARYGRID_H - -#include -#include - -class LibraryCard; -class QScrollArea; -class QTimer; - - - - -class LibraryGrid : public QWidget { - Q_OBJECT -public: - explicit LibraryGrid(QWidget* parent = nullptr); - - void setCards(const QList& cards); - QList cards() const { return m_cards; } - void relayout(bool animate = false); - - - static constexpr int CardWidth = 170; - static constexpr int CardHeight = 200; - 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(LibraryCard* card, const QPoint& globalPos); - void onCardDragMove(LibraryCard* card, const QPoint& globalPos); - void onCardDragEnd(LibraryCard* card); - -protected: - void resizeEvent(QResizeEvent* event) override; - -private: - 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(LibraryCard* card, const QPoint& globalPos); - - QList m_cards; - LibraryCard* m_dragCard = nullptr; - int m_dragOriginalIndex = -1; - int m_dragCurrentIndex = -1; - int m_columns = 3; - QPoint m_lastDragGlobalPos; - QTimer* m_autoScrollTimer = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/managepagebase.cpp b/src/qEmbyApp/views/admin/managepagebase.cpp deleted file mode 100644 index 508513b..0000000 --- a/src/qEmbyApp/views/admin/managepagebase.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "managepagebase.h" -#include - -ManagePageBase::ManagePageBase(QEmbyCore* core, const QString& pageTitle, QWidget* parent) - : QWidget(parent), m_core(core) -{ - m_mainLayout = new QVBoxLayout(this); - m_mainLayout->setContentsMargins(16, 30, 16, 40); - m_mainLayout->setSpacing(16); - - if (!pageTitle.isEmpty()) { - auto* titleLabel = new QLabel(pageTitle, this); - titleLabel->setObjectName("SettingsPageTitle"); - m_mainLayout->addWidget(titleLabel); - m_mainLayout->addSpacing(10); - } -} diff --git a/src/qEmbyApp/views/admin/managepagebase.h b/src/qEmbyApp/views/admin/managepagebase.h deleted file mode 100644 index 7476f25..0000000 --- a/src/qEmbyApp/views/admin/managepagebase.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef MANAGEPAGEBASE_H -#define MANAGEPAGEBASE_H - -#include -#include -#include - -class QEmbyCore; -class QLabel; - -class ManagePageBase : public QWidget { - Q_OBJECT -public: - explicit ManagePageBase(QEmbyCore* core, const QString& pageTitle, QWidget* parent = nullptr); - ~ManagePageBase() override = default; - -protected: - QEmbyCore* m_core; - QVBoxLayout* m_mainLayout; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/manageview.cpp b/src/qEmbyApp/views/admin/manageview.cpp deleted file mode 100644 index 9e8a402..0000000 --- a/src/qEmbyApp/views/admin/manageview.cpp +++ /dev/null @@ -1,273 +0,0 @@ -#include "manageview.h" -#include "managepagebase.h" -#include "../../quick/dashboardviewmodel.h" -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "pagecollections.h" -#include "pagelibraries.h" -#include "pagelogs.h" -#include "pagetasks.h" -#include "pagetranscoding.h" -#include "pageusers.h" -#include "../../components/slidingstackedwidget.h" -#include "../../managers/thememanager.h" -#include "../../tokens/AnimationTokens.h" -#include "../../utils/motionprefs.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -ManageView::ManageView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setAttribute(Qt::WA_StyledBackground, true); - setObjectName("SettingsRootView"); - - setupUi(); - setupConnections(); -} - - - - - -void ManageView::showEvent(QShowEvent* event) -{ - BaseView::showEvent(event); - m_core->serverManager()->connectWebSocket(); - qDebug() << "[ManageView] Entered management view — WebSocket connecting"; -} - -void ManageView::hideEvent(QHideEvent* event) -{ - m_core->serverManager()->disconnectWebSocket(); - qDebug() << "[ManageView] Left management view — WebSocket disconnected"; - BaseView::hideEvent(event); -} - -void ManageView::setupUi() { - auto *mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - - m_leftPanel = new QWidget(this); - m_leftPanel->setFixedWidth(260); - m_leftPanel->setObjectName("SettingsLeftPanel"); - auto *leftLayout = new QVBoxLayout(m_leftPanel); - leftLayout->setContentsMargins(32, 32, 32, 32); - leftLayout->setSpacing(24); - - m_titleLabel = new QLabel(tr("Management"), m_leftPanel); - m_titleLabel->setObjectName("SettingsMainTitle"); - - m_navMenu = new QListWidget(m_leftPanel); - m_navMenu->setObjectName("SettingsNavMenu"); - m_navMenu->setFocusPolicy(Qt::NoFocus); - m_navMenu->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_navMenu->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - - auto *itemDashboard = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-dashboard.svg"), tr(" Dashboard")); - itemDashboard->setData(Qt::UserRole, ":/svg/dark/manage-dashboard.svg"); - - auto *itemLibraries = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-libraries.svg"), tr(" Libraries")); - itemLibraries->setData(Qt::UserRole, ":/svg/dark/manage-libraries.svg"); - - auto *itemCollections = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-collections.svg"), tr(" Lists")); - itemCollections->setData(Qt::UserRole, ":/svg/dark/manage-collections.svg"); - - auto *itemTranscoding = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/video-output.svg"), tr(" Transcoding")); - itemTranscoding->setData(Qt::UserRole, ":/svg/dark/video-output.svg"); - - auto *itemUsers = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-users.svg"), tr(" Users")); - itemUsers->setData(Qt::UserRole, ":/svg/dark/manage-users.svg"); - - auto *itemTasks = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-tasks.svg"), tr(" Tasks")); - itemTasks->setData(Qt::UserRole, ":/svg/dark/manage-tasks.svg"); - - auto *itemLogs = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/manage-logs.svg"), tr(" Logs")); - itemLogs->setData(Qt::UserRole, ":/svg/dark/manage-logs.svg"); - - - QSize itemSize(220, 44); - itemDashboard->setSizeHint(itemSize); - itemLibraries->setSizeHint(itemSize); - itemCollections->setSizeHint(itemSize); - itemTranscoding->setSizeHint(itemSize); - itemUsers->setSizeHint(itemSize); - itemTasks->setSizeHint(itemSize); - itemLogs->setSizeHint(itemSize); - - m_navMenu->addItem(itemDashboard); - m_navMenu->addItem(itemLibraries); - m_navMenu->addItem(itemCollections); - m_navMenu->addItem(itemTranscoding); - m_navMenu->addItem(itemUsers); - m_navMenu->addItem(itemTasks); - m_navMenu->addItem(itemLogs); - - leftLayout->addWidget(m_titleLabel); - leftLayout->addWidget(m_navMenu); - - - m_stack = new SlidingStackedWidget(this); - m_stack->setObjectName("SettingsStack"); - - - auto wrapInScrollArea = [this](QWidget *page) -> QScrollArea * { - page->setAttribute(Qt::WA_StyledBackground, true); - - auto *scroll = new QScrollArea(); - scroll->setObjectName("SettingsScrollArea"); - scroll->setWidget(page); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - scroll->viewport()->setAutoFillBackground(false); - - m_scrollAreas.append(scroll); - scroll->viewport()->installEventFilter(this); - - return scroll; - }; - - - // Dashboard 已迁移为 QML 试点页:QuickPageHost + DashboardViewModel。 - // QML 页面自带 Flickable 滚动,不再套 QScrollArea。 - auto *dashboardHost = new QuickPageHost(m_stack); - auto *dashboardVm = new DashboardViewModel(m_core, dashboardHost, dashboardHost); - dashboardHost->setContextProperty(QStringLiteral("dashboard"), dashboardVm); - dashboardHost->addImageProvider(QStringLiteral("emby"), new EmbyImageProvider(m_core)); - dashboardHost->addImageProvider(QStringLiteral("adaptiveicon"), new AdaptiveIconProvider()); - dashboardHost->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/ManageDashboardPage.qml"))); - connect(dashboardHost, &QuickPageHost::pageShown, dashboardVm, - [dashboardVm]() { dashboardVm->setActive(true); }); - connect(dashboardHost, &QuickPageHost::pageHidden, dashboardVm, - [dashboardVm]() { dashboardVm->setActive(false); }); - m_stack->addWidget(dashboardHost); - - - auto *libPage = new PageLibraries(m_core, m_stack); - libPage->setAttribute(Qt::WA_StyledBackground, true); - m_stack->addWidget(libPage); - - m_scrollAreas.append(libPage->scrollArea()); - libPage->scrollArea()->viewport()->installEventFilter(this); - - - auto *collPage = new PageCollections(m_core, m_stack); - collPage->setAttribute(Qt::WA_StyledBackground, true); - m_stack->addWidget(collPage); - m_scrollAreas.append(collPage->scrollArea()); - collPage->scrollArea()->viewport()->installEventFilter(this); - - auto *transcodingPage = new PageTranscoding(m_core, m_stack); - transcodingPage->setAttribute(Qt::WA_StyledBackground, true); - m_stack->addWidget(transcodingPage); - m_scrollAreas.append(transcodingPage->scrollArea()); - transcodingPage->scrollArea()->viewport()->installEventFilter(this); - - m_stack->addWidget(wrapInScrollArea(new PageUsers(m_core, m_stack))); - - auto *taskPage = new PageTasks(m_core, m_stack); - taskPage->setAttribute(Qt::WA_StyledBackground, true); - m_stack->addWidget(taskPage); - m_scrollAreas.append(taskPage->scrollArea()); - taskPage->scrollArea()->viewport()->installEventFilter(this); - - auto *logPage = new PageLogs(m_core, m_stack); - logPage->setAttribute(Qt::WA_StyledBackground, true); - m_stack->addWidget(logPage); - - mainLayout->addWidget(m_leftPanel); - mainLayout->addWidget(m_stack, 1); - - m_navMenu->setCurrentRow(0); - - - m_scrollTargets.resize(m_scrollAreas.size(), 0); - for (auto *sa : m_scrollAreas) { - auto *anim = new QPropertyAnimation(sa->verticalScrollBar(), "value", this); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - m_scrollAnims.append(anim); - } - - qApp->postEvent(this, new QEvent(QEvent::StyleChange)); -} - -void ManageView::setupConnections() { - connect(m_navMenu, &QListWidget::currentRowChanged, this, [this](int row) { - if (row >= 0) { - // Vertical slide following the nav list's direction of travel. - m_stack->slideInIdx(row, row > m_stack->currentIndex() - ? SlidingStackedWidget::BottomToTop - : SlidingStackedWidget::TopToBottom); - } - }); - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - &ManageView::onThemeChanged); -} - -void ManageView::onThemeChanged() { - for (int i = 0; i < m_navMenu->count(); ++i) { - auto *item = m_navMenu->item(i); - QString svgPath = item->data(Qt::UserRole).toString(); - if (!svgPath.isEmpty()) { - item->setIcon(ThemeManager::getAdaptiveIcon(svgPath)); - } - } -} - -bool ManageView::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::Wheel) { - for (int i = 0; i < m_scrollAreas.size(); ++i) { - if (obj == m_scrollAreas[i]->viewport()) { - auto *we = static_cast(event); - auto *vBar = m_scrollAreas[i]->verticalScrollBar(); - auto *anim = m_scrollAnims[i]; - - if (vBar) { - int currentVal = vBar->value(); - - if (anim->state() == QAbstractAnimation::Running) { - currentVal = m_scrollTargets[i]; - } - - int step = we->angleDelta().y(); - int newTarget = currentVal - step; - newTarget = qBound(vBar->minimum(), newTarget, vBar->maximum()); - - if (newTarget != vBar->value()) { - m_scrollTargets[i] = newTarget; - anim->stop(); - if (MotionPrefs::reduceMotion()) { - vBar->setValue(newTarget); - } else { - anim->setStartValue(vBar->value()); - anim->setEndValue(newTarget); - anim->start(); - } - } - } - return true; - } - } - } - - return QWidget::eventFilter(obj, event); -} diff --git a/src/qEmbyApp/views/admin/manageview.h b/src/qEmbyApp/views/admin/manageview.h deleted file mode 100644 index cfdc631..0000000 --- a/src/qEmbyApp/views/admin/manageview.h +++ /dev/null @@ -1,44 +0,0 @@ -#ifndef MANAGEVIEW_H -#define MANAGEVIEW_H - -#include "../baseview.h" -#include -#include -#include -#include -#include -#include - -class SlidingStackedWidget; - -class ManageView : public BaseView { - Q_OBJECT -public: - explicit ManageView(QEmbyCore* core, QWidget* parent = nullptr); - ~ManageView() override = default; - -protected: - void showEvent(QShowEvent* event) override; - void hideEvent(QHideEvent* event) override; - bool eventFilter(QObject* obj, QEvent* event) override; - -private: - void setupUi(); - void setupConnections(); - -private slots: - void onThemeChanged(); - -private: - QWidget* m_leftPanel; - QLabel* m_titleLabel; - QListWidget* m_navMenu; - SlidingStackedWidget* m_stack; - - - QList m_scrollAreas; - QList m_scrollAnims; - QList m_scrollTargets; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/nowplayingcard.cpp b/src/qEmbyApp/views/admin/nowplayingcard.cpp deleted file mode 100644 index 6b675e3..0000000 --- a/src/qEmbyApp/views/admin/nowplayingcard.cpp +++ /dev/null @@ -1,281 +0,0 @@ -#include "nowplayingcard.h" -#include -#include -#include -#include "../../managers/thememanager.h" -#include "../../utils/imageutils.h" -#include "../../utils/tweenedvalue.h" - -#include -#include -#include -#include -#include -#include - -NowPlayingCard::NowPlayingCard(const SessionInfo& session, QEmbyCore* core, QWidget* parent) - : QFrame(parent), m_core(core), m_sessionId(session.id) -{ - setObjectName("NowPlayingCard"); - setFixedWidth(320); - setupUi(session); -} - -void NowPlayingCard::setupUi(const SessionInfo& session) { - auto* mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(10, 8, 0, 8); - mainLayout->setSpacing(10); - - - - - m_imageLabel = new QLabel(this); - m_imageLabel->setObjectName("NowPlayingImage"); - m_imageLabel->setFixedSize(140, 80); - m_imageLabel->setScaledContents(false); - m_imageLabel->setAlignment(Qt::AlignCenter); - - QIcon placeholderIcon = ThemeManager::getAdaptiveIcon(":/svg/dark/now-playing.svg"); - m_imageLabel->setPixmap(placeholderIcon.pixmap(QSize(32, 32))); - mainLayout->addWidget(m_imageLabel); - - - - - auto* infoLayout = new QVBoxLayout(); - infoLayout->setContentsMargins(0, 0, 10, 0); - infoLayout->setSpacing(3); - - - m_titleLabel = new QLabel(this); - m_titleLabel->setObjectName("NowPlayingTitle"); - m_titleLabel->setWordWrap(false); - m_titleLabel->setTextFormat(Qt::PlainText); - m_titleLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - infoLayout->addWidget(m_titleLabel); - - - m_subtitleLabel = new QLabel(this); - m_subtitleLabel->setObjectName("NowPlayingSubtitle"); - m_subtitleLabel->setWordWrap(false); - m_subtitleLabel->setTextFormat(Qt::PlainText); - m_subtitleLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - infoLayout->addWidget(m_subtitleLabel); - - - auto* progressRow = new QHBoxLayout(); - progressRow->setSpacing(4); - progressRow->setContentsMargins(0, 0, 0, 0); - - m_stateIcon = new QLabel(this); - m_stateIcon->setFixedSize(12, 12); - m_stateIcon->setScaledContents(true); - progressRow->addWidget(m_stateIcon); - - m_progressText = new QLabel(this); - m_progressText->setObjectName("NowPlayingProgress"); - progressRow->addWidget(m_progressText); - progressRow->addStretch(1); - - infoLayout->addLayout(progressRow); - - - m_progressBar = new QProgressBar(this); - m_progressBar->setObjectName("NowPlayingProgressBar"); - m_progressBar->setTextVisible(false); - m_progressBar->setMaximumWidth(120); - m_progressBar->setRange(0, 1000); - infoLayout->addWidget(m_progressBar); - - m_progressTween = new TweenedValue(this); - connect(m_progressTween, &TweenedValue::valueChanged, this, - [this](qreal value) { m_progressBar->setValue(qRound(value)); }); - - infoLayout->addSpacing(2); - - - m_userLabel = new QLabel(this); - m_userLabel->setObjectName("NowPlayingUser"); - m_userLabel->setWordWrap(false); - infoLayout->addWidget(m_userLabel); - - - m_streamLabel = new QLabel(this); - m_streamLabel->setObjectName("NowPlayingStream"); - m_streamLabel->setWordWrap(true); - infoLayout->addWidget(m_streamLabel); - - infoLayout->addStretch(1); - mainLayout->addLayout(infoLayout, 1); - - - updateSession(session); -} - -void NowPlayingCard::updateSession(const SessionInfo& session) { - m_sessionId = session.id; - - - { - const QString &title = session.nowPlayingItemName; - QFontMetrics fm(m_titleLabel->font()); - QString elided = fm.elidedText(title, Qt::ElideRight, m_titleLabel->width()); - m_titleLabel->setText(elided); - m_titleLabel->setToolTip(elided != title ? title : QString()); - } - - - QString subtitle; - if (!session.nowPlayingSeriesName.isEmpty()) { - subtitle = session.nowPlayingSeriesName; - QString seLabel = session.seasonEpisodeLabel(); - if (!seLabel.isEmpty()) { - subtitle += QString(" - %1").arg(seLabel); - } - } else if (!session.nowPlayingItemType.isEmpty()) { - subtitle = session.nowPlayingItemType; - } - { - QFontMetrics fm(m_subtitleLabel->font()); - QString elided = fm.elidedText(subtitle, Qt::ElideRight, m_subtitleLabel->width()); - m_subtitleLabel->setText(elided); - m_subtitleLabel->setToolTip(elided != subtitle ? subtitle : QString()); - } - m_subtitleLabel->setVisible(!subtitle.isEmpty()); - - - if (session.nowPlayingRunTimeTicks > 0) { - QString posStr = SessionInfo::formatTicks(session.positionTicks); - QString durStr = SessionInfo::formatTicks(session.nowPlayingRunTimeTicks); - - - QString iconPath = session.isPaused - ? QStringLiteral(":/svg/dark/now-paused.svg") - : QStringLiteral(":/svg/dark/now-playing.svg"); - QIcon stateIcon = ThemeManager::getAdaptiveIcon(iconPath); - m_stateIcon->setPixmap(stateIcon.pixmap(QSize(12, 12))); - - m_progressText->setText(posStr + " / " + durStr); - m_progressTween->setTarget(session.playbackProgress() * 1000); - m_progressText->show(); - m_stateIcon->show(); - m_progressBar->show(); - } else { - m_progressText->hide(); - m_stateIcon->hide(); - m_progressBar->hide(); - } - - - m_userLabel->setText(QString("%1 · %2").arg(session.userName, session.client)); - - - QStringList streamParts; - if (!session.playMethod.isEmpty()) { - QString methodDisplay; - if (session.playMethod == "DirectPlay") methodDisplay = tr("Direct Play"); - else if (session.playMethod == "DirectStream") methodDisplay = tr("Direct Stream"); - else if (session.playMethod == "Transcode") methodDisplay = tr("Transcode"); - else methodDisplay = session.playMethod; - - if (session.playMethod == "Transcode" && !session.transcodingVideoCodec.isEmpty()) { - QString bitrateStr; - if (session.transcodingBitrate > 0) { - double mbps = session.transcodingBitrate / 1000000.0; - bitrateStr = QString(" %1 Mbps").arg(mbps, 0, 'f', 1); - } - streamParts << QString("%1 → %2%3") - .arg(methodDisplay, session.transcodingVideoCodec.toUpper(), bitrateStr); - } else { - streamParts << methodDisplay; - } - } - if (!session.remoteEndPoint.isEmpty()) { - streamParts << session.remoteEndPoint; - } - m_streamLabel->setText(streamParts.join(" · ")); - - - if (!session.nowPlayingItemId.isEmpty() && session.nowPlayingItemId != m_currentImageItemId) { - m_currentImageItemId = session.nowPlayingItemId; - - QIcon placeholderIcon = ThemeManager::getAdaptiveIcon(":/svg/dark/now-playing.svg"); - m_imageLabel->setPixmap(placeholderIcon.pixmap(QSize(32, 32))); - loadImage(session); - } -} - -QCoro::Task NowPlayingCard::loadImage(const SessionInfo& session) { - QPointer safeThis(this); - - - - struct ImageCandidate { - QString itemId; - QString imageType; - QString imageTag; - }; - - QList candidates; - - - if (!session.nowPlayingThumbTag.isEmpty()) { - candidates.append({session.nowPlayingItemId, "Thumb", session.nowPlayingThumbTag}); - } - - if (!session.nowPlayingBackdropTag.isEmpty()) { - candidates.append({session.nowPlayingItemId, "Backdrop", session.nowPlayingBackdropTag}); - } - - if (!session.nowPlayingImageTag.isEmpty()) { - candidates.append({session.nowPlayingItemId, "Primary", session.nowPlayingImageTag}); - } - - if (!session.parentThumbItemId.isEmpty() && !session.parentThumbImageTag.isEmpty()) { - candidates.append({session.parentThumbItemId, "Thumb", session.parentThumbImageTag}); - } - - if (!session.parentBackdropItemId.isEmpty() && !session.parentBackdropImageTag.isEmpty()) { - candidates.append({session.parentBackdropItemId, "Backdrop", session.parentBackdropImageTag}); - } - - if (!session.nowPlayingSeriesId.isEmpty() && !session.nowPlayingSeriesPrimaryTag.isEmpty()) { - candidates.append({session.nowPlayingSeriesId, "Primary", session.nowPlayingSeriesPrimaryTag}); - } - - - if (candidates.isEmpty()) { - candidates.append({session.nowPlayingItemId, "Primary", ""}); - } - - - for (const auto& candidate : candidates) { - if (!safeThis) co_return; - - try { - auto pixmap = co_await m_core->mediaService()->fetchImage( - candidate.itemId, candidate.imageType, candidate.imageTag, 280); - - if (!safeThis) co_return; - - if (!pixmap.isNull()) { - - QPixmap scaled = pixmap.scaled( - m_imageLabel->size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - int x = (scaled.width() - m_imageLabel->width()) / 2; - int y = (scaled.height() - m_imageLabel->height()) / 2; - QPixmap cropped = scaled.copy(x, y, m_imageLabel->width(), m_imageLabel->height()); - m_imageLabel->setPixmap(ImageUtils::roundedPixmap(cropped, 10, 4)); - co_return; - } - } catch (...) { - - } - } - - - if (safeThis) { - QIcon placeholderIcon = ThemeManager::getAdaptiveIcon(":/svg/dark/now-playing.svg"); - m_imageLabel->setPixmap(placeholderIcon.pixmap(QSize(32, 32))); - } -} diff --git a/src/qEmbyApp/views/admin/nowplayingcard.h b/src/qEmbyApp/views/admin/nowplayingcard.h deleted file mode 100644 index 8bf4294..0000000 --- a/src/qEmbyApp/views/admin/nowplayingcard.h +++ /dev/null @@ -1,45 +0,0 @@ -#ifndef NOWPLAYINGCARD_H -#define NOWPLAYINGCARD_H - -#include -#include -#include - - -struct SessionInfo; -class QEmbyCore; -class QLabel; -class QProgressBar; -class TweenedValue; - -class NowPlayingCard : public QFrame { - Q_OBJECT -public: - explicit NowPlayingCard(const SessionInfo& session, QEmbyCore* core, QWidget* parent = nullptr); - - - void updateSession(const SessionInfo& session); - - QString sessionId() const { return m_sessionId; } - -private: - void setupUi(const SessionInfo& session); - QCoro::Task loadImage(const SessionInfo& session); - - QEmbyCore* m_core; - QString m_sessionId; - QString m_currentImageItemId; - - - QLabel* m_imageLabel; - QLabel* m_titleLabel; - QLabel* m_subtitleLabel; - QLabel* m_progressText; - QLabel* m_stateIcon; - QProgressBar* m_progressBar; - QLabel* m_userLabel; - QLabel* m_streamLabel; - TweenedValue* m_progressTween = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/pagecollections.cpp b/src/qEmbyApp/views/admin/pagecollections.cpp deleted file mode 100644 index 25f4625..0000000 --- a/src/qEmbyApp/views/admin/pagecollections.cpp +++ /dev/null @@ -1,40 +0,0 @@ -#include "pagecollections.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "collectionsviewmodel.h" - -#include -#include -#include - -PageCollections::PageCollections(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - // ManageView uses this viewport for its existing wheel-event coordination. - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->viewport()->setAutoFillBackground(false); - - m_quickHost = new QuickPageHost(m_scrollArea); - m_viewModel = new CollectionsViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("collectionsPage"), - m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminCollectionsPage.qml"))); - m_scrollArea->setWidget(m_quickHost); - m_mainLayout->addWidget(m_scrollArea, 1); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this] { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this] { m_viewModel->setActive(false); }); -} diff --git a/src/qEmbyApp/views/admin/pagecollections.h b/src/qEmbyApp/views/admin/pagecollections.h deleted file mode 100644 index f9494f3..0000000 --- a/src/qEmbyApp/views/admin/pagecollections.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef PAGECOLLECTIONS_H -#define PAGECOLLECTIONS_H - -#include "managepagebase.h" - -class CollectionsViewModel; -class QScrollArea; -class QuickPageHost; - -// Compatibility shell: ManageView still constructs PageCollections and uses -// scrollArea(), while the page body and state are owned by QML/ViewModel. -class PageCollections final : public ManagePageBase { - Q_OBJECT - -public: - explicit PageCollections(QEmbyCore *core, QWidget *parent = nullptr); - QScrollArea *scrollArea() const { return m_scrollArea; } - -private: - QScrollArea *m_scrollArea = nullptr; - QuickPageHost *m_quickHost = nullptr; - CollectionsViewModel *m_viewModel = nullptr; -}; - -#endif // PAGECOLLECTIONS_H diff --git a/src/qEmbyApp/views/admin/pagedashboard.cpp b/src/qEmbyApp/views/admin/pagedashboard.cpp deleted file mode 100644 index 8bd1a01..0000000 --- a/src/qEmbyApp/views/admin/pagedashboard.cpp +++ /dev/null @@ -1,502 +0,0 @@ -#include "pagedashboard.h" -#include "../../components/horizontalwidgetgallery.h" -#include "../../components/modernmessagebox.h" -#include "../../components/moderntoast.h" -#include "../../utils/layoututils.h" -#include "activitylogdelegate.h" -#include "nowplayingcard.h" -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/refreshspin.h" - -PageDashboard::PageDashboard(QEmbyCore *core, QWidget *parent) : ManagePageBase(core, tr("Dashboard"), parent) -{ - - - - auto *infoCard = new QFrame(this); - infoCard->setObjectName("ManageInfoCard"); - - auto *infoLayout = new QVBoxLayout(infoCard); - infoLayout->setSpacing(10); - - - auto *titleRow = new QHBoxLayout(); - titleRow->setContentsMargins(0, 0, 0, 0); - - auto *infoTitle = new QLabel(tr("Server Information"), infoCard); - infoTitle->setObjectName("ManageCardTitle"); - titleRow->addWidget(infoTitle); - titleRow->addStretch(1); - - - m_btnRefresh = new QPushButton(infoCard); - m_btnRefresh->setObjectName("ManageRefreshBtn"); - m_btnRefresh->setCursor(Qt::PointingHandCursor); - m_btnRefresh->setFixedSize(32, 32); - m_btnRefresh->setToolTip(tr("Refresh")); - titleRow->addWidget(m_btnRefresh); - - titleRow->addSpacing(8); - - - m_btnRestart = new QPushButton(infoCard); - m_btnRestart->setObjectName("ManageRestartBtn"); - m_btnRestart->setCursor(Qt::PointingHandCursor); - m_btnRestart->setFixedSize(32, 32); - m_btnRestart->setToolTip(tr("Restart Server")); - titleRow->addWidget(m_btnRestart); - - titleRow->addSpacing(4); - - - m_btnShutdown = new QPushButton(infoCard); - m_btnShutdown->setObjectName("ManageShutdownBtn"); - m_btnShutdown->setCursor(Qt::PointingHandCursor); - m_btnShutdown->setFixedSize(32, 32); - m_btnShutdown->setToolTip(tr("Shutdown Server")); - titleRow->addWidget(m_btnShutdown); - - infoLayout->addLayout(titleRow); - infoLayout->addSpacing(4); - - infoLayout->addLayout(LayoutUtils::createInfoRow(tr("Server Name"), m_serverNameValue)); - infoLayout->addLayout(LayoutUtils::createInfoRow(tr("Version"), m_versionValue)); - infoLayout->addLayout(LayoutUtils::createInfoRow(tr("Operating System"), m_osValue)); - infoLayout->addLayout(LayoutUtils::createInfoRow(tr("Address"), m_addressValue)); - - m_mainLayout->addWidget(infoCard); - - - - - auto *nowPlayingCard = new QFrame(this); - nowPlayingCard->setObjectName("ManageInfoCard"); - - auto *nowPlayingOuterLayout = new QVBoxLayout(nowPlayingCard); - nowPlayingOuterLayout->setSpacing(12); - - auto *nowPlayingTitle = new QLabel(tr("Now Playing"), nowPlayingCard); - nowPlayingTitle->setObjectName("ManageCardTitle"); - nowPlayingOuterLayout->addWidget(nowPlayingTitle); - - m_nowPlayingGallery = new HorizontalWidgetGallery(nowPlayingCard); - m_nowPlayingGallery->setSpacing(12); - nowPlayingOuterLayout->addWidget(m_nowPlayingGallery); - - m_nowPlayingEmptyLabel = new QLabel(tr("Nothing is playing"), nowPlayingCard); - m_nowPlayingEmptyLabel->setObjectName("ManageEmptyLabel"); - m_nowPlayingEmptyLabel->setAlignment(Qt::AlignCenter); - nowPlayingOuterLayout->addWidget(m_nowPlayingEmptyLabel); - - m_mainLayout->addWidget(nowPlayingCard); - - - - - auto *sessionsCard = new QFrame(this); - sessionsCard->setObjectName("ManageInfoCard"); - - auto *sessionsOuterLayout = new QVBoxLayout(sessionsCard); - sessionsOuterLayout->setSpacing(12); - - auto *sessionsTitle = new QLabel(tr("Active Sessions"), sessionsCard); - sessionsTitle->setObjectName("ManageCardTitle"); - sessionsOuterLayout->addWidget(sessionsTitle); - - m_sessionsGrid = new QGridLayout(); - m_sessionsGrid->setSpacing(8); - sessionsOuterLayout->addLayout(m_sessionsGrid); - - m_sessionsEmptyLabel = new QLabel(tr("No active sessions"), sessionsCard); - m_sessionsEmptyLabel->setObjectName("ManageEmptyLabel"); - m_sessionsEmptyLabel->setAlignment(Qt::AlignCenter); - m_sessionsGrid->addWidget(m_sessionsEmptyLabel, 0, 0, 1, 2); - - m_mainLayout->addWidget(sessionsCard); - - - - - auto *activityCard = new QFrame(this); - activityCard->setObjectName("ManageActivityCard"); - - auto *activityOuterLayout = new QVBoxLayout(activityCard); - activityOuterLayout->setSpacing(8); - activityOuterLayout->setContentsMargins(20, 20, 0, 20); - - auto *activityTitle = new QLabel(tr("Activity Log"), activityCard); - activityTitle->setObjectName("ManageCardTitle"); - activityOuterLayout->addWidget(activityTitle); - - m_activityModel = new QStandardItemModel(this); - - m_activityListView = new QListView(activityCard); - m_activityListView->setObjectName("ActivityLogList"); - m_activityListView->setModel(m_activityModel); - m_activityListView->setItemDelegate(new ActivityLogDelegate(m_activityListView)); - m_activityListView->setSelectionMode(QAbstractItemView::NoSelection); - m_activityListView->setEditTriggers(QAbstractItemView::NoEditTriggers); - m_activityListView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); - m_activityListView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_activityListView->setFixedHeight(400); - - - QScroller::grabGesture(m_activityListView->viewport(), QScroller::LeftMouseButtonGesture); - QScroller *scroller = QScroller::scroller(m_activityListView->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); - - activityOuterLayout->addWidget(m_activityListView); - - m_activityEmptyLabel = new QLabel(tr("No activity records"), activityCard); - m_activityEmptyLabel->setObjectName("ManageEmptyLabel"); - m_activityEmptyLabel->setAlignment(Qt::AlignCenter); - m_activityEmptyLabel->hide(); - activityOuterLayout->addWidget(m_activityEmptyLabel); - - m_mainLayout->addWidget(activityCard); - m_mainLayout->addStretch(1); - - - - - connect(m_btnRefresh, &QPushButton::clicked, this, [this]() { loadData(true); }); - connect(m_btnRestart, &QPushButton::clicked, this, [this]() { onRestartClicked(); }); - connect(m_btnShutdown, &QPushButton::clicked, this, [this]() { onShutdownClicked(); }); - - - - - m_refreshTimer = new QTimer(this); - m_refreshTimer->setInterval(2000); - connect(m_refreshTimer, &QTimer::timeout, this, [this]() { loadData(false); }); - - - loadData(); -} - -void PageDashboard::showEvent(QShowEvent *event) -{ - ManagePageBase::showEvent(event); - m_refreshTimer->start(); - loadData(false); -} - -void PageDashboard::hideEvent(QHideEvent *event) -{ - ManagePageBase::hideEvent(event); - m_refreshTimer->stop(); -} - -QCoro::Task PageDashboard::loadData(bool isManual) -{ - - if (m_loadingData && !isManual) - co_return; - m_loadingData = true; - - - if (isManual) - { - m_btnRefresh->setEnabled(false); - RefreshSpin::start(m_btnRefresh); - } - - try - { - - auto sysInfo = co_await m_core->adminService()->getSystemInfo(); - - m_serverNameValue->setText(sysInfo.serverName); - m_versionValue->setText(sysInfo.version); - m_osValue->setText(sysInfo.operatingSystemName.isEmpty() ? sysInfo.operatingSystem - : sysInfo.operatingSystemName); - - QString address = sysInfo.localAddress; - if (!sysInfo.wanAddress.isEmpty()) - { - address += QString(" | WAN: %1").arg(sysInfo.wanAddress); - } - m_addressValue->setText(address); - - - m_btnRestart->setEnabled(sysInfo.canSelfRestart); - - - auto sessions = co_await m_core->adminService()->getActiveSessions(); - - - QList playingSessions; - QList allSessions; - - for (const auto &session : sessions) - { - if (session.userName.isEmpty()) - continue; - allSessions.append(session); - if (session.isNowPlaying()) - { - playingSessions.append(session); - } - } - - - - - static constexpr qint64 kExpireMs = 30000; - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); - - if (playingSessions.isEmpty()) - { - m_nowPlayingGallery->hide(); - m_nowPlayingEmptyLabel->show(); - for (auto *card : m_nowPlayingCards) - { - card->hide(); - card->setParent(nullptr); - card->deleteLater(); - } - m_nowPlayingCards.clear(); - m_cardLastSeen.clear(); - } - else - { - m_nowPlayingEmptyLabel->hide(); - m_nowPlayingGallery->show(); - - - QSet activeKeys; - for (const auto &session : playingSessions) - { - activeKeys.insert(session.id + "|" + session.nowPlayingItemId); - } - - - for (auto it = m_nowPlayingCards.begin(); it != m_nowPlayingCards.end();) - { - if (!activeKeys.contains(it.key())) - { - qint64 lastSeen = m_cardLastSeen.value(it.key(), 0); - if (nowMs - lastSeen >= kExpireMs) - { - it.value()->hide(); - it.value()->setParent(nullptr); - it.value()->deleteLater(); - m_cardLastSeen.remove(it.key()); - it = m_nowPlayingCards.erase(it); - } - else - { - ++it; - } - } - else - { - ++it; - } - } - - - for (const auto &session : playingSessions) - { - QString compositeKey = session.id + "|" + session.nowPlayingItemId; - m_cardLastSeen[compositeKey] = nowMs; - if (m_nowPlayingCards.contains(compositeKey)) - { - m_nowPlayingCards[compositeKey]->updateSession(session); - } - else - { - auto *card = new NowPlayingCard(session, m_core); - m_nowPlayingCards.insert(compositeKey, card); - m_nowPlayingGallery->addWidget(card); - } - } - m_nowPlayingGallery->adjustHeightToContent(); - } - - - while (m_sessionsGrid->count() > 0) - { - auto *item = m_sessionsGrid->takeAt(0); - if (item->widget()) - item->widget()->deleteLater(); - delete item; - } - - if (allSessions.isEmpty()) - { - m_sessionsEmptyLabel = new QLabel(tr("No active sessions"), this); - m_sessionsEmptyLabel->setObjectName("ManageEmptyLabel"); - m_sessionsEmptyLabel->setAlignment(Qt::AlignCenter); - m_sessionsGrid->addWidget(m_sessionsEmptyLabel, 0, 0, 1, 2); - } - else - { - - auto formatTime = [](const QString &isoDate) -> QString - { - if (isoDate.isEmpty()) - return {}; - QDateTime dt = QDateTime::fromString(isoDate, Qt::ISODate); - if (!dt.isValid()) - { - dt = QDateTime::fromString(isoDate, "yyyy-MM-ddTHH:mm:ss.zzzzzzzZ"); - } - if (!dt.isValid()) - return isoDate; - dt.setTimeZone(QTimeZone::UTC); - return dt.toLocalTime().toString("yyyy-MM-dd HH:mm:ss"); - }; - - const int columns = 3; - for (int i = 0; i < allSessions.size(); ++i) - { - const auto &session = allSessions[i]; - - auto *frame = new QFrame(this); - frame->setObjectName("ManageSessionCard"); - - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(4); - - - auto *userLabel = new QLabel(session.userName); - userLabel->setObjectName("ManageSessionUser"); - layout->addWidget(userLabel); - - - auto *clientLabel = new QLabel( - QString("%1 · %2 (v%3)").arg(session.client, session.deviceName, session.applicationVersion)); - clientLabel->setObjectName("ManageSessionClient"); - layout->addWidget(clientLabel); - - - QString info; - QString timeStr = formatTime(session.lastActivityDate); - if (!timeStr.isEmpty()) - info += timeStr; - if (!session.remoteEndPoint.isEmpty()) - { - if (!info.isEmpty()) - info += " | "; - info += session.remoteEndPoint; - } - if (!info.isEmpty()) - { - auto *infoLabel = new QLabel(info); - infoLabel->setObjectName("ManageSessionTime"); - layout->addWidget(infoLabel); - } - - m_sessionsGrid->addWidget(frame, i / columns, i % columns); - } - } - - - auto entries = co_await m_core->adminService()->getActivityLog(200); - - m_activityModel->clear(); - if (entries.isEmpty()) - { - m_activityListView->hide(); - m_activityEmptyLabel->show(); - } - else - { - m_activityEmptyLabel->hide(); - m_activityListView->show(); - - for (const auto &entry : entries) - { - auto *item = new QStandardItem(); - item->setData(entry.name, ActivityLogDelegate::NameRole); - item->setData(entry.overview.isEmpty() ? entry.shortOverview : entry.overview, - ActivityLogDelegate::OverviewRole); - item->setData(entry.date, ActivityLogDelegate::DateRole); - item->setData(entry.severity, ActivityLogDelegate::SeverityRole); - item->setData(entry.type, ActivityLogDelegate::TypeRole); - m_activityModel->appendRow(item); - } - } - } - catch (const std::exception &e) - { - ModernToast::showMessage(tr("Failed to load: %1").arg(QString::fromUtf8(e.what())), 3000); - } - - if (!m_btnRefresh->isEnabled()) - m_btnRefresh->setEnabled(true); - - m_loadingData = false; -} - -QCoro::Task PageDashboard::onRestartClicked() -{ - bool confirmed = - ModernMessageBox::question(this, tr("Restart Server"), - tr("Are you sure you want to restart the server? All active sessions " - "will be terminated."), - tr("Restart"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); - - if (!confirmed) - co_return; - - try - { - co_await m_core->adminService()->restartServer(); - ModernToast::showMessage(tr("Server restart command sent"), 2000); - } - catch (const std::exception &e) - { - ModernToast::showMessage(tr("Restart failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} - -QCoro::Task PageDashboard::onShutdownClicked() -{ - bool confirmed = - ModernMessageBox::question(this, tr("Shutdown Server"), - tr("Are you sure you want to shut down the server? This will stop all " - "services and disconnect all clients."), - tr("Shutdown"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); - - if (!confirmed) - co_return; - - try - { - co_await m_core->adminService()->shutdownServer(); - ModernToast::showMessage(tr("Server shutdown command sent"), 2000); - } - catch (const std::exception &e) - { - ModernToast::showMessage(tr("Shutdown failed: %1").arg(QString::fromUtf8(e.what())), 3000); - } -} diff --git a/src/qEmbyApp/views/admin/pagedashboard.h b/src/qEmbyApp/views/admin/pagedashboard.h deleted file mode 100644 index ecd1c10..0000000 --- a/src/qEmbyApp/views/admin/pagedashboard.h +++ /dev/null @@ -1,66 +0,0 @@ -#ifndef PAGEDASHBOARD_H -#define PAGEDASHBOARD_H - -#include "managepagebase.h" -#include -#include - -class QListView; -class QStandardItemModel; - -class QLabel; -class QPushButton; -class QVBoxLayout; -class QGridLayout; -class QTimer; -class HorizontalWidgetGallery; -class NowPlayingCard; - -class PageDashboard : public ManagePageBase { - Q_OBJECT -public: - explicit PageDashboard(QEmbyCore* core, QWidget* parent = nullptr); - -protected: - void showEvent(QShowEvent* event) override; - void hideEvent(QHideEvent* event) override; - -private: - QCoro::Task loadData(bool isManual = false); - QCoro::Task onRestartClicked(); - QCoro::Task onShutdownClicked(); - - - QLabel* m_serverNameValue; - QLabel* m_versionValue; - QLabel* m_osValue; - QLabel* m_addressValue; - - - HorizontalWidgetGallery* m_nowPlayingGallery; - QLabel* m_nowPlayingEmptyLabel; - - QMap m_nowPlayingCards; - - QMap m_cardLastSeen; - - - QGridLayout* m_sessionsGrid; - QLabel* m_sessionsEmptyLabel; - - - QListView* m_activityListView; - QStandardItemModel* m_activityModel; - QLabel* m_activityEmptyLabel; - - - QPushButton* m_btnRefresh; - QPushButton* m_btnRestart; - QPushButton* m_btnShutdown; - - - QTimer* m_refreshTimer; - bool m_loadingData = false; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/pagelibraries.cpp b/src/qEmbyApp/views/admin/pagelibraries.cpp deleted file mode 100644 index b7df270..0000000 --- a/src/qEmbyApp/views/admin/pagelibraries.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include "pagelibraries.h" - -#include "../../quick/adminlibrariesviewmodel.h" -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" - -#include -#include -#include -#include - -PageLibraries::PageLibraries(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - // ManageView currently tracks this viewport for wheel-event coordination. - // Keep that contract while making QuickPageHost the only page content. - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->viewport()->setAutoFillBackground(false); - - m_quickHost = new QuickPageHost(m_scrollArea); - m_viewModel = new AdminLibrariesViewModel(core, m_quickHost, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("librariesPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminLibrariesPage.qml"))); - m_scrollArea->setWidget(m_quickHost); - m_mainLayout->addWidget(m_scrollArea); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this] { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this] { m_viewModel->setActive(false); }); -} diff --git a/src/qEmbyApp/views/admin/pagelibraries.h b/src/qEmbyApp/views/admin/pagelibraries.h deleted file mode 100644 index 04f0692..0000000 --- a/src/qEmbyApp/views/admin/pagelibraries.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef PAGELIBRARIES_H -#define PAGELIBRARIES_H - -#include "managepagebase.h" - -class AdminLibrariesViewModel; -class QScrollArea; -class QuickPageHost; - -// Compatibility shell kept because ManageView owns and indexes PageLibraries. -// The complete page body and interaction state now live in QML/ViewModel. -class PageLibraries final : public ManagePageBase { - Q_OBJECT - -public: - explicit PageLibraries(QEmbyCore *core, QWidget *parent = nullptr); - QScrollArea *scrollArea() const { return m_scrollArea; } - -private: - QScrollArea *m_scrollArea = nullptr; - QuickPageHost *m_quickHost = nullptr; - AdminLibrariesViewModel *m_viewModel = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/pagelogs.cpp b/src/qEmbyApp/views/admin/pagelogs.cpp deleted file mode 100644 index f7602d5..0000000 --- a/src/qEmbyApp/views/admin/pagelogs.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "pagelogs.h" - -#include "../../quick/logsviewmodel.h" -#include "../../quick/quickpagehost.h" - -#include -#include -#include -#include - -PageLogs::PageLogs(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new LogsViewModel(core, this, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("logsPage"), m_viewModel); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminLogsPage.qml"))); - m_mainLayout->addWidget(m_quickHost); - - // ManageView tracks legacy scroll areas. QML owns the visible scrolling. - m_scrollArea = new QScrollArea(this); - m_scrollArea->hide(); -} - -void PageLogs::showEvent(QShowEvent *event) { - ManagePageBase::showEvent(event); - m_viewModel->setActive(true); -} - -void PageLogs::hideEvent(QHideEvent *event) { - m_viewModel->setActive(false); - ManagePageBase::hideEvent(event); -} diff --git a/src/qEmbyApp/views/admin/pagelogs.h b/src/qEmbyApp/views/admin/pagelogs.h deleted file mode 100644 index c44f9eb..0000000 --- a/src/qEmbyApp/views/admin/pagelogs.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef PAGELOGS_H -#define PAGELOGS_H - -#include "managepagebase.h" - -class LogsViewModel; -class QHideEvent; -class QScrollArea; -class QShowEvent; -class QuickPageHost; - -// Compatibility shell retained until ManageView itself moves to QML. -class PageLogs : public ManagePageBase { - Q_OBJECT -public: - explicit PageLogs(QEmbyCore *core, QWidget *parent = nullptr); - QScrollArea *scrollArea() const { return m_scrollArea; } - -protected: - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - -private: - QuickPageHost *m_quickHost = nullptr; - LogsViewModel *m_viewModel = nullptr; - QScrollArea *m_scrollArea = nullptr; -}; - -#endif // PAGELOGS_H diff --git a/src/qEmbyApp/views/admin/pagetasks.cpp b/src/qEmbyApp/views/admin/pagetasks.cpp deleted file mode 100644 index cd97126..0000000 --- a/src/qEmbyApp/views/admin/pagetasks.cpp +++ /dev/null @@ -1,50 +0,0 @@ -#include "pagetasks.h" - -#include "../../components/taskeditdialog.h" -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "../../quick/tasksviewmodel.h" - -#include -#include -#include -#include - -PageTasks::PageTasks(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new TasksViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("tasksPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminTasksPage.qml"))); - m_mainLayout->addWidget(m_quickHost); - - connect(m_viewModel, &TasksViewModel::editRequested, this, - [this](const QString &taskId, const QString &taskName) { - auto *dialog = new TaskEditDialog(m_core, taskId, taskName, this); - connect(dialog, &TaskEditDialog::triggersUpdated, m_viewModel, - &TasksViewModel::refresh); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); - }); - - // Temporary compatibility object for ManageView::scrollArea(). It is not - // part of the visual hierarchy; AdminTasksPage owns the real scrolling. - m_scrollArea = new QScrollArea(this); - m_scrollArea->hide(); -} - -void PageTasks::showEvent(QShowEvent *event) { - ManagePageBase::showEvent(event); - m_viewModel->setActive(true); -} - -void PageTasks::hideEvent(QHideEvent *event) { - m_viewModel->setActive(false); - ManagePageBase::hideEvent(event); -} diff --git a/src/qEmbyApp/views/admin/pagetasks.h b/src/qEmbyApp/views/admin/pagetasks.h deleted file mode 100644 index 0e281df..0000000 --- a/src/qEmbyApp/views/admin/pagetasks.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef PAGETASKS_H -#define PAGETASKS_H - -#include "managepagebase.h" - -class QHideEvent; -class QScrollArea; -class QShowEvent; -class QuickPageHost; -class TasksViewModel; - -// Compatibility shell kept for ManageView while the page body lives in QML. -class PageTasks : public ManagePageBase { - Q_OBJECT -public: - explicit PageTasks(QEmbyCore *core, QWidget *parent = nullptr); - - // ManageView still registers every legacy page's scroll area for wheel - // animation. The QML page scrolls itself; this hidden object only preserves - // that construction-time contract until ManageView is migrated. - QScrollArea *scrollArea() const { return m_scrollArea; } - -protected: - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - -private: - QuickPageHost *m_quickHost = nullptr; - TasksViewModel *m_viewModel = nullptr; - QScrollArea *m_scrollArea = nullptr; -}; - -#endif // PAGETASKS_H diff --git a/src/qEmbyApp/views/admin/pagetranscoding.cpp b/src/qEmbyApp/views/admin/pagetranscoding.cpp deleted file mode 100644 index 96d3d16..0000000 --- a/src/qEmbyApp/views/admin/pagetranscoding.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include "pagetranscoding.h" - -#include "../../components/embycodecinfodialog.h" -#include "../../components/embycodecparametersdialog.h" -#include "../../quick/quickpagehost.h" -#include "transcodingviewmodel.h" - -#include -#include -#include -#include - -PageTranscoding::PageTranscoding(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - setObjectName(QStringLiteral("admin-transcoding-page")); - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - // ManageView uses this viewport for wheel-event coordination. QML owns the - // actual scrolling, so the compatibility scroll bars remain disabled. - m_scrollArea = new QScrollArea(this); - m_scrollArea->setObjectName(QStringLiteral("SettingsScrollArea")); - m_scrollArea->setWidgetResizable(true); - m_scrollArea->setFrameShape(QFrame::NoFrame); - m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - m_quickHost = new QuickPageHost(m_scrollArea); - m_viewModel = new TranscodingViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("transcodingPage"), - m_viewModel); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminTranscodingPage.qml"))); - m_scrollArea->setWidget(m_quickHost); - m_mainLayout->addWidget(m_scrollArea, 1); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this] { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this] { m_viewModel->setActive(false); }); - connect(m_viewModel, &TranscodingViewModel::pathPickerRequested, this, - &PageTranscoding::choosePath); - connect(m_viewModel, &TranscodingViewModel::codecInfoRequested, this, - &PageTranscoding::showCodecInfo); - connect(m_viewModel, &TranscodingViewModel::codecParametersRequested, this, - &PageTranscoding::showCodecParameters); -} - -void PageTranscoding::choosePath(const QString &key, bool directory) { - const QString initial = m_viewModel->value(key).toString(); - const QString selected = - directory - ? QFileDialog::getExistingDirectory(this, tr("Select Folder"), - initial) - : QFileDialog::getOpenFileName(this, tr("Select File"), initial); - if (!selected.isEmpty()) - m_viewModel->setValue(key, selected); -} - -void PageTranscoding::showCodecInfo(const QString &codecId) { - const QJsonObject info = m_viewModel->codecInfo(codecId); - if (info.isEmpty()) - return; - auto *dialog = new EmbyCodecInfoDialog(info, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); -} - -void PageTranscoding::showCodecParameters(const QString &codecId) { - const QJsonObject info = m_viewModel->codecInfo(codecId); - if (info.isEmpty()) - return; - auto *dialog = new EmbyCodecParametersDialog(m_core, info, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); -} diff --git a/src/qEmbyApp/views/admin/pagetranscoding.h b/src/qEmbyApp/views/admin/pagetranscoding.h deleted file mode 100644 index 22a4bc3..0000000 --- a/src/qEmbyApp/views/admin/pagetranscoding.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef PAGETRANSCODING_H -#define PAGETRANSCODING_H - -#include "managepagebase.h" - -class QScrollArea; -class QuickPageHost; -class TranscodingViewModel; - -// Compatibility shell retained for ManageView. The page body and editable state -// live in AdminTranscodingPage.qml; API serialization remains in the ViewModel. -class PageTranscoding final : public ManagePageBase { - Q_OBJECT - -public: - explicit PageTranscoding(QEmbyCore *core, QWidget *parent = nullptr); - QScrollArea *scrollArea() const { return m_scrollArea; } - -private: - void choosePath(const QString &key, bool directory); - void showCodecInfo(const QString &codecId); - void showCodecParameters(const QString &codecId); - - QScrollArea *m_scrollArea = nullptr; - QuickPageHost *m_quickHost = nullptr; - TranscodingViewModel *m_viewModel = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/admin/pageusers.cpp b/src/qEmbyApp/views/admin/pageusers.cpp deleted file mode 100644 index 61ad8c5..0000000 --- a/src/qEmbyApp/views/admin/pageusers.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include "pageusers.h" - -#include "../../components/adduserdialog.h" -#include "../../components/usereditdialog.h" -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "usersviewmodel.h" - -#include - -PageUsers::PageUsers(QEmbyCore *core, QWidget *parent) - : ManagePageBase(core, QString(), parent) { - setObjectName(QStringLiteral("admin-users-page")); - setAttribute(Qt::WA_StyledBackground, true); - m_mainLayout->setContentsMargins(0, 0, 0, 0); - m_mainLayout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new UsersViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("usersPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/AdminUsersPage.qml"))); - m_mainLayout->addWidget(m_quickHost, 1); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this]() { m_viewModel->setActive(false); }); - connect(m_viewModel, &UsersViewModel::addUserRequested, this, - &PageUsers::openAddUserDialog); - connect(m_viewModel, &UsersViewModel::editUserRequested, this, - &PageUsers::openUserEditor); -} - -void PageUsers::openAddUserDialog() { - auto *dialog = new AddUserDialog(m_core, this); - connect(dialog, &AddUserDialog::userCreated, m_viewModel, - &UsersViewModel::reloadAfterDialog); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); -} - -void PageUsers::openUserEditor(const QString &userId) { - auto *dialog = new UserEditDialog(m_core, userId, this); - connect(dialog, &UserEditDialog::userUpdated, m_viewModel, - &UsersViewModel::reloadAfterDialog); - connect(dialog, &UserEditDialog::userDeleted, m_viewModel, - &UsersViewModel::reloadAfterDialog); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->open(); -} diff --git a/src/qEmbyApp/views/admin/pageusers.h b/src/qEmbyApp/views/admin/pageusers.h deleted file mode 100644 index f9d3528..0000000 --- a/src/qEmbyApp/views/admin/pageusers.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef PAGEUSERS_H -#define PAGEUSERS_H - -#include "managepagebase.h" - -class QuickPageHost; -class UsersViewModel; - -// 保留 ManageView 依赖的 QWidget 构造与生命周期契约;页面主体由 -// AdminUsersPage.qml 渲染,复杂的新增/编辑对话框暂由 Widgets 承载。 -class PageUsers final : public ManagePageBase { - Q_OBJECT - -public: - explicit PageUsers(QEmbyCore *core, QWidget *parent = nullptr); - -private: - void openAddUserDialog(); - void openUserEditor(const QString &userId); - - QuickPageHost *m_quickHost = nullptr; - UsersViewModel *m_viewModel = nullptr; -}; - -#endif // PAGEUSERS_H diff --git a/src/qEmbyApp/views/media/detailview.cpp b/src/qEmbyApp/views/media/detailview.cpp deleted file mode 100644 index cf877d5..0000000 --- a/src/qEmbyApp/views/media/detailview.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include "detailview.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "../../utils/mediaitemutils.h" -#include "../../utils/playerpreferenceutils.h" -#include "detailviewmodel.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include - -DetailView::DetailView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("detail-view")); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new DetailViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("detailPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/DetailPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &DetailViewModel::itemActivated, this, - [this](const MediaItem &item) { - if (item.type == QLatin1String("Season")) { - const QString seriesId = item.seriesId.isEmpty() - ? m_viewModel->item().id - : item.seriesId; - emit navigateToSeason(seriesId, item.id, item.name); - } else if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) { - emit navigateToFolder(item.id, item.name); - } else if (item.type == QLatin1String("Person")) { - emit navigateToPerson(item.id, item.name); - } else { - emit navigateToDetail(item.id, item.name, item); - } - }); - connect(m_viewModel, &DetailViewModel::genreActivated, this, - [this](const QString &genre) { - emit navigateToFilteredView(QStringLiteral("Genre"), genre); - }); - connect(m_viewModel, &DetailViewModel::playRequested, this, - [this](const MediaItem &item, int sourceIndex, bool resume) { - playSelected(item, sourceIndex, resume); - }); - connect(m_viewModel, &DetailViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &DetailViewModel::playedRequested, this, - [this](const MediaItem &item, bool played) { - if (played) - handleMarkPlayedRequested(item); - else - handleMarkUnplayedRequested(item); - }); - connect(m_viewModel, &DetailViewModel::moreRequested, this, - [this](const MediaItem &item) { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -QCoro::Task DetailView::loadItem(const QString &itemId, - const MediaItem &seedItem) { - co_await m_viewModel->loadItem(itemId, seedItem); -} - -void DetailView::prepareForStackLeave() { m_viewModel->setActive(false); } - -void DetailView::scrollToTop() { - if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) - QMetaObject::invokeMethod(root, "scrollToTop"); -} - -void DetailView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void DetailView::beginOptimisticPlayedUpdate() { - m_viewModel->setActionBusy(true); -} - -void DetailView::endOptimisticPlayedUpdate() { - m_viewModel->setActionBusy(false); -} - -QCoro::Task DetailView::playSelected(MediaItem item, int sourceIndex, - bool resume) { - if (!m_core || !m_core->mediaService() || item.id.isEmpty()) - co_return; - - QPointer guard(this); - try { - if (item.mediaSources.isEmpty()) { - const PlaybackInfo info = - co_await m_core->mediaService()->getPlaybackInfo(item.id); - if (!guard) - co_return; - item.mediaSources = info.mediaSources; - } - - QVariant launchData; - QString sourceId = item.id; - QString streamUrl; - if (!item.mediaSources.isEmpty()) { - sourceIndex = qBound(0, sourceIndex, item.mediaSources.size() - 1); - MediaSourceInfo selected = item.mediaSources.at(sourceIndex); - PlayerPreferenceUtils::applyPreferredStreamRules( - selected, - ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, - "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, - "auto")); - sourceId = selected.id; - PlayerLaunchContext context; - context.mediaItem = item; - context.selectedSource = selected; - launchData = QVariant::fromValue(context); - streamUrl = m_core->mediaService()->getStreamUrl(item.id, selected); - } else { - streamUrl = m_core->mediaService()->getStreamUrl(item.id, sourceId); - } - - if (!guard) - co_return; - const long long startTicks = - resume ? item.userData.playbackPositionTicks : 0; - emit navigateToPlayer(item.id, MediaItemUtils::playbackTitle(item), - streamUrl, startTicks, launchData); - } catch (...) { - if (guard) - m_viewModel->setTransientError(tr("Unable to prepare playback")); - } -} diff --git a/src/qEmbyApp/views/media/detailview.h b/src/qEmbyApp/views/media/detailview.h deleted file mode 100644 index 0045f4c..0000000 --- a/src/qEmbyApp/views/media/detailview.h +++ /dev/null @@ -1,37 +0,0 @@ -#ifndef DETAILVIEW_H -#define DETAILVIEW_H - -#include "../baseview.h" - -#include - -class DetailViewModel; -class QuickPageHost; - -// QWidget compatibility shell for the QML detail page. HomeView constructs and -// connects this class exactly as before; presentation state lives in -// DetailViewModel. -class DetailView final : public BaseView { - Q_OBJECT - -public: - explicit DetailView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task loadItem(const QString &itemId, - const MediaItem &seedItem = {}); - void prepareForStackLeave() override; - -public slots: - void scrollToTop() override; - void onMediaItemUpdated(const MediaItem &item) override; - void beginOptimisticPlayedUpdate() override; - void endOptimisticPlayedUpdate() override; - -private: - QCoro::Task playSelected(MediaItem item, int sourceIndex, bool resume); - - QuickPageHost *m_quickHost = nullptr; - DetailViewModel *m_viewModel = nullptr; -}; - -#endif // DETAILVIEW_H diff --git a/src/qEmbyApp/views/media/libraryview.cpp b/src/qEmbyApp/views/media/libraryview.cpp deleted file mode 100644 index d199b4e..0000000 --- a/src/qEmbyApp/views/media/libraryview.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "libraryview.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "libraryviewmodel.h" - -#include -#include -#include -#include -#include - -LibraryView::LibraryView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("library-view")); - setProperty("showGlobalSearch", true); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new LibraryViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("libraryPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/LibraryPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, this, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, this, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &LibraryViewModel::itemActivated, this, - [this](const MediaItem &item) { - if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) { - emit navigateToFolder(item.id, item.name); - } else if (item.type == QLatin1String("Person")) { - emit navigateToPerson(item.id, item.name); - } else { - emit navigateToDetail(item.id, item.name, item); - } - }); - connect(m_viewModel, &LibraryViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &LibraryViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &LibraryViewModel::currentFavoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &LibraryViewModel::moreRequested, this, - [this](const MediaItem &item) { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -QCoro::Task LibraryView::loadLibrary(const QString &libraryId, - const QString &libraryName) { - co_await m_viewModel->loadLibrary(libraryId, libraryName); -} - -QCoro::Task LibraryView::loadPerson(const QString &personId, - const QString &personName) { - co_await m_viewModel->loadPerson(personId, personName); -} - -QCoro::Task LibraryView::loadFiltered(const QString &filterType, - const QString &filterValue) { - co_await m_viewModel->loadFiltered(filterType, filterValue); -} - -void LibraryView::prepareForStackLeave() { m_viewModel->setActive(false); } - -void LibraryView::scrollToTop() { - if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) - QMetaObject::invokeMethod(root, "scrollToTop"); -} - -void LibraryView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void LibraryView::onMediaItemRemoved(const QString &itemId) { - m_viewModel->removeItem(itemId); -} diff --git a/src/qEmbyApp/views/media/libraryview.h b/src/qEmbyApp/views/media/libraryview.h deleted file mode 100644 index 89a0cf3..0000000 --- a/src/qEmbyApp/views/media/libraryview.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef LIBRARYVIEW_H -#define LIBRARYVIEW_H - -#include "../baseview.h" - -#include - -class LibraryViewModel; -class QuickPageHost; - -class LibraryView final : public BaseView { - Q_OBJECT - -public: - explicit LibraryView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task loadLibrary(const QString &libraryId, - const QString &libraryName); - QCoro::Task loadPerson(const QString &personId, - const QString &personName); - QCoro::Task loadFiltered(const QString &filterType, - const QString &filterValue); - - void prepareForStackLeave() override; - -public slots: - void scrollToTop() override; - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - -private: - QuickPageHost *m_quickHost = nullptr; - LibraryViewModel *m_viewModel = nullptr; -}; - -#endif // LIBRARYVIEW_H diff --git a/src/qEmbyApp/views/media/mediacarddelegate.cpp b/src/qEmbyApp/views/media/mediacarddelegate.cpp deleted file mode 100644 index 2a0ba1b..0000000 --- a/src/qEmbyApp/views/media/mediacarddelegate.cpp +++ /dev/null @@ -1,1017 +0,0 @@ -#include "mediacarddelegate.h" -#include "medialistmodel.h" - -#include "../../managers/thememanager.h" -#include "../../utils/indexhoveranimator.h" -#include "../../utils/popfeedback.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ - -enum class HoverOverlayMode -{ - None, - FullControls, - MenuOnly -}; - -bool isPlayableItem(const MediaItem &item) -{ - return item.mediaType == "Video" || item.type == "Movie" || item.type == "Episode" || item.type == "Video" || - item.type == "MusicVideo"; -} - -bool isPlaylistItem(const MediaItem &item) -{ - return item.type == "Playlist"; -} - -bool canShowMenuOnlyButtonForTile(const MediaItem &item) -{ - if (item.id.trimmed().isEmpty()) - { - return false; - } - - const QString normalizedCollectionType = item.collectionType.trimmed().toLower(); - const QString normalizedType = item.type.trimmed().toLower(); - return normalizedCollectionType != "playlists" && normalizedCollectionType != "boxsets" && - normalizedType != "playlist" && normalizedType != "boxset"; -} - -HoverOverlayMode hoverOverlayModeForItem(const MediaItem &item, MediaCardDelegate::CardStyle style, - bool showMoreButtonForNonPlayableTiles) -{ - if (style == MediaCardDelegate::EpisodeList) - { - return isPlayableItem(item) ? HoverOverlayMode::FullControls : HoverOverlayMode::None; - } - - if (isPlayableItem(item) || item.type == "Series" || item.type == "Season") - { - return HoverOverlayMode::FullControls; - } - - if (isPlaylistItem(item)) - { - return HoverOverlayMode::MenuOnly; - } - - if (style == MediaCardDelegate::LibraryTile && showMoreButtonForNonPlayableTiles && - canShowMenuOnlyButtonForTile(item)) - { - return HoverOverlayMode::MenuOnly; - } - - return HoverOverlayMode::None; -} - -QRect baseImageRectForCard(MediaCardDelegate::CardStyle style, const QRect &rect, int padding) -{ - const int imgWidth = rect.width() - padding * 2; - - if (style == MediaCardDelegate::LibraryTile) - { - const int imgHeight = qRound(imgWidth * 9.0 / 16.0); - return QRect(rect.x() + padding, rect.y() + padding, imgWidth, imgHeight); - } - - const int imgHeight = qRound(imgWidth * 1.5); - return QRect(rect.x() + padding, rect.y() + padding, imgWidth, imgHeight); -} - -QRect episodeImageRect(const QRect &rect) -{ - const int padding = 10; - const int imgWidth = 250; - const int imgHeight = 140; - return QRect(rect.x() + padding, rect.y() + (rect.height() - imgHeight) / 2, imgWidth, imgHeight); -} - -QRect episodeFavoriteRect(const QRect &rect) -{ - const int favBtnSize = 24; - const int rightMargin = 20; - return QRect(rect.right() - rightMargin - favBtnSize, rect.center().y() - favBtnSize / 2, favBtnSize, favBtnSize); -} - -QRect centerPlayButtonRect(const QRect &targetImgRect) -{ - const int playSize = 48; - return QRect(targetImgRect.center().x() - playSize / 2, targetImgRect.center().y() - playSize / 2, playSize, - playSize); -} - -QRect moreButtonRect(const QRect &targetImgRect) -{ - const int btnWidth = 28; - const int btnHeight = 28; - return QRect(targetImgRect.right() - btnWidth - 8, targetImgRect.bottom() - btnHeight - 8, btnWidth, btnHeight); -} - -QRect favoriteButtonRect(const QRect &moreRect) -{ - const int btnWidth = 28; - const int spacing = 6; - return QRect(moreRect.left() - btnWidth - spacing, moreRect.top(), btnWidth, moreRect.height()); -} - -bool shouldShowPlayButton(HoverOverlayMode overlayMode, MediaCardDelegate::HoverControls controls) -{ - return overlayMode == HoverOverlayMode::FullControls && controls.testFlag(MediaCardDelegate::HoverControlPlay); -} - -bool shouldShowFavoriteButton(HoverOverlayMode overlayMode, MediaCardDelegate::HoverControls controls) -{ - return overlayMode == HoverOverlayMode::FullControls && controls.testFlag(MediaCardDelegate::HoverControlFavorite); -} - -bool shouldShowMoreButton(HoverOverlayMode overlayMode, MediaCardDelegate::HoverControls controls) -{ - return overlayMode != HoverOverlayMode::None && controls.testFlag(MediaCardDelegate::HoverControlMore); -} - -struct HoverButtonLayout -{ - QRect playRect; - QRect favoriteRect; - QRect moreRect; -}; - -HoverButtonLayout buildHoverButtonLayout(const QRect &targetImgRect, bool showPlayButton, bool showFavoriteButton, - bool showMoreButton) -{ - HoverButtonLayout layout; - if (showPlayButton) - { - layout.playRect = centerPlayButtonRect(targetImgRect); - } - - const QRect trailingButtonRect = moreButtonRect(targetImgRect); - if (showMoreButton) - { - layout.moreRect = trailingButtonRect; - } - if (showFavoriteButton) - { - layout.favoriteRect = showMoreButton ? favoriteButtonRect(trailingButtonRect) : trailingButtonRect; - } - - return layout; -} - -QPixmap scaledCardPixmap(const MediaItem &item, const QPixmap &source, const QSize &targetSize, - MediaCardDelegate::CardStyle style) -{ - if (source.isNull() || targetSize.isEmpty()) - { - return {}; - } - - const QString cacheKey = QStringLiteral("MediaCardDelegate:%1:%2:%3x%4:%5") - .arg(item.id) - .arg(source.cacheKey()) - .arg(targetSize.width()) - .arg(targetSize.height()) - .arg(static_cast(style)); - - QPixmap cached; - if (QPixmapCache::find(cacheKey, &cached)) - { - return cached; - } - - QPixmap scaled = source.scaled(targetSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - QPixmapCache::insert(cacheKey, scaled); - return scaled; -} - -} - - -MediaCardThemeHelper::MediaCardThemeHelper(QWidget *parent) - : QWidget(parent), m_hoverBg(229, 231, 235), - m_selectedBg(219, 234, 254), m_titleColor(31, 41, 55), m_subTitleColor(107, 114, 128), - m_placeholderBg(229, 231, 235), m_placeholderTextColor(156, 163, 175) -{ - hide(); -} - - - -MediaCardDelegate::MediaCardDelegate(CardStyle style, QObject *parent) - : QStyledItemDelegate(parent), m_style(style), m_tileSize(160, 270), m_themeHelper(nullptr) -{ - m_hover = new IndexHoverAnimator(this); - // Stale-region reach: 3px lift + shadow falloff + 5px downward offset. - m_hover->setRepaintMargin(24); - m_hover->setRepaintCallback([this](const QModelIndex &index) { repaintIndex(index); }); -} - -void MediaCardDelegate::repaintIndex(const QModelIndex &index) const -{ - if (!index.isValid()) - { - return; - } - const int margin = m_hover->repaintMargin(); - for (QWidget *viewport : m_installedViewports) - { - auto *view = qobject_cast(viewport->parentWidget()); - if (view && view->model() == index.model()) - { - viewport->update(view->visualRect(index).adjusted(-margin, -margin, margin, margin)); - } - } -} - -MediaCardDelegate::HoverControl MediaCardDelegate::hoverButtonAt(const QRect &itemRect, const MediaItem &item, - const QPoint &pos) const -{ - const HoverOverlayMode overlayMode = hoverOverlayModeForItem(item, m_style, m_showMoreButtonForNonPlayableTiles); - if (overlayMode == HoverOverlayMode::None) - { - return HoverControlNone; - } - - if (m_style == EpisodeList) - { - if (m_hoverControls.testFlag(HoverControlFavorite) && episodeFavoriteRect(itemRect).contains(pos)) - { - return HoverControlFavorite; - } - if (m_hoverControls.testFlag(HoverControlPlay) && - centerPlayButtonRect(episodeImageRect(itemRect)).contains(pos)) - { - return HoverControlPlay; - } - return HoverControlNone; - } - - const QRect baseImgRect = baseImageRectForCard(m_style, itemRect, m_contentPadding); - const bool showPlayButton = shouldShowPlayButton(overlayMode, m_hoverControls); - const bool showFavoriteButton = shouldShowFavoriteButton(overlayMode, m_hoverControls); - const bool showMoreButton = shouldShowMoreButton(overlayMode, m_hoverControls); - if (!showPlayButton && !showFavoriteButton && !showMoreButton) - { - return HoverControlNone; - } - - const HoverButtonLayout layout = - buildHoverButtonLayout(baseImgRect, showPlayButton, showFavoriteButton, showMoreButton); - if (showPlayButton && layout.playRect.contains(pos)) - { - return HoverControlPlay; - } - if (showFavoriteButton && layout.favoriteRect.contains(pos)) - { - return HoverControlFavorite; - } - if (showMoreButton && layout.moreRect.contains(pos)) - { - return HoverControlMore; - } - return HoverControlNone; -} - -void MediaCardDelegate::setHoveredButton(const QPersistentModelIndex &index, HoverControl button) const -{ - if (m_hoverButtonIndex == index && m_hoveredButton == button) - { - return; - } - const QPersistentModelIndex previous = m_hoverButtonIndex; - m_hoverButtonIndex = index; - m_hoveredButton = button; - if (previous.isValid() && previous != index) - { - repaintIndex(previous); - } - repaintIndex(index); -} - -void MediaCardDelegate::setPressedIndex(const QPersistentModelIndex &index) const -{ - if (m_pressedIndex == index) - { - return; - } - const QPersistentModelIndex previous = m_pressedIndex; - m_pressedIndex = index; - if (previous.isValid()) - { - repaintIndex(previous); - } - repaintIndex(index); -} - -void MediaCardDelegate::playFavoritePop(const QPersistentModelIndex &index) -{ - if (!index.isValid()) - { - return; - } - auto *anim = PopFeedback::play(this, [this, index](qreal scale) { - m_popScale.insert(index, scale); - repaintIndex(index); - }); - if (anim) - { - connect(anim, &QObject::destroyed, this, [this, index]() { - m_popScale.remove(index); - repaintIndex(index); - }); - } - else - { - m_popScale.remove(index); - } -} - -qreal MediaCardDelegate::hoverProgressFor(const QStyleOptionViewItem &option, const QModelIndex &index) const -{ - // MouseMove on the viewport drives m_hover; State_MouseOver only bootstraps - // states the filter cannot see (view shown under the cursor, hover moved by - // scrolling). Never mutate animation state synchronously inside paint(). - if ((option.state & QStyle::State_MouseOver) && m_hover->hoveredIndex() != index) - { - const QPersistentModelIndex persistent(index); - QMetaObject::invokeMethod( - m_hover, [hover = m_hover, persistent]() { hover->setHoveredIndex(persistent); }, - Qt::QueuedConnection); - } - return m_hover->progressFor(index); -} - -const NinePatchShadow &MediaCardDelegate::liftShadow(qreal dpr, int cornerRadius) const -{ - if (m_liftShadow.isNull() || !qFuzzyCompare(m_liftShadow.dpr, dpr)) - { - m_liftShadow = ShadowPixmap::make(14, cornerRadius, QColor(0, 0, 0, 110), dpr); - } - return m_liftShadow; -} - -QSize MediaCardDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const -{ - Q_UNUSED(option); - Q_UNUSED(index); - - return m_tileSize; -} - - -bool MediaCardDelegate::eventFilter(QObject *object, QEvent *event) -{ - QWidget *viewport = qobject_cast(object); - QAbstractItemView *view = viewport ? qobject_cast(viewport->parentWidget()) : nullptr; - if (!view) - { - return QStyledItemDelegate::eventFilter(object, event); - } - - const QEvent::Type type = event->type(); - - if (type == QEvent::Leave) - { - m_hover->clear(); - setHoveredButton(QPersistentModelIndex(), HoverControlNone); - setPressedIndex(QPersistentModelIndex()); - return QStyledItemDelegate::eventFilter(object, event); - } - - if (type == QEvent::MouseMove) - { - QMouseEvent *mouseEvent = static_cast(event); - const QPoint pos = mouseEvent->pos(); - const QModelIndex index = view->indexAt(pos); - const QPersistentModelIndex persistent(index); - m_hover->setHoveredIndex(persistent); - - HoverControl button = HoverControlNone; - if (index.isValid()) - { - const MediaItem item = index.data(MediaListModel::ItemDataRole).value(); - button = hoverButtonAt(view->visualRect(index), item, pos); - } - setHoveredButton(button == HoverControlNone ? QPersistentModelIndex() : persistent, button); - return QStyledItemDelegate::eventFilter(object, event); - } - - if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease || - type == QEvent::MouseButtonDblClick) - { - QMouseEvent *mouseEvent = static_cast(event); - const QPoint pos = mouseEvent->pos(); - const QModelIndex index = view->indexAt(pos); - - // Press feedback is instant; only the release animates. - if (type == QEvent::MouseButtonPress && mouseEvent->button() == Qt::LeftButton) - { - setPressedIndex(QPersistentModelIndex(index)); - } - else if (type == QEvent::MouseButtonRelease) - { - setPressedIndex(QPersistentModelIndex()); - } - - if (index.isValid()) - { - const MediaItem item = index.data(MediaListModel::ItemDataRole).value(); - const HoverControl button = hoverButtonAt(view->visualRect(index), item, pos); - if (button != HoverControlNone) - { - if (type == QEvent::MouseButtonRelease) - { - if (button == HoverControlPlay) - { - emit playRequested(item); - } - else if (button == HoverControlFavorite) - { - playFavoritePop(QPersistentModelIndex(index)); - emit favoriteRequested(item); - } - else if (button == HoverControlMore) - { - emit moreMenuRequested(item, mouseEvent->globalPosition().toPoint()); - } - } - return true; - } - } - } - - return QStyledItemDelegate::eventFilter(object, event); -} - -void MediaCardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const -{ - - if (option.widget) - { - - QAbstractItemView *view = qobject_cast(const_cast(option.widget)); - if (view) - { - QWidget *viewport = view->viewport(); - if (!m_installedViewports.contains(viewport)) - { - viewport->installEventFilter(const_cast(this)); - m_installedViewports.insert(viewport); - - connect(viewport, &QObject::destroyed, const_cast(this), - [this, viewport]() { m_installedViewports.remove(viewport); }); - } - } - } - - if (!m_themeHelper && option.widget) - { - m_themeHelper = new MediaCardThemeHelper(const_cast(option.widget)); - m_themeHelper->setObjectName("media-card-theme"); - option.widget->style()->polish(m_themeHelper); - } - - - QColor selectedBg = m_themeHelper ? m_themeHelper->selectedBg() : QColor(219, 234, 254); - QColor titleColor = m_themeHelper ? m_themeHelper->titleColor() : QColor(31, 41, 55); - QColor subTitleColor = m_themeHelper ? m_themeHelper->subTitleColor() : QColor(107, 114, 128); - QColor placeholderBg = m_themeHelper ? m_themeHelper->placeholderBg() : QColor(229, 231, 235); - QColor placeholderTextColor = m_themeHelper ? m_themeHelper->placeholderTextColor() : QColor(156, 163, 175); - - - bool isDarkTheme = ThemeManager::instance()->isDarkMode(); - - - const qreal fontScale = ThemeManager::fontScaleFactor(); - - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - painter->setRenderHint(QPainter::SmoothPixmapTransform, true); - - QRect rect = option.rect; - MediaItem item = index.data(MediaListModel::ItemDataRole).value(); - QPixmap poster = index.data(MediaListModel::PosterPixmapRole).value(); - const qreal hp = hoverProgressFor(option, index); - const bool isPressed = m_pressedIndex.isValid() && m_pressedIndex == index; - const qreal popScale = m_popScale.value(QPersistentModelIndex(index), 1.0); - const HoverOverlayMode overlayMode = hoverOverlayModeForItem(item, m_style, m_showMoreButtonForNonPlayableTiles); - const bool showPlayButton = shouldShowPlayButton(overlayMode, m_hoverControls); - const bool showFavoriteButton = shouldShowFavoriteButton(overlayMode, m_hoverControls); - const bool showMoreButton = shouldShowMoreButton(overlayMode, m_hoverControls); - const bool hasHoverButtons = showPlayButton || showFavoriteButton || showMoreButton; - - - - - if (m_style == EpisodeList) - { - if (hp > 0.01) - { - QColor rowBg = isDarkTheme ? QColor(255, 255, 255, 20) : QColor(0, 0, 0, 15); - rowBg.setAlpha(qRound(rowBg.alpha() * hp)); - painter->setPen(Qt::NoPen); - painter->setBrush(rowBg); - painter->drawRoundedRect(rect.adjusted(5, 5, -5, -5), 8, 8); - } - if (isPressed) - { - painter->setPen(Qt::NoPen); - painter->setBrush(isDarkTheme ? QColor(255, 255, 255, 14) : QColor(0, 0, 0, 20)); - painter->drawRoundedRect(rect.adjusted(5, 5, -5, -5), 8, 8); - } - - const QRect baseImgRect = episodeImageRect(rect); - - int imgRadius = 8; - painter->setPen(Qt::NoPen); - painter->setBrush(placeholderBg); - painter->drawRoundedRect(baseImgRect, imgRadius, imgRadius); - - if (!poster.isNull()) - { - QPainterPath path; - path.addRoundedRect(baseImgRect, imgRadius, imgRadius); - painter->setClipPath(path); - QPixmap scaled = scaledCardPixmap(item, poster, baseImgRect.size(), m_style); - int px = baseImgRect.x() + (baseImgRect.width() - scaled.width()) / 2; - int py = baseImgRect.y() + (baseImgRect.height() - scaled.height()) / 2; - painter->drawPixmap(px, py, scaled); - painter->setClipping(false); - } - else - { - painter->setPen(placeholderTextColor); - painter->drawText(baseImgRect, Qt::AlignCenter, tr("No Image")); - } - - if (hp > 0.01 && showPlayButton) - { - painter->setOpacity(hp); - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0, 0, 0, 70)); - painter->drawRoundedRect(baseImgRect, imgRadius, imgRadius); - - const QRect playRectCenter = centerPlayButtonRect(baseImgRect); - const bool playHovered = m_hoverButtonIndex == index && m_hoveredButton == HoverControlPlay; - painter->setBrush(QColor(0, 0, 0, playHovered ? 200 : 160)); - painter->drawEllipse(playRectCenter); - if (playHovered) - { - painter->setPen(QPen(QColor(255, 255, 255, 90), 1)); - painter->setBrush(Qt::NoBrush); - painter->drawEllipse(playRectCenter); - painter->setPen(Qt::NoPen); - } - QIcon playIcon(":/svg/player/play.svg"); - if (!playIcon.isNull()) - { - playIcon.paint(painter, playRectCenter.adjusted(12, 12, -12, -12), Qt::AlignCenter); - } - else - { - QPainterPath playPath; - playPath.moveTo(playRectCenter.center().x() - 4, playRectCenter.center().y() - 8); - playPath.lineTo(playRectCenter.center().x() + 10, playRectCenter.center().y()); - playPath.lineTo(playRectCenter.center().x() - 4, playRectCenter.center().y() + 8); - playPath.closeSubpath(); - painter->setBrush(Qt::white); - painter->drawPath(playPath); - } - painter->setOpacity(1.0); - } - - - int textX = baseImgRect.right() + 20; - int favBtnSize = 24; - int rightMargin = 20; - int btnAreaWidth = favBtnSize + rightMargin + 10; - int textWidth = rect.right() - textX - btnAreaWidth - 10; - - QRect titleRect(textX, baseImgRect.top() + 5, textWidth, 24); - QRect metaRect(textX, titleRect.bottom() + 5, textWidth, 18); - QRect descRect(textX, metaRect.bottom() + 10, textWidth, baseImgRect.bottom() - metaRect.bottom() - 10); - - QFont titleFont = option.font; - titleFont.setPixelSize(qMax(8, qRound(16 * fontScale))); - titleFont.setBold(true); - painter->setFont(titleFont); - painter->setPen(titleColor); - QString epTitle = QString("%1. %2").arg(item.indexNumber).arg(item.name); - QFontMetrics fmTitle(titleFont); - painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignVCenter, - fmTitle.elidedText(epTitle, Qt::ElideRight, titleRect.width())); - - QFont metaFont = option.font; - metaFont.setPixelSize(qMax(8, qRound(13 * fontScale))); - painter->setFont(metaFont); - painter->setPen(subTitleColor); - QStringList metas; - if (!item.premiereDate.isEmpty()) - { - const int len = item.premiereDate.length(); - if (len > 0) - metas << item.premiereDate.left(qMin(len, 10)); - } - if (item.runTimeTicks > 0) - { - long long mins = item.runTimeTicks / 10000000 / 60; - metas << QString(tr("%1 min")).arg(mins); - } - painter->drawText(metaRect, Qt::AlignLeft | Qt::AlignVCenter, metas.join(" • ")); - - QFont descFont = option.font; - descFont.setPixelSize(qMax(8, qRound(13 * fontScale))); - painter->setFont(descFont); - painter->setPen(subTitleColor); - - QString desc = item.overview; - desc.replace('\n', ' '); - QFontMetrics fmDesc(descFont); - int availableHeight = descRect.height(); - int lineSpacing = fmDesc.lineSpacing(); - int maxLines = availableHeight / lineSpacing; - if (maxLines < 1) - maxLines = 1; - - QString elidedDesc = fmDesc.elidedText(desc, Qt::ElideRight, descRect.width() * maxLines); - painter->drawText(descRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, elidedDesc); - - - if (showFavoriteButton) - { - QRectF favRect(episodeFavoriteRect(rect)); - const bool favHovered = m_hoverButtonIndex == index && m_hoveredButton == HoverControlFavorite; - const qreal favScale = popScale * (favHovered ? 1.1 : 1.0); - if (!qFuzzyCompare(favScale, 1.0)) - { - const QPointF center = favRect.center(); - favRect.setSize(favRect.size() * favScale); - favRect.moveCenter(center); - } - QString themeFolder = isDarkTheme ? "dark" : "light"; - QString favIconPath = item.isFavorite() ? QString(":/svg/%1/heart-fill.svg").arg(themeFolder) - : QString(":/svg/%1/heart-outline.svg").arg(themeFolder); - QIcon favIcon(favIconPath); - if (!favIcon.isNull()) - { - favIcon.paint(painter, favRect.toAlignedRect().adjusted(2, 2, -2, -2)); - } - } - - painter->restore(); - return; - } - - - - - - - if (option.state & QStyle::State_Selected) - { - painter->setPen(Qt::NoPen); - painter->setBrush(selectedBg); - painter->drawRoundedRect(rect, 8, 8); - } - - const int padding = m_contentPadding; - QRect baseImgRect, titleRect, yearRect; - - - if (m_style == Poster) - { - baseImgRect = baseImageRectForCard(m_style, rect, padding); - const int imgWidth = baseImgRect.width(); - const int titleHeight = qMax(16, m_titleFontPixelSize + 6); - const int subTitleHeight = qMax(14, m_subTitleFontPixelSize + 4); - titleRect = QRect(rect.x() + padding, baseImgRect.bottom() + 4, imgWidth, titleHeight); - yearRect = QRect(rect.x() + padding, titleRect.bottom() + 1, imgWidth, subTitleHeight); - } - else if (m_style == LibraryTile) - { - baseImgRect = baseImageRectForCard(m_style, rect, padding); - const int imgWidth = baseImgRect.width(); - const int titleHeight = qMax(16, m_titleFontPixelSize + 6); - const int subTitleHeight = qMax(14, m_subTitleFontPixelSize + 4); - titleRect = QRect(rect.x() + padding, baseImgRect.bottom() + 4, imgWidth, titleHeight); - yearRect = QRect(rect.x() + padding, titleRect.bottom() + 1, imgWidth, subTitleHeight); - } - else if (m_style == Cast) - { - baseImgRect = baseImageRectForCard(m_style, rect, padding); - const int imgWidth = baseImgRect.width(); - const int titleHeight = qMax(16, m_titleFontPixelSize + 6); - const int subTitleHeight = qMax(14, m_subTitleFontPixelSize + 4); - titleRect = QRect(rect.x() + padding, baseImgRect.bottom() + 5, imgWidth, titleHeight); - yearRect = QRect(rect.x() + padding, titleRect.bottom() + 1, imgWidth, subTitleHeight); - } - - // Ring + lift + fade hover: the image block rises slightly and gains a - // pre-baked soft shadow; paint and hit-testing share baseImgRect. - const int imgRadius = 8; - const int lift = qRound(3.0 * hp); - const QRect targetImgRect = baseImgRect.translated(0, -lift); - - if (hp > 0.01) - { - const qreal dpr = option.widget ? option.widget->devicePixelRatioF() : 1.0; - const NinePatchShadow &shadow = liftShadow(dpr, imgRadius); - painter->setOpacity(hp); - ShadowPixmap::drawNinePatch( - painter, - targetImgRect.adjusted(-shadow.border, -shadow.border, shadow.border, shadow.border).translated(0, 5), - shadow); - painter->setOpacity(1.0); - } - - painter->setPen(Qt::NoPen); - painter->setBrush(placeholderBg); - painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius); - - - if (!poster.isNull()) - { - QPainterPath path; - path.addRoundedRect(targetImgRect, imgRadius, imgRadius); - painter->setClipPath(path); - - - QPixmap scaled = scaledCardPixmap(item, poster, targetImgRect.size(), m_style); - int px = targetImgRect.x() + (targetImgRect.width() - scaled.width()) / 2; - int py = targetImgRect.y() + (targetImgRect.height() - scaled.height()) / 2; - - painter->drawPixmap(px, py, scaled); - painter->setClipping(false); - } - else - { - painter->setPen(placeholderTextColor); - painter->drawText(targetImgRect, Qt::AlignCenter, tr("No Image")); - } - - - - - if ((item.type == "Series" || item.type == "Season") && item.recursiveItemCount > 0) - { - QFont badgeFont = option.font; - badgeFont.setPixelSize(qMax(8, qRound(11 * fontScale))); - badgeFont.setBold(true); - - - QString countStr = item.recursiveItemCount > 999 ? "999+" : QString::number(item.recursiveItemCount); - - - QFontMetrics fm(badgeFont); - int textWidth = fm.horizontalAdvance(countStr); - - - int badgeHeight = 22; - int badgeWidth = qMax(badgeHeight, textWidth + 10); - - - int badgeMargin = 4; - QRect badgeRect(targetImgRect.right() - badgeWidth - badgeMargin, targetImgRect.top() + badgeMargin, badgeWidth, - badgeHeight); - - - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0, 0, 0, 160)); - - painter->drawRoundedRect(badgeRect, badgeHeight / 2, badgeHeight / 2); - - - painter->setFont(badgeFont); - painter->setPen(Qt::white); - painter->drawText(badgeRect, Qt::AlignCenter, countStr); - } - - - - - if ((item.type == "Episode" || item.type == "Season") && item.userData.played) - { - int checkSize = 16; - int checkMargin = 4; - int checkX = item.type == "Season" - ? targetImgRect.left() + checkMargin - : targetImgRect.right() - checkSize - checkMargin; - QRect checkRect(checkX, targetImgRect.top() + checkMargin, checkSize, - checkSize); - - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - - - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(16, 185, 129, 140)); - painter->drawRoundedRect(checkRect, checkSize / 2, checkSize / 2); - - - QIcon checkIcon(":/svg/dark/check.svg"); - if (!checkIcon.isNull()) { - checkIcon.paint(painter, checkRect.adjusted(3, 3, -3, -3), - Qt::AlignCenter); - } - - painter->restore(); - } - - - - - if (hp > 0.01 && hasHoverButtons) - { - painter->setOpacity(hp); - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0, 0, 0, 70)); - painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius); - - const HoverButtonLayout buttonLayout = - buildHoverButtonLayout(targetImgRect, showPlayButton, showFavoriteButton, showMoreButton); - const bool buttonStateHere = m_hoverButtonIndex == index; - const QPen buttonHoverPen(QColor(255, 255, 255, 90), 1); - - if (showPlayButton) - { - const bool playHovered = buttonStateHere && m_hoveredButton == HoverControlPlay; - painter->setBrush(QColor(0, 0, 0, playHovered ? 200 : 160)); - painter->drawEllipse(buttonLayout.playRect); - if (playHovered) - { - painter->setPen(buttonHoverPen); - painter->setBrush(Qt::NoBrush); - painter->drawEllipse(buttonLayout.playRect); - painter->setPen(Qt::NoPen); - } - - QIcon playIcon(":/svg/player/play.svg"); - if (!playIcon.isNull()) - { - playIcon.paint(painter, buttonLayout.playRect.adjusted(12, 12, -12, -12), Qt::AlignCenter); - } - else - { - QPainterPath playPath; - playPath.moveTo(buttonLayout.playRect.center().x() - 4, buttonLayout.playRect.center().y() - 8); - playPath.lineTo(buttonLayout.playRect.center().x() + 10, buttonLayout.playRect.center().y()); - playPath.lineTo(buttonLayout.playRect.center().x() - 4, buttonLayout.playRect.center().y() + 8); - playPath.closeSubpath(); - painter->setBrush(Qt::white); - painter->drawPath(playPath); - } - } - - if (showMoreButton) - { - const bool moreHovered = buttonStateHere && m_hoveredButton == HoverControlMore; - painter->setBrush(QColor(0, 0, 0, moreHovered ? 200 : 160)); - painter->drawRoundedRect(buttonLayout.moreRect, 6, 6); - if (moreHovered) - { - painter->setPen(buttonHoverPen); - painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(buttonLayout.moreRect, 6, 6); - painter->setPen(Qt::NoPen); - } - - QIcon moreIcon(":/svg/dark/more-line.svg"); - if (!moreIcon.isNull()) - { - moreIcon.paint(painter, buttonLayout.moreRect.adjusted(4, 4, -4, -4), Qt::AlignCenter); - } - else - { - painter->setBrush(Qt::white); - int cx = buttonLayout.moreRect.center().x(); - int cy = buttonLayout.moreRect.center().y(); - painter->drawEllipse(cx - 2, cy - 8, 4, 4); - painter->drawEllipse(cx - 2, cy - 2, 4, 4); - painter->drawEllipse(cx - 2, cy + 4, 4, 4); - } - } - - if (showFavoriteButton) - { - const bool favHovered = buttonStateHere && m_hoveredButton == HoverControlFavorite; - QRectF favRect(buttonLayout.favoriteRect); - if (!qFuzzyCompare(popScale, 1.0)) - { - const QPointF center = favRect.center(); - favRect.setSize(favRect.size() * popScale); - favRect.moveCenter(center); - } - painter->setBrush(QColor(0, 0, 0, favHovered ? 200 : 160)); - painter->drawRoundedRect(favRect, 6, 6); - if (favHovered) - { - painter->setPen(buttonHoverPen); - painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(favRect, 6, 6); - painter->setPen(Qt::NoPen); - } - - QString favIconPath = item.isFavorite() ? ":/svg/dark/heart-fill.svg" : ":/svg/dark/heart-outline.svg"; - QIcon favIcon(favIconPath); - if (!favIcon.isNull()) - { - favIcon.paint(painter, favRect.toAlignedRect().adjusted(5, 5, -5, -5), Qt::AlignCenter); - } - else - { - painter->setBrush(Qt::white); - painter->drawEllipse(favRect.center(), 4, 4); - } - } - - painter->setOpacity(1.0); - } - - // Press feedback: instant dim, no animation. - if (isPressed) - { - painter->setPen(Qt::NoPen); - painter->setBrush(QColor(0, 0, 0, 45)); - painter->drawRoundedRect(targetImgRect, imgRadius, imgRadius); - } - - const bool isHighlighted = !m_highlightedItemId.isEmpty() && item.id == m_highlightedItemId; - if (hp > 0.01 && !isHighlighted) - { - // Hover ring; yields to the highlight ring below. - QColor ringColor = isDarkTheme ? QColor(96, 165, 250) : QColor(59, 130, 246); - ringColor.setAlpha(qRound(200 * hp)); - painter->setPen(QPen(ringColor, 1.5)); - painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(QRectF(targetImgRect).adjusted(-1.5, -1.5, 1.5, 1.5), 9.5, 9.5); - painter->setPen(Qt::NoPen); - } - - if (isHighlighted) - { - QColor borderColor = isDarkTheme ? QColor(96, 165, 250, 220) - : QColor(59, 130, 246, 220); - QPen highlightPen(borderColor, 2.0); - painter->setPen(highlightPen); - painter->setBrush(Qt::NoBrush); - painter->drawRoundedRect(targetImgRect.adjusted(-2, -2, 2, 2), 10, 10); - } - - - QFont titleFont = option.font; - titleFont.setPixelSize(qMax(8, qRound(m_titleFontPixelSize * fontScale))); - titleFont.setBold(true); - painter->setFont(titleFont); - painter->setPen(titleColor); - - QFontMetrics fm(titleFont); - - QString displayTitle = item.name; - if (item.type == "Episode" && item.parentIndexNumber >= 0 && item.indexNumber >= 0) - { - displayTitle = QString("S%1E%2 %3") - .arg(item.parentIndexNumber, 2, 10, QChar('0')) - .arg(item.indexNumber, 2, 10, QChar('0')) - .arg(item.name); - } - QString elidedTitle = fm.elidedText(displayTitle, Qt::ElideRight, titleRect.width()); - Qt::Alignment titleAlign = - (m_style == Poster) ? (Qt::AlignHCenter | Qt::AlignVCenter) : (Qt::AlignHCenter | Qt::AlignVCenter); - painter->drawText(titleRect, titleAlign, elidedTitle); - - - - const bool canDrawYear = item.productionYear > 0 && yearRect.isValid() && yearRect.bottom() <= rect.bottom(); - if ((m_style == Poster || m_style == LibraryTile) && canDrawYear) - { - QFont subFont = option.font; - subFont.setPixelSize(qMax(8, qRound(m_subTitleFontPixelSize * fontScale))); - painter->setFont(subFont); - painter->setPen(subTitleColor); - painter->drawText(yearRect, Qt::AlignHCenter | Qt::AlignVCenter, QString::number(item.productionYear)); - } - else if (m_style == Cast) - { - QFont subFont = option.font; - subFont.setPixelSize(qMax(8, qRound(m_subTitleFontPixelSize * fontScale))); - painter->setFont(subFont); - painter->setPen(subTitleColor); - QFontMetrics fmSub(subFont); - QString elidedRole = fmSub.elidedText(item.overview, Qt::ElideRight, yearRect.width()); - painter->drawText(yearRect, Qt::AlignHCenter | Qt::AlignVCenter, elidedRole); - } - - painter->restore(); -} diff --git a/src/qEmbyApp/views/media/mediacarddelegate.h b/src/qEmbyApp/views/media/mediacarddelegate.h deleted file mode 100644 index 762ed94..0000000 --- a/src/qEmbyApp/views/media/mediacarddelegate.h +++ /dev/null @@ -1,146 +0,0 @@ -#ifndef MEDIACARDDELEGATE_H -#define MEDIACARDDELEGATE_H - -#include -#include -#include -#include -#include -#include -#include - -#include "../../utils/shadowpixmap.h" - -struct MediaItem; -class IndexHoverAnimator; -class QAbstractItemView; - - -class MediaCardThemeHelper : public QWidget { - Q_OBJECT - Q_PROPERTY(QColor hoverBg READ hoverBg WRITE setHoverBg) - Q_PROPERTY(QColor selectedBg READ selectedBg WRITE setSelectedBg) - Q_PROPERTY(QColor titleColor READ titleColor WRITE setTitleColor) - Q_PROPERTY(QColor subTitleColor READ subTitleColor WRITE setSubTitleColor) - Q_PROPERTY(QColor placeholderBg READ placeholderBg WRITE setPlaceholderBg) - Q_PROPERTY(QColor placeholderTextColor READ placeholderTextColor WRITE setPlaceholderTextColor) - -public: - explicit MediaCardThemeHelper(QWidget *parent = nullptr); - - QColor hoverBg() const { return m_hoverBg; } - void setHoverBg(const QColor &c) { m_hoverBg = c; } - - QColor selectedBg() const { return m_selectedBg; } - void setSelectedBg(const QColor &c) { m_selectedBg = c; } - - QColor titleColor() const { return m_titleColor; } - void setTitleColor(const QColor &c) { m_titleColor = c; } - - QColor subTitleColor() const { return m_subTitleColor; } - void setSubTitleColor(const QColor &c) { m_subTitleColor = c; } - - QColor placeholderBg() const { return m_placeholderBg; } - void setPlaceholderBg(const QColor &c) { m_placeholderBg = c; } - - QColor placeholderTextColor() const { return m_placeholderTextColor; } - void setPlaceholderTextColor(const QColor &c) { m_placeholderTextColor = c; } - -private: - QColor m_hoverBg; - QColor m_selectedBg; - QColor m_titleColor; - QColor m_subTitleColor; - QColor m_placeholderBg; - QColor m_placeholderTextColor; -}; - -class MediaCardDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - enum CardStyle { - Poster, - LibraryTile, - Cast, - EpisodeList - }; - - enum HoverControl { - HoverControlNone = 0x0, - HoverControlPlay = 0x1, - HoverControlFavorite = 0x2, - HoverControlMore = 0x4, - HoverControlAll = - HoverControlPlay | HoverControlFavorite | HoverControlMore - }; - Q_DECLARE_FLAGS(HoverControls, HoverControl) - - explicit MediaCardDelegate(CardStyle style = Poster, 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; - - void setTileSize(const QSize &size) { m_tileSize = size; } - void setStyle(CardStyle style) { m_style = style; } - void setHighlightedItemId(const QString &id) { m_highlightedItemId = id; } - void setTextPixelSizes(int titlePx, int subTitlePx) - { - m_titleFontPixelSize = titlePx; - m_subTitleFontPixelSize = subTitlePx; - } - void setContentPadding(int padding) - { - m_contentPadding = padding < 0 ? 0 : padding; - } - int contentPadding() const { return m_contentPadding; } - void setShowMoreButtonForNonPlayableTiles(bool enabled) - { - m_showMoreButtonForNonPlayableTiles = enabled; - } - void setHoverControls(HoverControls controls) - { - m_hoverControls = controls; - } - HoverControls hoverControls() const { return m_hoverControls; } - -signals: - - void playRequested(const MediaItem &item); - void moreMenuRequested(const MediaItem &item, const QPoint &globalPos); - void favoriteRequested(const MediaItem &item); - -protected: - - bool eventFilter(QObject *object, QEvent *event) override; - -private: - // Hover button hit-testing shared by paint highlights and mouse handling. - HoverControl hoverButtonAt(const QRect &itemRect, const MediaItem &item, const QPoint &pos) const; - void setHoveredButton(const QPersistentModelIndex &index, HoverControl button) const; - void setPressedIndex(const QPersistentModelIndex &index) const; - void repaintIndex(const QModelIndex &index) const; - void playFavoritePop(const QPersistentModelIndex &index); - qreal hoverProgressFor(const QStyleOptionViewItem &option, const QModelIndex &index) const; - const NinePatchShadow &liftShadow(qreal dpr, int cornerRadius) const; - - CardStyle m_style; - QSize m_tileSize; - QString m_highlightedItemId; - mutable MediaCardThemeHelper *m_themeHelper; - mutable QSet m_installedViewports; - IndexHoverAnimator *m_hover = nullptr; - mutable QPersistentModelIndex m_pressedIndex; - mutable QPersistentModelIndex m_hoverButtonIndex; - mutable HoverControl m_hoveredButton = HoverControlNone; - QHash m_popScale; - mutable NinePatchShadow m_liftShadow; - bool m_showMoreButtonForNonPlayableTiles = false; - HoverControls m_hoverControls = HoverControlAll; - int m_titleFontPixelSize = 13; - int m_subTitleFontPixelSize = 12; - int m_contentPadding = 8; -}; - -Q_DECLARE_OPERATORS_FOR_FLAGS(MediaCardDelegate::HoverControls) - -#endif diff --git a/src/qEmbyApp/views/media/mediaitem.h b/src/qEmbyApp/views/media/mediaitem.h deleted file mode 100644 index 2a9f42b..0000000 --- a/src/qEmbyApp/views/media/mediaitem.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef MEDIAITEM_H -#define MEDIAITEM_H - -#include -#include -#include - -struct MediaItem { - QString id; - QString name; - QString type; - QString collectionType; - QString primaryImageTag; - int productionYear = 0; - - - static MediaItem fromJson(const QJsonObject& obj) { - MediaItem item; - item.id = obj["Id"].toString(); - item.name = obj["Name"].toString(); - item.type = obj["Type"].toString(); - item.collectionType = obj["CollectionType"].toString(); - - if (obj.contains("ProductionYear")) { - item.productionYear = obj["ProductionYear"].toInt(); - } - - if (obj.contains("ImageTags")) { - QJsonObject tags = obj["ImageTags"].toObject(); - if (tags.contains("Primary")) { - item.primaryImageTag = tags["Primary"].toString(); - } - } - return item; - } -}; - - -Q_DECLARE_METATYPE(MediaItem) - -#endif diff --git a/src/qEmbyApp/views/media/medialistmodel.cpp b/src/qEmbyApp/views/media/medialistmodel.cpp deleted file mode 100644 index 183a9dc..0000000 --- a/src/qEmbyApp/views/media/medialistmodel.cpp +++ /dev/null @@ -1,585 +0,0 @@ -#include "medialistmodel.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -constexpr int kMaxConcurrentImageFetches = 6; - -QString buildImageIdentity(const MediaItem& item) -{ - return QStringLiteral("%1|%2|%3|%4|%5|%6|%7|%8|%9") - .arg(item.getPrimaryImageId()) - .arg(item.images.primaryTag) - .arg(item.images.thumbTag) - .arg(item.images.backdropTag) - .arg(item.images.logoTag) - .arg(item.images.parentPrimaryTag) - .arg(item.images.parentThumbTag) - .arg(item.images.parentBackdropTag) - .arg(item.images.parentImageItemId); -} - -bool isResumeSourceForItem(const MediaItem& item, const QString& sourceItemId) -{ - const QString trimmedSourceItemId = sourceItemId.trimmed(); - if (trimmedSourceItemId.isEmpty()) { - return false; - } - - const QString trimmedResumeItemId = item.resumeItemId.trimmed(); - return item.hasResumeContext && !trimmedResumeItemId.isEmpty() && - trimmedResumeItemId == trimmedSourceItemId; -} - -bool matchesItemOrResumeSource(const MediaItem& item, const QString& itemId) -{ - const QString trimmedItemId = itemId.trimmed(); - if (trimmedItemId.isEmpty()) { - return false; - } - - return item.id.trimmed() == trimmedItemId || - isResumeSourceForItem(item, trimmedItemId); -} - -void preserveResumeContext(MediaItem& target, const MediaItem& source) -{ - if (target.hasResumeContext || !source.hasResumeContext) { - return; - } - - target.hasResumeContext = true; - target.resumeItemId = source.resumeItemId; - target.resumeUserData = source.resumeUserData; -} - -MediaItem mergeItemUpdate(const MediaItem& currentItem, - const MediaItem& updatedItem) -{ - if (currentItem.id == updatedItem.id) { - MediaItem mergedItem = updatedItem; - preserveResumeContext(mergedItem, currentItem); - return mergedItem; - } - - if (isResumeSourceForItem(currentItem, updatedItem.id)) { - MediaItem mergedItem = currentItem; - mergedItem.resumeUserData = updatedItem.userData; - return mergedItem; - } - - return updatedItem; -} - -} - -MediaListModel::MediaListModel(int imageMaxWidth, QEmbyCore* core, QObject *parent) - : QAbstractListModel(parent), m_imageMaxWidth(imageMaxWidth), m_core(core) -{ - m_imageNotifyTimer = new QTimer(this); - m_imageNotifyTimer->setSingleShot(true); - m_imageNotifyTimer->setTimerType(Qt::PreciseTimer); - connect(m_imageNotifyTimer, &QTimer::timeout, this, - &MediaListModel::flushPendingImageDataChanges); -} - -int MediaListModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid()) { - return 0; - } - return m_items.size(); -} - -QVariant MediaListModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= m_items.size()) { - return QVariant(); - } - - const MediaItem& item = m_items.at(index.row()); - - if (role == ItemDataRole) { - return QVariant::fromValue(item); - } - else if (role == Qt::ToolTipRole) { - if (!ConfigStore::instance()->get(ConfigKeys::ShowMediaTooltips, - true)) { - return QVariant(); - } - return buildTooltipText(item); - } - else if (role == PosterPixmapRole) { - if (m_imageCache.contains(item.id)) { - return m_imageCache.value(item.id); - } - - const_cast(this)->ensureImageRequested(item); - return QVariant(); - } - return QVariant(); -} - -QString MediaListModel::buildTooltipText(const MediaItem &item) const { - QStringList lines; - - if ((item.type == QStringLiteral("Episode") || - item.type == QStringLiteral("Season")) && - !item.seriesName.isEmpty()) { - lines << item.seriesName; - } - - QString title = item.name.trimmed(); - if (item.type == QStringLiteral("Episode") && item.parentIndexNumber >= 0 && - item.indexNumber >= 0) { - title = QStringLiteral("S%1E%2 %3") - .arg(item.parentIndexNumber, 2, 10, QChar('0')) - .arg(item.indexNumber, 2, 10, QChar('0')) - .arg(item.name); - } - lines << title; - - QStringList metaParts; - if (item.type == QStringLiteral("Season") && item.parentIndexNumber >= 0) { - metaParts << tr("Season %1").arg(item.parentIndexNumber); - } - if (item.productionYear > 0) { - metaParts << QString::number(item.productionYear); - } - if (!item.premiereDate.trimmed().isEmpty()) { - const QString trimmedDate = item.premiereDate.trimmed(); - const int len = trimmedDate.length(); - if (len > 0) metaParts << trimmedDate.left(qMin(len, 10)); - } - if (item.runTimeTicks > 0) { - const long long minutes = item.runTimeTicks / 10000000 / 60; - if (minutes > 0) { - metaParts << tr("%1 min").arg(minutes); - } - } - if (item.type == QStringLiteral("Season") && item.recursiveItemCount > 0) { - metaParts << tr("%1 Episodes").arg(item.recursiveItemCount); - } - if (!metaParts.isEmpty()) { - lines << metaParts.join(QStringLiteral(" • ")); - } - - const QString overview = item.overview.simplified(); - if (!overview.isEmpty()) { - lines << overview; - } - - return lines.join(QLatin1Char('\n')); -} - - -void MediaListModel::setItems(const QList& newItems) { - if (m_items.isEmpty() || newItems.isEmpty()) { - beginResetModel(); - m_items = newItems; - if (newItems.isEmpty()) { - clearImageCache(); - } - endResetModel(); - return; - } - - - QSet newIds; - for (const auto& item : newItems) { - newIds.insert(item.id); - } - - - - for (int i = m_items.size() - 1; i >= 0; --i) { - if (!newIds.contains(m_items[i].id)) { - beginRemoveRows(QModelIndex(), i, i); - m_items.removeAt(i); - endRemoveRows(); - } - } - - - for (int newIdx = 0; newIdx < newItems.size(); ++newIdx) { - const MediaItem& newItem = newItems[newIdx]; - - - - - int oldIdx = -1; - for (int j = newIdx; j < m_items.size(); ++j) { - if (m_items[j].id == newItem.id) { - oldIdx = j; - break; - } - } - - if (oldIdx == -1) { - - beginInsertRows(QModelIndex(), newIdx, newIdx); - m_items.insert(newIdx, newItem); - endInsertRows(); - } - else if (oldIdx == newIdx) { - - m_items[newIdx] = newItem; - Q_EMIT dataChanged(index(newIdx, 0), index(newIdx, 0), {ItemDataRole}); - } - else { - - - beginMoveRows(QModelIndex(), oldIdx, oldIdx, QModelIndex(), newIdx); - m_items.move(oldIdx, newIdx); - endMoveRows(); - - - m_items[newIdx] = newItem; - Q_EMIT dataChanged(index(newIdx, 0), index(newIdx, 0), {ItemDataRole}); - } - } - - - QMutableHashIterator it(m_imageCache); - while (it.hasNext()) { - it.next(); - if (!newIds.contains(it.key())) { - it.remove(); - } - } -} - -MediaItem MediaListModel::getItem(const QModelIndex& index) const { - if (!index.isValid() || index.row() >= m_items.size()) { - return MediaItem(); - } - return m_items.at(index.row()); -} - -void MediaListModel::updateItem(const MediaItem& updatedItem) { - for (int i = 0; i < m_items.size(); ++i) { - if (matchesItemOrResumeSource(m_items[i], updatedItem.id)) { - const QString cachedItemId = m_items[i].id; - const MediaItem mergedItem = mergeItemUpdate(m_items[i], updatedItem); - const bool imageChanged = - buildImageIdentity(m_items[i]) != buildImageIdentity(mergedItem); - - - m_items[i] = mergedItem; - - if (imageChanged) { - m_imageCache.remove(cachedItemId); - m_loadingImages.remove(cachedItemId); - } - - - QModelIndex idx = index(i, 0); - QVector roles = {ItemDataRole}; - if (imageChanged) { - roles.append(PosterPixmapRole); - } - Q_EMIT dataChanged(idx, idx, roles); - break; - } - } -} - -void MediaListModel::prependOrUpdateItem(const MediaItem& item, int maxItems) -{ - const QString itemId = item.id.trimmed(); - if (itemId.isEmpty()) { - return; - } - - for (int i = 0; i < m_items.size(); ++i) { - if (matchesItemOrResumeSource(m_items[i], itemId)) { - updateItem(item); - return; - } - } - - beginInsertRows(QModelIndex(), 0, 0); - m_items.prepend(item); - endInsertRows(); - - if (maxItems > 0) { - while (m_items.size() > maxItems) { - const int removeIndex = m_items.size() - 1; - const QString cachedItemId = m_items.at(removeIndex).id; - beginRemoveRows(QModelIndex(), removeIndex, removeIndex); - m_items.removeAt(removeIndex); - endRemoveRows(); - m_imageCache.remove(cachedItemId); - m_loadingImages.remove(cachedItemId); - } - } -} - - - - -void MediaListModel::removeItem(const QString& itemId) { - for (int i = 0; i < m_items.size(); ++i) { - if (matchesItemOrResumeSource(m_items[i], itemId)) { - const QString cachedItemId = m_items[i].id; - - beginRemoveRows(QModelIndex(), i, i); - - - m_items.removeAt(i); - - - endRemoveRows(); - - - m_imageCache.remove(cachedItemId); - m_loadingImages.remove(cachedItemId); - m_imageCache.remove(itemId); - m_loadingImages.remove(itemId); - - break; - } - } -} - -void MediaListModel::setPriorityRows(const QList& rows) -{ - QStringList priorityIds; - priorityIds.reserve(rows.size()); - - for (const int row : rows) { - if (row < 0 || row >= m_items.size()) { - continue; - } - - const MediaItem& item = m_items.at(row); - if (item.id.isEmpty() || priorityIds.contains(item.id)) { - continue; - } - - priorityIds.append(item.id); - ensureImageRequested(item); - } - - m_priorityImageIds = priorityIds; - scheduleImageFetches(); -} - -void MediaListModel::ensureImageRequested(const MediaItem& item) -{ - if (item.id.isEmpty() || m_imageCache.contains(item.id) || - m_loadingImages.contains(item.id)) { - return; - } - - QString imgType = "Primary"; - QString imgTag = item.images.primaryTag; - - if (m_preferThumb) { - if (ConfigStore::instance()->get(ConfigKeys::AdaptiveImages, true)) { - auto best = item.images.bestThumb(); - imgTag = best.first; - imgType = best.second; - } else { - if (!item.images.thumbTag.isEmpty()) { - imgType = "Thumb"; - imgTag = item.images.thumbTag; - } else if (!item.images.backdropTag.isEmpty()) { - imgType = "Backdrop"; - imgTag = item.images.backdropTag; - } - } - } else if (imgTag.isEmpty() && - ConfigStore::instance()->get(ConfigKeys::AdaptiveImages, - true)) { - auto best = item.images.bestPoster(); - imgTag = best.first; - imgType = best.second; - } - - QString targetImageId = item.getPrimaryImageId(); - bool usingParentImage = false; - if (!imgTag.isEmpty() && item.images.isParentTag(imgTag) && - !item.images.parentImageItemId.isEmpty()) { - targetImageId = item.images.parentImageItemId; - usingParentImage = true; - } - - if (imgTag.isEmpty()) { - return; - } - - PendingImageRequest request; - request.targetImageId = targetImageId; - request.imageType = imgType; - request.imageTag = imgTag; - request.maxWidth = usingParentImage ? m_imageMaxWidth * 2 : m_imageMaxWidth; - enqueueImageFetch(item.id, request); -} - -void MediaListModel::enqueueImageFetch(const QString& itemId, - const PendingImageRequest& request) -{ - if (itemId.isEmpty() || m_imageCache.contains(itemId) || - m_loadingImages.contains(itemId)) { - return; - } - - m_loadingImages.insert(itemId); - m_pendingImageRequests.insert(itemId, request); - m_pendingImageOrder.append(itemId); - scheduleImageFetches(); -} - -void MediaListModel::scheduleImageFetches() -{ - while (m_activeImageFetches < kMaxConcurrentImageFetches && - !m_pendingImageRequests.isEmpty()) { - const QString itemId = takeNextPendingImageId(); - if (itemId.isEmpty()) { - return; - } - - const PendingImageRequest request = m_pendingImageRequests.take(itemId); - m_pendingImageOrder.removeAll(itemId); - ++m_activeImageFetches; - - QPointer safeThis(this); - executeImageFetch(safeThis, itemId, request.targetImageId, - request.imageType, request.imageTag, - request.maxWidth, m_imageRequestGeneration, m_core); - } -} - -QString MediaListModel::takeNextPendingImageId() -{ - for (const QString& itemId : std::as_const(m_priorityImageIds)) { - if (m_pendingImageRequests.contains(itemId)) { - return itemId; - } - } - - while (!m_pendingImageOrder.isEmpty()) { - const QString itemId = m_pendingImageOrder.takeFirst(); - if (m_pendingImageRequests.contains(itemId)) { - return itemId; - } - } - - if (!m_pendingImageRequests.isEmpty()) { - return m_pendingImageRequests.constBegin().key(); - } - - return {}; -} - - -QCoro::Task MediaListModel::executeImageFetch( - QPointer safeThis, QString itemId, QString targetImageId, - QString imgType, QString imgTag, int maxWidth, int generation, - QEmbyCore* core) { - try { - - QPixmap pix = co_await core->mediaService()->fetchImage(targetImageId, imgType, imgTag, maxWidth); - - - if (!safeThis) { - co_return; - } - - auto finishFetch = [safeThis, generation]() { - if (!safeThis) { - return; - } - if (generation == safeThis->m_imageRequestGeneration) { - safeThis->m_activeImageFetches = - qMax(0, safeThis->m_activeImageFetches - 1); - } - safeThis->scheduleImageFetches(); - }; - - if (generation != safeThis->m_imageRequestGeneration) { - finishFetch(); - co_return; - } - - - safeThis->m_imageCache.insert(itemId, pix); - safeThis->m_loadingImages.remove(itemId); - - - int currentRow = -1; - for (int i = 0; i < safeThis->m_items.size(); ++i) { - if (safeThis->m_items.at(i).id == itemId) { - currentRow = i; - break; - } - } - - - - if (currentRow >= 0) { - safeThis->queueImageDataChanged(itemId); - } - finishFetch(); - } catch (...) { - - if (safeThis) { - if (generation == safeThis->m_imageRequestGeneration) { - safeThis->m_loadingImages.remove(itemId); - safeThis->m_activeImageFetches = - qMax(0, safeThis->m_activeImageFetches - 1); - } - safeThis->scheduleImageFetches(); - } - } -} - -void MediaListModel::queueImageDataChanged(const QString& itemId) -{ - if (itemId.isEmpty()) { - return; - } - - m_pendingImageNotifyIds.insert(itemId); - if (m_imageNotifyTimer && !m_imageNotifyTimer->isActive()) { - m_imageNotifyTimer->start(16); - } -} - -void MediaListModel::flushPendingImageDataChanges() -{ - if (m_pendingImageNotifyIds.isEmpty()) { - return; - } - - QStringList itemIds = m_pendingImageNotifyIds.values(); - m_pendingImageNotifyIds.clear(); - - QList rows; - rows.reserve(itemIds.size()); - for (const QString& itemId : std::as_const(itemIds)) { - for (int row = 0; row < m_items.size(); ++row) { - if (m_items.at(row).id == itemId) { - rows.append(row); - break; - } - } - } - - std::sort(rows.begin(), rows.end()); - rows.erase(std::unique(rows.begin(), rows.end()), rows.end()); - - for (const int row : std::as_const(rows)) { - if (row < 0 || row >= m_items.size()) { - continue; - } - Q_EMIT dataChanged(index(row, 0), index(row, 0), {PosterPixmapRole}); - } -} diff --git a/src/qEmbyApp/views/media/medialistmodel.h b/src/qEmbyApp/views/media/medialistmodel.h deleted file mode 100644 index 8c30e0f..0000000 --- a/src/qEmbyApp/views/media/medialistmodel.h +++ /dev/null @@ -1,101 +0,0 @@ -#ifndef MEDIALISTMODEL_H -#define MEDIALISTMODEL_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class QEmbyCore; - -class MediaListModel : public QAbstractListModel { - Q_OBJECT -public: - enum MediaRoles { - ItemDataRole = Qt::UserRole + 1, - PosterPixmapRole - }; - - explicit MediaListModel(int imageMaxWidth, QEmbyCore* core, QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - - void setItems(const QList& items); - MediaItem getItem(const QModelIndex& index) const; - - - void setPreferThumb(bool prefer) { m_preferThumb = prefer; } - - - - void updateItem(const MediaItem& updatedItem); - void prependOrUpdateItem(const MediaItem& item, int maxItems = 0); - - - - - void removeItem(const QString& itemId); - void setPriorityRows(const QList& rows); - - - void clearImageCache() - { - ++m_imageRequestGeneration; - m_imageCache.clear(); - m_loadingImages.clear(); - m_pendingImageRequests.clear(); - m_pendingImageOrder.clear(); - m_priorityImageIds.clear(); - m_pendingImageNotifyIds.clear(); - m_activeImageFetches = 0; - if (m_imageNotifyTimer) { - m_imageNotifyTimer->stop(); - } - } - -private: - struct PendingImageRequest { - QString targetImageId; - QString imageType; - QString imageTag; - int maxWidth = 0; - }; - - QString buildTooltipText(const MediaItem &item) const; - void ensureImageRequested(const MediaItem& item); - void enqueueImageFetch(const QString& itemId, - const PendingImageRequest& request); - void scheduleImageFetches(); - QString takeNextPendingImageId(); - void queueImageDataChanged(const QString& itemId); - void flushPendingImageDataChanges(); - - - static QCoro::Task executeImageFetch( - QPointer safeThis, QString itemId, - QString targetImageId, QString imgType, QString imgTag, int maxWidth, - int generation, QEmbyCore* core); - - bool m_preferThumb = false; - int m_imageMaxWidth; - QEmbyCore* m_core; - QList m_items; - - mutable QHash m_imageCache; - mutable QSet m_loadingImages; - QHash m_pendingImageRequests; - QStringList m_pendingImageOrder; - QStringList m_priorityImageIds; - QSet m_pendingImageNotifyIds; - QTimer* m_imageNotifyTimer = nullptr; - int m_activeImageFetches = 0; - int m_imageRequestGeneration = 0; -}; - -#endif diff --git a/src/qEmbyApp/views/media/overviewdialog.cpp b/src/qEmbyApp/views/media/overviewdialog.cpp deleted file mode 100644 index d845591..0000000 --- a/src/qEmbyApp/views/media/overviewdialog.cpp +++ /dev/null @@ -1,90 +0,0 @@ -#include "overviewdialog.h" -#include -#include -#include -#include -#include -#include -#include - -OverviewDialog::OverviewDialog(QWidget *parent) : ModernDialogBase(parent) { - setTitle(tr("作品简介")); - resize(800, 450); - - auto* mainLayout = new QHBoxLayout(); - mainLayout->setContentsMargins(10, 10, 10, 10); - mainLayout->setSpacing(30); - - - m_posterLabel = new QLabel(this); - m_posterLabel->setObjectName("overview-poster"); - m_posterLabel->setFixedSize(220, 330); - m_posterLabel->setScaledContents(true); - - auto *shadow = new QGraphicsDropShadowEffect(this); - shadow->setBlurRadius(25); - shadow->setColor(QColor(0, 0, 0, 100)); - shadow->setOffset(0, 15); - m_posterLabel->setGraphicsEffect(shadow); - - mainLayout->addWidget(m_posterLabel, 0, Qt::AlignTop); - - - auto* rightLayout = new QVBoxLayout(); - rightLayout->setSpacing(15); - - m_titleLabel = new QLabel(this); - m_titleLabel->setObjectName("overview-title"); - m_titleLabel->setWordWrap(true); - - - auto* scrollArea = new QScrollArea(this); - scrollArea->setObjectName("overview-scroll"); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - auto* scrollContent = new QWidget(scrollArea); - scrollContent->setObjectName("overview-scroll-content"); - auto* scrollLayout = new QVBoxLayout(scrollContent); - scrollLayout->setContentsMargins(0, 0, 15, 0); - - m_overviewLabel = new QLabel(scrollContent); - m_overviewLabel->setObjectName("overview-text"); - m_overviewLabel->setWordWrap(true); - m_overviewLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - m_overviewLabel->setTextFormat(Qt::RichText); - - scrollLayout->addWidget(m_overviewLabel); - scrollLayout->addStretch(); - scrollArea->setWidget(scrollContent); - - rightLayout->addWidget(m_titleLabel); - rightLayout->addWidget(scrollArea, 1); - - mainLayout->addLayout(rightLayout, 1); - contentLayout()->addLayout(mainLayout); -} - -void OverviewDialog::setMediaItem(const MediaItem& item, const QPixmap& posterPix) { - if (!posterPix.isNull()) { - m_posterLabel->setPixmap(posterPix); - } - m_titleLabel->setText(item.name); - - - - - QString html = item.overview; - - - html.replace("\r\n", "\n"); - - html.replace(QRegularExpression("\\n", QRegularExpression::CaseInsensitiveOption), "
"); - - html.replace("\n", "
"); - - - m_overviewLabel->setTextFormat(Qt::RichText); - m_overviewLabel->setText(html); -} diff --git a/src/qEmbyApp/views/media/overviewdialog.h b/src/qEmbyApp/views/media/overviewdialog.h deleted file mode 100644 index 8b73cf4..0000000 --- a/src/qEmbyApp/views/media/overviewdialog.h +++ /dev/null @@ -1,22 +0,0 @@ -#ifndef OVERVIEWDIALOG_H -#define OVERVIEWDIALOG_H - -#include "../../components/moderndialogbase.h" -#include -#include - -class QLabel; - -class OverviewDialog : public ModernDialogBase { - Q_OBJECT -public: - explicit OverviewDialog(QWidget *parent = nullptr); - void setMediaItem(const MediaItem& item, const QPixmap& posterPix); - -private: - QLabel* m_posterLabel; - QLabel* m_titleLabel; - QLabel* m_overviewLabel; -}; - -#endif diff --git a/src/qEmbyApp/views/media/playerview.cpp b/src/qEmbyApp/views/media/playerview.cpp deleted file mode 100644 index fb73ae5..0000000 --- a/src/qEmbyApp/views/media/playerview.cpp +++ /dev/null @@ -1,5459 +0,0 @@ -#include "playerview.h" -#include "../../components/modernscrollpanel.h" -#include "../../components/nativedanmakuoverlay.h" -#include "../../components/playerdanmakuidentifydialog.h" -#include "../../components/playerdanmakusettingsdialog.h" -#include "../../components/playerlongpresshandler.h" -#include "../../components/playermediaswitcherpanel.h" -#include "../../components/playerosdlayer.h" -#include "../../components/playerstatisticsoverlay.h" -#include "../../components/playersubtitlesettingsdialog.h" -#include "../../components/seektimebubble.h" -#include "../../components/shadowoverlay.h" -#include "../../utils/mediaitemutils.h" -#include "../../utils/mediasourcepreferenceutils.h" -#include "../../utils/playerpreferenceutils.h" -#include "../../utils/powerinhibitutils.h" -#include "../../utils/subtitlestyleutils.h" -#include "qembycore.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include "../../utils/motionprefs.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -constexpr int kHudAutoHideDelayMs = 1800; - -bool isDanmakuEnabledConfigKey(const QString &key) -{ - static const QString kDanmakuEnabledSuffix = - QStringLiteral("/") + QString::fromLatin1(ConfigKeys::PlayerDanmakuEnabled); - return key == QLatin1String(ConfigKeys::PlayerDanmakuEnabled) || key.endsWith(kDanmakuEnabledSuffix); -} - -QString trimOrDash(QString value) -{ - value = value.trimmed(); - return value.isEmpty() ? QStringLiteral("-") : value; -} - -QString firstNonEmpty(const QStringList &values) -{ - for (QString value : values) - { - value = value.trimmed(); - if (!value.isEmpty()) - { - return value; - } - } - return {}; -} - -QString joinNonEmpty(const QStringList &values, const QString &separator) -{ - QStringList filtered; - for (QString value : values) - { - value = value.trimmed(); - if (!value.isEmpty()) - { - filtered.append(value); - } - } - return filtered.join(separator); -} - -QString stripTrailingZeros(QString text) -{ - if (!text.contains(QLatin1Char('.'))) - { - return text; - } - - while (text.endsWith(QLatin1Char('0'))) - { - text.chop(1); - } - if (text.endsWith(QLatin1Char('.'))) - { - text.chop(1); - } - return text; -} - -QString formatBitrateValue(qint64 bitsPerSecond) -{ - if (bitsPerSecond <= 0) - { - return {}; - } - - if (bitsPerSecond < 1000000) - { - return QStringLiteral("%1 kbps").arg(stripTrailingZeros(QString::number(bitsPerSecond / 1000.0, 'f', 1))); - } - - return QStringLiteral("%1 Mbps").arg(stripTrailingZeros(QString::number(bitsPerSecond / 1000000.0, 'f', 2))); -} - -QString formatDataRateValue(qint64 bytesPerSecond) -{ - if (bytesPerSecond <= 0) - { - return QStringLiteral("0.0 KB/s"); - } - - if (bytesPerSecond < 1024 * 1024) - { - return QStringLiteral("%1 KB/s").arg(stripTrailingZeros(QString::number(bytesPerSecond / 1024.0, 'f', 1))); - } - - return QStringLiteral("%1 MB/s").arg(stripTrailingZeros(QString::number(bytesPerSecond / 1048576.0, 'f', 1))); -} - -QString formatFrameRateValue(double fps) -{ - if (!std::isfinite(fps) || fps <= 0.0) - { - return {}; - } - - return QStringLiteral("%1 fps").arg(stripTrailingZeros(QString::number(fps, 'f', fps >= 100.0 ? 1 : 3))); -} - -QString formatDurationValue(double seconds) -{ - if (!std::isfinite(seconds) || seconds <= 0.0) - { - return {}; - } - - return QStringLiteral("%1 s").arg(stripTrailingZeros(QString::number(seconds, 'f', seconds >= 100.0 ? 0 : 1))); -} - -QString formatAvSyncValue(double seconds) -{ - if (!std::isfinite(seconds)) - { - return {}; - } - - const double milliseconds = seconds * 1000.0; - return QStringLiteral("%1%2 ms") - .arg(milliseconds >= 0.0 ? QStringLiteral("+") : QString()) - .arg(stripTrailingZeros(QString::number(milliseconds, 'f', 1))); -} - -QString formatLevelValue(int level) -{ - if (level <= 0) - { - return {}; - } - - if (level % 10 == 0) - { - return QString::number(level / 10); - } - - return QStringLiteral("%1.%2").arg(level / 10).arg(level % 10); -} - -QString formatDimensionValue(int width, int height) -{ - if (width <= 0 || height <= 0) - { - return {}; - } - - return QStringLiteral("%1 x %2").arg(width).arg(height); -} - -QString formatAspectValue(double width, double height, QString explicitAspect = {}) -{ - explicitAspect = explicitAspect.trimmed(); - if (!explicitAspect.isEmpty()) - { - return explicitAspect; - } - - if (!std::isfinite(width) || !std::isfinite(height) || width <= 0.0 || height <= 0.0) - { - return {}; - } - - const double ratio = width / height; - struct KnownRatio - { - double value; - const char *label; - }; - - static const KnownRatio knownRatios[] = { - {4.0 / 3.0, "4:3"}, {16.0 / 9.0, "16:9"}, {21.0 / 9.0, "21:9"}, {1.0, "1:1"}}; - - for (const KnownRatio &known : knownRatios) - { - if (std::fabs(ratio - known.value) < 0.03) - { - return QString::fromLatin1(known.label); - } - } - - return QStringLiteral("%1:1").arg(stripTrailingZeros(QString::number(ratio, 'f', 2))); -} - -qint64 variantToLongLong(const QVariant &value) -{ - bool ok = false; - const qint64 result = value.toLongLong(&ok); - return ok ? result : 0; -} - -double variantToDouble(const QVariant &value) -{ - bool ok = false; - const double result = value.toDouble(&ok); - return ok ? result : 0.0; -} - -const MediaStreamInfo *findFirstStreamByType(const MediaSourceInfo &source, const QString &type) -{ - for (const MediaStreamInfo &stream : source.mediaStreams) - { - if (stream.type.compare(type, Qt::CaseInsensitive) == 0) - { - return &stream; - } - } - return nullptr; -} - -QVariantMap findSelectedTrackByType(const QVariantList &tracks, const QString &type) -{ - for (const QVariant &trackValue : tracks) - { - const QVariantMap track = trackValue.toMap(); - if (track.value(QStringLiteral("type")).toString() == type && track.value(QStringLiteral("selected")).toBool()) - { - return track; - } - } - return {}; -} - -bool hasValidAnimationTarget(const QPropertyAnimation *animation) -{ - return animation && animation->targetObject(); -} - -bool fadeGroupHasValidTargets(const QParallelAnimationGroup *group) -{ - if (!group) - { - return false; - } - - for (int i = 0; i < group->animationCount(); ++i) - { - const auto *animation = qobject_cast(group->animationAt(i)); - if (!hasValidAnimationTarget(animation)) - { - return false; - } - } - - return true; -} - -void stopPropertyAnimationSafely(QPropertyAnimation *animation) -{ - if (!hasValidAnimationTarget(animation) || animation->state() == QAbstractAnimation::Stopped) - { - return; - } - - animation->stop(); -} - -void stopFadeGroupSafely(QParallelAnimationGroup *group) -{ - if (!group || group->state() == QAbstractAnimation::Stopped || !fadeGroupHasValidTargets(group)) - { - return; - } - - group->stop(); -} - -void detachPropertyAnimationTarget(QPropertyAnimation *animation) -{ - if (!animation) - { - return; - } - - if (animation->state() != QAbstractAnimation::Stopped) - { - animation->stop(); - } - - animation->setTargetObject(nullptr); -} - -void detachFadeGroupTargets(QParallelAnimationGroup *group) -{ - if (!group) - { - return; - } - - if (group->state() != QAbstractAnimation::Stopped) - { - group->stop(); - } - - for (int i = 0; i < group->animationCount(); ++i) - { - auto *animation = qobject_cast(group->animationAt(i)); - detachPropertyAnimationTarget(animation); - } -} -} - -PlayerView::PlayerView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent), m_isPlaying(false), m_currentPosition(0.0), m_totalDuration(0.0), m_activePopup(nullptr) -{ - - setProperty("isImmersive", true); - setMouseTracking(true); - setAutoFillBackground(true); - setFocusPolicy(Qt::StrongFocus); - - m_isRightSidebarVisible = false; - - m_hideTimer = new QTimer(this); - m_hideTimer->setSingleShot(true); - connect(m_hideTimer, &QTimer::timeout, this, &PlayerView::hideControls); - - m_reportTimer = new QTimer(this); - m_reportTimer->setInterval(10000); - connect(m_reportTimer, &QTimer::timeout, this, &PlayerView::reportProgressToServer); - - - m_longPressHandler = new PlayerLongPressHandler(this); - connect(m_longPressHandler, &PlayerLongPressHandler::seekRequested, this, &PlayerView::seekRelative); - connect(m_longPressHandler, &PlayerLongPressHandler::toastRequested, this, &PlayerView::showToast); - - - m_bufferTimer = new QTimer(this); - m_bufferTimer->setInterval(250); - connect(m_bufferTimer, &QTimer::timeout, this, - [this]() - { - updateBufferProgress(); - - if (m_showStatisticsOverlay && m_statisticsOverlay) - { - m_bufferTimerTick = (m_bufferTimerTick + 1) % 2; - if (m_bufferTimerTick == 0) - { - updateStatisticsDisplay(); - } - } - }); - - m_toastTimer = new QTimer(this); - m_toastTimer->setSingleShot(true); - connect(m_toastTimer, &QTimer::timeout, this, [this]() { m_toastLabel->hide(); }); - - - m_singleClickTimer = new QTimer(this); - m_singleClickTimer->setSingleShot(true); - m_singleClickTimer->setInterval(200); - connect(m_singleClickTimer, &QTimer::timeout, this, &PlayerView::togglePlayPause); - - setupUi(); - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [this](const QString &key, const QVariant &value) - { - Q_UNUSED(value); - if (isDanmakuEnabledConfigKey(key)) - { - updateDanmakuButtonState(); - } - if (key == ConfigKeys::PlayerMediaSwitcherMode) - { - applyMediaSwitcherMode(); - } - if (SubtitleStyleUtils::isSubtitleStyleKey(key)) - { - applySubtitleStyleSettings(); - } - }); - applyMediaSwitcherMode(); - applySubtitleStyleSettings(); - this->installEventFilter(this); -} - - -PlayerView::~PlayerView() -{ - disconnect(this, &PlayerView::playerChromeVisibilityChanged, nullptr, nullptr); - beginViewTeardown(); - stopAndReport(); -} - -void PlayerView::prepareForStackLeave() -{ - if (m_isViewTearingDown && m_hasReportedStop) - { - return; - } - - qDebug() << "[PlayerView] Prepare for stack leave" - << "| mediaId=" << m_currentMediaId << "| isTearingDown=" << m_isViewTearingDown - << "| hasReportedStop=" << m_hasReportedStop; - - beginViewTeardown(); - stopAndReport(); -} - -void PlayerView::clearMediaSwitcherCache() -{ - m_switcherCacheMediaId.clear(); - m_switcherCacheReady = false; - m_switcherResumeItems.clear(); - m_switcherSeriesSeasons.clear(); - m_switcherSeasonEpisodes.clear(); -} - -bool PlayerView::useHudMediaSwitcher() const -{ - return ConfigStore::instance()->get(ConfigKeys::PlayerMediaSwitcherMode, "sidebar") == - QStringLiteral("hud"); -} - -void PlayerView::applyMediaSwitcherMode() -{ - const bool hudMode = useHudMediaSwitcher(); - - if (m_mediaSwitchBtn) - { - m_mediaSwitchBtn->setVisible(hudMode); - } - if (m_rightTrigger) - { - m_rightTrigger->setVisible(!hudMode); - } - if (hudMode) - { - hideRightSidebar(); - } - if (!hudMode) - { - hideHudMediaSwitcher(); - } - - updateMediaSwitcherButton(); -} - -void PlayerView::updateMediaSwitcherButton() -{ - if (!m_mediaSwitchBtn) - { - return; - } - - m_mediaSwitchBtn->setProperty("drawerActive", - m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible() && useHudMediaSwitcher()); - m_mediaSwitchBtn->style()->unpolish(m_mediaSwitchBtn); - m_mediaSwitchBtn->style()->polish(m_mediaSwitchBtn); - m_mediaSwitchBtn->update(); - - const QString tooltip = m_isSeriesMode ? tr("Switch season and episode") : tr("Switch media"); - m_mediaSwitchBtn->setToolTip(tooltip); -} - -QString PlayerView::formatMediaSwitcherPlaybackTitle(const MediaItem &item) const -{ - return MediaItemUtils::playbackTitle(item, m_seriesName); -} - -void PlayerView::showHudMediaSwitcher() -{ - if (m_isViewTearingDown || !useHudMediaSwitcher() || !m_mediaSwitchDrawer) - { - return; - } - - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId) - { - m_mediaSwitchDrawer->show(); - syncHudMediaSwitcherContent(); - updateMediaSwitcherButton(); - updateOverlayLayout(); - showControls(); - return; - } - - m_mediaSwitchDrawer->setLoadingState(m_isSeriesMode ? (m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName) - : tr("Continue Watching"), - tr("Loading..."), m_isSeriesMode); - m_mediaSwitchDrawer->show(); - updateMediaSwitcherButton(); - updateOverlayLayout(); - showControls(); - ensureMediaSwitcherDataLoaded(); -} - -void PlayerView::hideHudMediaSwitcher() -{ - if (m_isViewTearingDown) - { - if (m_mediaSwitchDrawer) - { - m_mediaSwitchDrawer->hide(); - } - return; - } - - if (!m_mediaSwitchDrawer || !m_mediaSwitchDrawer->isVisible()) - { - return; - } - - m_mediaSwitchDrawer->hide(); - updateMediaSwitcherButton(); - updateOverlayLayout(); -} - -void PlayerView::syncHudMediaSwitcherContent() -{ - if (!m_mediaSwitchDrawer) - { - return; - } - - if (m_isSeriesMode && !m_seriesId.isEmpty()) - { - m_mediaSwitchDrawer->setSeriesData(m_seriesName, m_switcherSeriesSeasons, m_switcherSeasonEpisodes, - m_currentMediaId); - } - else - { - m_mediaSwitchDrawer->setMovieItems(m_switcherResumeItems, m_currentMediaId); - } - - updateMediaSwitcherButton(); - updateOverlayLayout(); -} - -bool PlayerView::findAdjacentMediaFromCache(int direction, QString &mediaId, QString &title, - long long &startPositionTicks) const -{ - mediaId.clear(); - title.clear(); - startPositionTicks = 0; - - if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId) - { - return false; - } - - if (m_isSeriesMode && !m_seriesId.isEmpty()) - { - const QString currentId = m_currentMediaId; - MediaItem previousItem; - MediaItem nextItem; - bool foundCurrent = false; - - for (const MediaItem &season : m_switcherSeriesSeasons) - { - const QList episodes = m_switcherSeasonEpisodes.value(season.id); - for (const MediaItem &episode : episodes) - { - if (episode.id == currentId) - { - foundCurrent = true; - } - else if (!foundCurrent) - { - previousItem = episode; - } - else if (foundCurrent && nextItem.id.isEmpty()) - { - nextItem = episode; - break; - } - } - if (foundCurrent && !nextItem.id.isEmpty()) - { - break; - } - } - - const MediaItem targetItem = direction < 0 ? previousItem : nextItem; - if (targetItem.id.isEmpty()) - { - return false; - } - - mediaId = targetItem.id; - title = formatMediaSwitcherPlaybackTitle(targetItem); - startPositionTicks = targetItem.userData.playbackPositionTicks; - return true; - } - - if (m_switcherResumeItems.isEmpty()) - { - return false; - } - - QVector filteredIndexes; - filteredIndexes.reserve(m_switcherResumeItems.size()); - int currentFilteredIndex = -1; - const QString currentType = m_currentMediaItem.type; - - for (int i = 0; i < m_switcherResumeItems.size(); ++i) - { - const MediaItem &item = m_switcherResumeItems.at(i); - if (!currentType.isEmpty() && item.type != currentType) - { - continue; - } - - const int filteredIndex = filteredIndexes.size(); - filteredIndexes.append(i); - if (item.id == m_currentMediaId) - { - currentFilteredIndex = filteredIndex; - } - } - - if (currentFilteredIndex < 0) - { - return false; - } - - const int step = direction < 0 ? -1 : 1; - const int targetFilteredIndex = currentFilteredIndex + step; - if (targetFilteredIndex < 0 || targetFilteredIndex >= filteredIndexes.size()) - { - return false; - } - - const MediaItem &targetItem = m_switcherResumeItems.at(filteredIndexes.at(targetFilteredIndex)); - if (targetItem.id.isEmpty()) - { - return false; - } - - mediaId = targetItem.id; - title = formatMediaSwitcherPlaybackTitle(targetItem); - startPositionTicks = targetItem.userData.playbackPositionTicks; - return true; -} - -bool PlayerView::findNextResumeMediaFromCache(QString &mediaId, - QString &title, - long long &startPositionTicks, - bool skipCurrentSeries) const -{ - mediaId.clear(); - title.clear(); - startPositionTicks = 0; - - if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId || m_switcherResumeItems.isEmpty()) - { - return false; - } - - const auto isEligible = [this, skipCurrentSeries](const MediaItem &item) - { - if (item.id.isEmpty() || item.id == m_currentMediaId) - { - return false; - } - - if (skipCurrentSeries && !m_seriesId.isEmpty() && - (item.seriesId == m_seriesId || item.id == m_seriesId)) - { - return false; - } - - return true; - }; - - int currentIndex = -1; - for (int i = 0; i < m_switcherResumeItems.size(); ++i) - { - if (m_switcherResumeItems.at(i).id == m_currentMediaId) - { - currentIndex = i; - break; - } - } - - const int startIndex = currentIndex >= 0 ? currentIndex + 1 : 0; - for (int i = startIndex; i < m_switcherResumeItems.size(); ++i) - { - const MediaItem &item = m_switcherResumeItems.at(i); - if (!isEligible(item)) - { - continue; - } - - mediaId = item.id; - title = MediaItemUtils::playbackTitle(item); - if (title.isEmpty()) - { - title = item.name; - } - startPositionTicks = item.userData.playbackPositionTicks; - return true; - } - - return false; -} - - -bool PlayerView::isMediaPlaying() const -{ - return m_isPlaying; -} - -void PlayerView::updatePowerInhibition() -{ - const bool shouldHold = !m_isViewTearingDown && !m_hasReportedStop && m_isPlaying && !m_currentMediaId.isEmpty(); - - if (shouldHold == m_powerInhibitionHeld) - { - return; - } - - if (shouldHold) - { - m_powerInhibitionHeld = PowerInhibitUtils::acquirePlaybackInhibition(QStringLiteral("qEmby video playback")); - } - else - { - PowerInhibitUtils::releasePlaybackInhibition(); - m_powerInhibitionHeld = false; - } -} - -QCoro::Task PlayerView::autoPlayNextMediaIfEnabled() -{ - if (m_isViewTearingDown || m_hasReportedStop || m_currentMediaId.isEmpty()) - { - co_return; - } - - if (!ConfigStore::instance()->get(ConfigKeys::PlayerContinuousPlay, true)) - { - qInfo().noquote() << "[PlayerView] Continuous playback disabled" - << "| mediaId:" << m_currentMediaId; - co_return; - } - - if (m_autoPlayAdvanceInProgress) - { - qDebug().noquote() << "[PlayerView] Continuous playback already advancing" - << "| mediaId:" << m_currentMediaId; - co_return; - } - - m_autoPlayAdvanceInProgress = true; - - QPointer guard(this); - const QString finishedMediaId = m_currentMediaId; - const bool finishedSeriesMode = m_isSeriesMode && !m_seriesId.isEmpty(); - - qInfo().noquote() << "[PlayerView] Continuous playback resolving next item" - << "| mediaId:" << finishedMediaId - << "| seriesMode:" << finishedSeriesMode - << "| seriesId:" << m_seriesId; - - if (!m_switcherCacheReady || m_switcherCacheMediaId != finishedMediaId) - { - co_await ensureMediaSwitcherDataLoaded(); - if (!guard || guard->m_currentMediaId != finishedMediaId) - { - co_return; - } - } - - QString targetId; - QString targetTitle; - long long startTicks = 0; - bool hasTarget = guard->findAdjacentMediaFromCache(1, targetId, targetTitle, startTicks); - if (!hasTarget) - { - hasTarget = guard->findNextResumeMediaFromCache(targetId, targetTitle, startTicks, finishedSeriesMode); - } - - if (!hasTarget) - { - qInfo().noquote() << "[PlayerView] Continuous playback has no next item" - << "| mediaId:" << finishedMediaId - << "| seriesMode:" << finishedSeriesMode; - guard->m_autoPlayAdvanceInProgress = false; - co_return; - } - - qInfo().noquote() << "[PlayerView] Continuous playback switching item" - << "| from:" << finishedMediaId - << "| to:" << targetId - << "| title:" << targetTitle - << "| startTicks:" << startTicks; - - co_await guard->switchFromMediaSwitcher(targetId, targetTitle, startTicks); - if (guard && !guard->m_hasReportedStop && guard->m_currentMediaId == finishedMediaId) - { - guard->m_autoPlayAdvanceInProgress = false; - } -} - -QCoro::Task PlayerView::requestIntroDBSegments() -{ - QPointer introDB = m_core ? m_core->introDBService() : nullptr; - QPointer mediaService = m_core ? m_core->mediaService() : nullptr; - if (m_segmentsRequested || !introDB) - co_return; - - const bool skipIntro = ConfigStore::instance()->get(ConfigKeys::PlayerSkipIntro, false); - const bool skipOutro = ConfigStore::instance()->get(ConfigKeys::PlayerSkipOutro, false); - if (!skipIntro && !skipOutro) - { - qInfo("IntroDB: both skip intro and skip outro disabled, skipping"); - co_return; - } - - const QString currentMediaId = m_currentMediaId; - const MediaItem currentItem = m_currentMediaItem; - const int season = currentItem.parentIndexNumber; - const int episode = currentItem.indexNumber; - if (season <= 0 || episode <= 0) - { - qInfo("IntroDB: invalid season/episode: S%02dE%02d", season, episode); - co_return; - } - - m_segmentsRequested = true; - QPointer safeThis(this); - - const auto providerIdValue = [](const QVariantMap &providerIds, std::initializer_list keys) - { - for (const char *key : keys) - { - const QString value = providerIds.value(QString::fromLatin1(key)).toString().trimmed(); - if (!value.isEmpty()) - { - return value; - } - } - return QString(); - }; - - const QString episodeImdbId = providerIdValue(currentItem.providerIds, {"Imdb", "IMDb", "imdb", "imdbid"}); - QString imdbId; - if (mediaService && !currentItem.seriesId.trimmed().isEmpty()) - { - try - { - const MediaItem seriesDetail = co_await mediaService->getItemDetail(currentItem.seriesId); - if (!safeThis || safeThis->m_currentMediaId != currentMediaId) - co_return; - imdbId = providerIdValue(seriesDetail.providerIds, {"Imdb", "IMDb", "imdb", "imdbid"}); - if (!imdbId.isEmpty()) - { - qInfo("IntroDB: using series IMDb ID imdb=%s seriesId=%s", qPrintable(imdbId), - qPrintable(currentItem.seriesId)); - } - } - catch (const std::exception &e) - { - if (!safeThis || safeThis->m_currentMediaId != currentMediaId) - co_return; - qWarning("IntroDB: failed to fetch series detail for IMDb ID: %s", e.what()); - } - } - - if (imdbId.isEmpty()) - { - imdbId = episodeImdbId; - if (!imdbId.isEmpty()) - { - qInfo("IntroDB: using episode IMDb ID fallback imdb=%s", qPrintable(imdbId)); - } - } - - if (imdbId.isEmpty()) - { - qInfo("IntroDB: no IMDb ID in providerIds, episodeKeys=%s", - qPrintable(currentItem.providerIds.keys().join(", "))); - co_return; - } - - qInfo("IntroDB: requesting segments imdb=%s S%02dE%02d", qPrintable(imdbId), season, episode); - - if (!introDB) - co_return; - const IntroDBService::EpisodeSegments segments = co_await introDB->fetchSegments(imdbId, season, episode); - if (!safeThis || safeThis->m_currentMediaId != currentMediaId) - co_return; - safeThis->m_episodeSegments = segments; - qInfo("IntroDB: segments fetched=%d notFound=%d intro=[%.1f-%.1f] outro=[%.1f-%.1f]", - safeThis->m_episodeSegments.fetched, safeThis->m_episodeSegments.notFound, - safeThis->m_episodeSegments.intro.startSec, safeThis->m_episodeSegments.intro.endSec, - safeThis->m_episodeSegments.outro.startSec, safeThis->m_episodeSegments.outro.endSec); -} - -void PlayerView::checkAndSkipSegment(double position) -{ - if (!m_episodeSegments.fetched || m_episodeSegments.notFound) - return; - - const bool skipIntro = ConfigStore::instance()->get(ConfigKeys::PlayerSkipIntro, false); - const bool skipOutro = ConfigStore::instance()->get(ConfigKeys::PlayerSkipOutro, false); - - const auto skipIfInRange = - [this, position](const IntroDBService::SegmentInfo &seg, const char *label, bool enabled, bool &flag) - { - if (!enabled || flag || !m_mpvWidget || !std::isfinite(seg.startSec) || !std::isfinite(seg.endSec) || - seg.startSec < 0 || seg.endSec <= seg.startSec) - return; - if (position >= seg.startSec && position < seg.endSec) - { - qInfo("IntroDB: skipping %s [%.1f-%.1f] at pos=%.1f", label, seg.startSec, seg.endSec, position); - m_mpvWidget->seek(seg.endSec); - flag = true; - } - }; - - skipIfInRange(m_episodeSegments.intro, "intro", skipIntro, m_introSkipped); - skipIfInRange(m_episodeSegments.outro, "outro", skipOutro, m_outroSkipped); -} - - -void PlayerView::pausePlayback() -{ - if (m_mpvWidget) - m_mpvWidget->pause(); -} - - - -void PlayerView::resumePlayback() -{ - if (!m_mpvWidget || m_currentMediaId.isEmpty()) - return; - - - QString newStreamUrl = m_originalStreamUrl; - if (m_currentSourceInfoVar.isValid()) - { - MediaSourceInfo sourceInfo; - if (m_currentSourceInfoVar.canConvert()) - { - sourceInfo = m_currentSourceInfoVar.value().selectedSource; - } - else if (m_currentSourceInfoVar.canConvert()) - { - sourceInfo = m_currentSourceInfoVar.value(); - } - - QString directUrl = m_core->mediaService()->getStreamUrl(m_currentMediaId, sourceInfo); - if (!directUrl.isEmpty()) - { - newStreamUrl = directUrl; - } - } - - - m_pendingSeekSeconds = m_currentPosition; - m_isBuffering = true; - updateLoadingState(); - const QString activeServerId = m_core->serverManager()->activeProfile().id; - m_mpvWidget->loadMedia(newStreamUrl, activeServerId); - m_mpvWidget->play(); -} - - -QPushButton *PlayerView::createHudButton(const QString &iconPath, const QSize &size) -{ - auto *btn = new QPushButton(this); - btn->setProperty("class", "player-hud-btn"); - btn->setIcon(QIcon(iconPath)); - btn->setIconSize(size); - btn->setCursor(Qt::PointingHandCursor); - btn->setFocusPolicy(Qt::NoFocus); - btn->setFixedSize(size.width() + 16, size.height() + 16); - return btn; -} - -void PlayerView::setupUi() -{ - m_mpvWidget = new MpvWidget(this); - m_mpvWidget->setAutoFillBackground(true); - m_mpvWidget->installEventFilter(this); - m_mpvWidget->setMouseTracking(true); - m_nativeDanmakuOverlay = new NativeDanmakuOverlay(m_mpvWidget->controller(), this); - m_nativeDanmakuOverlay->setExternalFrameClockEnabled(true); - connect(m_mpvWidget, &MpvWidget::renderUpdateRequested, m_nativeDanmakuOverlay, - &NativeDanmakuOverlay::advanceFrame); - m_danmakuController = new PlayerDanmakuController(m_core, m_mpvWidget, m_nativeDanmakuOverlay, this); - connect(m_danmakuController, &PlayerDanmakuController::toastRequested, this, - [this](const QString &message) { showToast(message); }); - connect(m_danmakuController, &PlayerDanmakuController::stateChanged, this, &PlayerView::updateDanmakuButtonState); - - - m_loadingOverlay = new LoadingOverlay(this); - m_loadingOverlay->setObjectName("playerLoadingOverlay"); - - m_loadingOverlay->setImmersive(true); - - m_loadingOverlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); - - - connect(m_mpvWidget->controller(), &MpvController::fileLoaded, this, - [this]() - { - m_isBuffering = false; - updateLoadingState(); - applySubtitleStyleSettings(); - }); - - - - m_osdLayer = new PlayerOsdLayer(this); - - - - - - setupRightSidebar(); - - - m_rightTrigger = new QWidget(this); - m_rightTrigger->setFixedWidth(15); - m_rightTrigger->setCursor(Qt::PointingHandCursor); - m_rightTrigger->setAttribute(Qt::WA_TransparentForMouseEvents, false); - m_rightTrigger->installEventFilter(this); - - - m_logoLabel = new QLabel(this); - m_logoLabel->setObjectName("playerLogoLabel"); - m_logoLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); - m_logoLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop); - m_logoLabel->hide(); - - - m_networkSpeedLabel = new QLabel("0.0 KB/s", this); - m_networkSpeedLabel->setObjectName("playerNetworkSpeed"); - m_networkSpeedLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); - m_networkSpeedLabel->setAlignment(Qt::AlignRight | Qt::AlignTop); - - - m_topHUD = new QWidget(this); - m_topHUD->setObjectName("playerTopHUD"); - - m_topHUD->setFixedHeight(32); - m_topHUD->installEventFilter(this); - - auto *topLayout = new QHBoxLayout(m_topHUD); - topLayout->setContentsMargins(0, 0, 0, 0); - topLayout->setSpacing(0); - - - m_backBtn = new QPushButton(m_topHUD); - m_backBtn->setObjectName("hud-back-btn"); - m_backBtn->setIcon(QIcon(":/svg/player/back.svg")); - m_backBtn->setIconSize(QSize(16, 16)); - m_backBtn->setFixedSize(38, 28); - m_backBtn->setFocusPolicy(Qt::NoFocus); - m_backBtn->setToolTip(tr("Back")); - connect(m_backBtn, &QPushButton::clicked, this, &PlayerView::onBackClicked); - - m_titleLabel = new QLabel(m_topHUD); - m_titleLabel->setObjectName("playerTitleLabel"); - m_titleLabel->setMinimumWidth(0); - m_titleLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - m_titleLabel->setAlignment(Qt::AlignCenter); - m_titleLabel->installEventFilter(this); - - - auto *sysLayout = new QHBoxLayout(); - sysLayout->setSpacing(0); - sysLayout->setContentsMargins(0, 0, 0, 0); - - - m_minBtn = new QPushButton(m_topHUD); - m_minBtn->setObjectName("hud-min-btn"); - m_minBtn->setIcon(QIcon(":/svg/player/min.svg")); - m_minBtn->setIconSize(QSize(12, 12)); - m_minBtn->setFixedSize(38, 28); - m_minBtn->setFocusPolicy(Qt::NoFocus); - m_minBtn->setToolTip(tr("Minimize")); - - m_maxBtn = new QPushButton(m_topHUD); - m_maxBtn->setObjectName("hud-max-btn"); - m_maxBtn->setIcon(QIcon(":/svg/player/max.svg")); - m_maxBtn->setIconSize(QSize(12, 12)); - m_maxBtn->setFixedSize(38, 28); - m_maxBtn->setFocusPolicy(Qt::NoFocus); - m_maxBtn->setToolTip(tr("Maximize")); - - m_closeBtn = new QPushButton(m_topHUD); - m_closeBtn->setObjectName("hud-close-btn"); - m_closeBtn->setIcon(QIcon(":/svg/player/close.svg")); - m_closeBtn->setIconSize(QSize(12, 12)); - m_closeBtn->setFixedSize(38, 28); - m_closeBtn->setFocusPolicy(Qt::NoFocus); - m_closeBtn->setToolTip(tr("Close")); - -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - m_minBtn->hide(); - m_maxBtn->hide(); - m_closeBtn->hide(); -#else - connect(m_minBtn, &QPushButton::clicked, this, [this]() { window()->showMinimized(); }); - - connect(m_maxBtn, &QPushButton::clicked, this, - [this]() - { - - if (window()->isMaximized()) - { - window()->showNormal(); - } - else - { - window()->showMaximized(); - } - }); - - - connect(m_closeBtn, &QPushButton::clicked, this, - [this]() - { - beginViewTeardown(); - stopAndReport(); - window()->close(); - }); -#endif - -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - delete sysLayout; -#else - sysLayout->addWidget(m_minBtn, 0, Qt::AlignTop); - sysLayout->addWidget(m_maxBtn, 0, Qt::AlignTop); - sysLayout->addWidget(m_closeBtn, 0, Qt::AlignTop); -#endif - - -#if defined(Q_OS_MACOS) || defined(Q_OS_MAC) - auto *macLeftTitlebarArea = new QWidget(m_topHUD); - macLeftTitlebarArea->setObjectName("playerMacTitlebarLeftArea"); - macLeftTitlebarArea->setFixedWidth(118); - - auto *macRightTitlebarArea = new QWidget(m_topHUD); - macRightTitlebarArea->setObjectName("playerMacTitlebarRightArea"); - macRightTitlebarArea->setFixedWidth(118); - auto *macRightLayout = new QHBoxLayout(macRightTitlebarArea); - macRightLayout->setContentsMargins(0, 0, 0, 0); - macRightLayout->setSpacing(0); - macRightLayout->addStretch(); - macRightLayout->addWidget(m_backBtn, 0, Qt::AlignTop); - - topLayout->addWidget(macLeftTitlebarArea, 0, Qt::AlignTop); - topLayout->addWidget(m_titleLabel, 1, Qt::AlignTop); - topLayout->addWidget(macRightTitlebarArea, 0, Qt::AlignTop); -#else - topLayout->addWidget(m_backBtn, 0, Qt::AlignTop); - topLayout->addWidget(m_titleLabel, 1, Qt::AlignTop); - topLayout->addLayout(sysLayout, 0); -#endif - - - m_bottomHUD = new QWidget(this); - m_bottomHUD->setObjectName("playerBottomHUD"); - m_bottomHUD->setFixedHeight(m_bottomHudBaseHeight); - - - m_bottomHUD->installEventFilter(this); - - auto *bottomLayout = new QVBoxLayout(m_bottomHUD); - bottomLayout->setContentsMargins(32, 10, 32, 16); - bottomLayout->setSpacing(8); - - m_mediaSwitchDrawer = new PlayerMediaSwitcherPanel(m_core, m_bottomHUD); - m_mediaSwitchDrawer->hide(); - connect(m_mediaSwitchDrawer, &PlayerMediaSwitcherPanel::playRequested, this, - [this](QString mediaId, QString title, long long startPositionTicks) -> QCoro::Task - { co_await switchFromMediaSwitcher(mediaId, title, startPositionTicks); }); - bottomLayout->addWidget(m_mediaSwitchDrawer); - - - auto *progressLayout = new QHBoxLayout(); - progressLayout->setSpacing(16); - - m_currentTimeLabel = new QLabel("00:00", m_bottomHUD); - m_currentTimeLabel->setObjectName("playerTimeLabel"); - - m_progressSlider = new ModernSlider(Qt::Horizontal, m_bottomHUD); - m_progressSlider->setObjectName("playerSlider"); - m_progressSlider->setFocusPolicy(Qt::NoFocus); - m_progressSlider->setCursor(Qt::PointingHandCursor); - m_progressSlider->setSingleStep(1); - m_progressSlider->setPageStep(10); - m_progressSlider->setRange(0, 1000); - - m_totalTimeLabel = new QLabel("00:00", m_bottomHUD); - m_totalTimeLabel->setObjectName("playerTimeLabel"); - - progressLayout->addWidget(m_currentTimeLabel); - progressLayout->addWidget(m_progressSlider, 1); - progressLayout->addWidget(m_totalTimeLabel); - - - auto *controlLayout = new QHBoxLayout(); - controlLayout->setSpacing(12); - - m_prevMediaBtn = createHudButton(":/svg/player/prev.svg"); - m_prevMediaBtn->setToolTip(tr("Previous")); - m_rewindBtn = createHudButton(":/svg/player/fast-rewind.svg"); - m_rewindBtn->setToolTip(tr("Rewind")); - m_playPauseBtn = createHudButton(":/svg/player/pause.svg", QSize(36, 36)); - m_playPauseBtn->setToolTip(tr("Play/Pause")); - m_forwardBtn = createHudButton(":/svg/player/fast-forward.svg"); - m_forwardBtn->setToolTip(tr("Forward")); - m_nextMediaBtn = createHudButton(":/svg/player/next.svg"); - m_nextMediaBtn->setToolTip(tr("Next")); - - - m_volumeBtn = createHudButton(":/svg/player/volume.svg"); - m_volumeBtn->setToolTip(tr("Mute/Unmute")); - m_volumeBtn->installEventFilter(this); - - m_volumeSlider = new ModernSlider(Qt::Horizontal, m_bottomHUD); - m_volumeSlider->setObjectName("volumeSlider"); - m_volumeSlider->setFocusPolicy(Qt::NoFocus); - m_volumeSlider->setCursor(Qt::PointingHandCursor); - m_volumeSlider->setRange(0, 100); - m_volumeSlider->setValue(100); - m_volumeSlider->setFixedWidth(80); - m_volumeSlider->installEventFilter(this); - - m_speedBtn = new QPushButton("1.0X", m_bottomHUD); - m_speedBtn->setObjectName("playerSpeedBtn"); - m_speedBtn->setCursor(Qt::PointingHandCursor); - m_speedBtn->setFocusPolicy(Qt::NoFocus); - m_speedBtn->setFixedHeight(40); - m_speedBtn->setToolTip(tr("Playback Speed")); - - m_mediaSwitchBtn = createHudButton(":/svg/player/media-switcher.svg"); - m_mediaSwitchBtn->setObjectName("playerMediaSwitchBtn"); - m_mediaSwitchBtn->setToolTip(tr("Media Switcher")); - - m_audioBtn = createHudButton(":/svg/player/audio.svg"); - m_audioBtn->setToolTip(tr("Audio Track")); - m_subtitleBtn = createHudButton(":/svg/player/subtitle.svg"); - m_subtitleBtn->setToolTip(tr("Subtitles")); - m_danmakuBtn = createHudButton(":/svg/player/danmaku.svg"); - m_danmakuBtn->setToolTip(tr("Danmaku")); - m_settingsBtn = createHudButton(":/svg/player/settings.svg"); - m_settingsBtn->setToolTip(tr("Settings")); - - - m_scaleBtn = createHudButton(":/svg/player/scale-fit.svg"); - m_scaleBtn->setToolTip(tr("Aspect Ratio")); - m_fullscreenBtn = createHudButton(":/svg/player/fullscreen.svg"); - m_fullscreenBtn->setToolTip(tr("Fullscreen")); - - m_toastLabel = new QLabel("", m_bottomHUD); - m_toastLabel->setObjectName("playerToastLabel"); - m_toastLabel->setAlignment(Qt::AlignCenter); - m_toastLabel->hide(); - - - controlLayout->addWidget(m_prevMediaBtn); - controlLayout->addWidget(m_rewindBtn); - controlLayout->addWidget(m_playPauseBtn); - controlLayout->addWidget(m_forwardBtn); - controlLayout->addWidget(m_nextMediaBtn); - - - controlLayout->addSpacing(20); - - controlLayout->addWidget(m_volumeBtn); - controlLayout->addWidget(m_volumeSlider); - - controlLayout->addStretch(); - controlLayout->addWidget(m_toastLabel); - controlLayout->addStretch(); - - controlLayout->addWidget(m_mediaSwitchBtn); - controlLayout->addWidget(m_speedBtn); - controlLayout->addWidget(m_audioBtn); - controlLayout->addWidget(m_subtitleBtn); - controlLayout->addWidget(m_danmakuBtn); - controlLayout->addWidget(m_settingsBtn); - controlLayout->addWidget(m_scaleBtn); - controlLayout->addWidget(m_fullscreenBtn); - - bottomLayout->addLayout(progressLayout); - bottomLayout->addLayout(controlLayout); - - - m_statisticsOverlay = new PlayerStatisticsOverlay(this); - m_statisticsOverlay->setObjectName("playerStatisticsOverlay"); - m_statisticsOverlay->hide(); - - - m_topOpacity = new QGraphicsOpacityEffect(m_topHUD); - m_bottomOpacity = new QGraphicsOpacityEffect(m_bottomHUD); - m_logoOpacity = new QGraphicsOpacityEffect(m_logoLabel); - m_speedOpacity = new QGraphicsOpacityEffect(m_networkSpeedLabel); - - m_topHUD->setGraphicsEffect(m_topOpacity); - m_bottomHUD->setGraphicsEffect(m_bottomOpacity); - m_logoLabel->setGraphicsEffect(m_logoOpacity); - m_networkSpeedLabel->setGraphicsEffect(m_speedOpacity); - - m_fadeGroup = new QParallelAnimationGroup(this); - - auto *topAnim = new QPropertyAnimation(m_topOpacity, "opacity", this); - topAnim->setDuration(AnimationTokens::DurationMs::kNormal); - topAnim->setEasingCurve(AnimationTokens::Curve::fade()); - - auto *bottomAnim = new QPropertyAnimation(m_bottomOpacity, "opacity", this); - bottomAnim->setDuration(AnimationTokens::DurationMs::kNormal); - bottomAnim->setEasingCurve(AnimationTokens::Curve::fade()); - - auto *logoAnim = new QPropertyAnimation(m_logoOpacity, "opacity", this); - logoAnim->setDuration(AnimationTokens::DurationMs::kNormal); - logoAnim->setEasingCurve(AnimationTokens::Curve::fade()); - - auto *speedAnim = new QPropertyAnimation(m_speedOpacity, "opacity", this); - speedAnim->setDuration(AnimationTokens::DurationMs::kNormal); - speedAnim->setEasingCurve(AnimationTokens::Curve::fade()); - - m_fadeGroup->addAnimation(topAnim); - m_fadeGroup->addAnimation(bottomAnim); - m_fadeGroup->addAnimation(logoAnim); - m_fadeGroup->addAnimation(speedAnim); - - - connect(m_playPauseBtn, &QPushButton::clicked, this, &PlayerView::togglePlayPause); - connect(m_progressSlider, &QSlider::sliderMoved, this, &PlayerView::onSliderMoved); - - m_seekBubble = new SeekTimeBubble(this); - connect(m_progressSlider, &ModernSlider::hoverMoved, this, - [this](const QPoint &pos) - { - if (m_totalDuration <= 0) - { - m_seekBubble->hideBubble(); - return; - } - // Mirror ModernSlider's track math so the bubble time matches - // what a click at this x would seek to. - const int edgePadding = qMax(m_progressSlider->normalHandleRadius(), - m_progressSlider->activeHandleRadius()); - const double trackWidth = m_progressSlider->width() - 2.0 * edgePadding; - if (trackWidth <= 0) - { - return; - } - double ratio = (pos.x() - edgePadding) / trackWidth; - ratio = qBound(0.0, ratio, 1.0); - const double hoverSeconds = ratio * m_totalDuration; - m_seekBubble->setTimeText(formatTime(hoverSeconds, m_totalDuration)); - const QPoint anchor = m_progressSlider->mapTo(this, QPoint(pos.x(), 0)); - m_seekBubble->showAt(anchor - QPoint(0, 8)); - }); - connect(m_progressSlider, &ModernSlider::hoverLeft, this, - [this]() { m_seekBubble->hideBubble(); }); - - - connect(m_rewindBtn, &QPushButton::pressed, this, - [this]() - { - if (!m_longPressHandler) - { - double step = ConfigStore::instance()->get(ConfigKeys::PlayerSeekStep, "10").toDouble(); - seekRelative(-step); - return; - } - - bool isHudVisible = (m_topOpacity && m_topOpacity->opacity() > 0.0); - m_longPressHandler->startKeyLongPress(-1, !isHudVisible); - }); - - connect(m_forwardBtn, &QPushButton::pressed, this, - [this]() - { - if (!m_longPressHandler) - { - double step = ConfigStore::instance()->get(ConfigKeys::PlayerSeekStep, "10").toDouble(); - seekRelative(step); - return; - } - - bool isHudVisible = (m_topOpacity && m_topOpacity->opacity() > 0.0); - m_longPressHandler->startKeyLongPress(1, !isHudVisible); - }); - - connect(m_rewindBtn, &QPushButton::released, this, - [this]() - { - if (m_longPressHandler) - { - m_longPressHandler->stopKeyLongPress(); - } - }); - - connect(m_forwardBtn, &QPushButton::released, this, - [this]() - { - if (m_longPressHandler) - { - m_longPressHandler->stopKeyLongPress(); - } - }); - - connect(m_prevMediaBtn, &QPushButton::clicked, this, - [this]() -> QCoro::Task - { - QString targetId; - QString targetTitle; - long long startTicks = 0; - if (!findAdjacentMediaFromCache(-1, targetId, targetTitle, startTicks)) - { - showToast(m_isSeriesMode ? tr("No previous episode") : tr("No previous media")); - ensureMediaSwitcherDataLoaded(); - co_return; - } - - co_await switchFromMediaSwitcher(targetId, targetTitle, startTicks); - }); - - connect(m_nextMediaBtn, &QPushButton::clicked, this, - [this]() -> QCoro::Task - { - QString targetId; - QString targetTitle; - long long startTicks = 0; - if (!findAdjacentMediaFromCache(1, targetId, targetTitle, startTicks)) - { - showToast(m_isSeriesMode ? tr("No next episode") : tr("No next media")); - ensureMediaSwitcherDataLoaded(); - co_return; - } - - co_await switchFromMediaSwitcher(targetId, targetTitle, startTicks); - }); - - - connect(m_volumeBtn, &QPushButton::clicked, this, &PlayerView::toggleMute); - connect(m_volumeSlider, &QSlider::valueChanged, this, &PlayerView::onVolumeSliderMoved); - - connect(m_speedBtn, &QPushButton::clicked, this, &PlayerView::showSpeedMenu); - connect(m_mediaSwitchBtn, &QPushButton::clicked, this, - [this]() - { - if (m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible()) - { - hideHudMediaSwitcher(); - return; - } - showHudMediaSwitcher(); - }); - connect(m_audioBtn, &QPushButton::clicked, this, &PlayerView::showAudioMenu); - connect(m_subtitleBtn, &QPushButton::clicked, this, &PlayerView::showSubtitleMenu); - connect(m_danmakuBtn, &QPushButton::clicked, this, &PlayerView::showDanmakuMenu); - connect(m_settingsBtn, &QPushButton::clicked, this, &PlayerView::showSettingsMenu); - connect(m_scaleBtn, &QPushButton::clicked, this, &PlayerView::cycleVideoScale); - connect(m_fullscreenBtn, &QPushButton::clicked, this, &PlayerView::toggleFullscreenWindow); - - - connect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged); - connect(m_mpvWidget, &MpvWidget::durationChanged, this, &PlayerView::onDurationChanged); - connect(m_mpvWidget, &MpvWidget::playbackStateChanged, this, &PlayerView::onPlaybackStateChanged); - connect(m_mpvWidget, &MpvWidget::relayActiveChanged, this, - [this](bool active) - { - m_useRelayNetworkSpeed = active; - if (!active && m_networkSpeedLabel) - { - m_effectiveNetworkSpeed = 0; - m_networkSpeedLabel->setText(formatDataRateValue(0)); - } - }); - connect(m_mpvWidget, &MpvWidget::networkSpeedChanged, this, - [this](qint64 bytesPerSecond) - { - if (m_useRelayNetworkSpeed && m_networkSpeedLabel) - { - m_effectiveNetworkSpeed = bytesPerSecond; - m_networkSpeedLabel->setText(formatDataRateValue(bytesPerSecond)); - } - }); - - connect(m_mpvWidget->controller(), &MpvController::propertyChanged, this, &PlayerView::onMpvPropertyChanged); - connect(m_mpvWidget->controller(), &MpvController::endOfFile, this, - [this](const QString &reason) - { - qDebug() << "[PlayerView] MPV end of file" - << "| reason=" << reason; - m_isPlaybackFinished = (reason == QLatin1String("eof")); - m_isPlaying = false; - m_isBuffering = false; - m_isSeeking = false; - updatePowerInhibition(); - updateLoadingState(); - if (m_playPauseBtn) - { - m_playPauseBtn->setIcon(QIcon(":/svg/player/play.svg")); - } - if (m_isPlaybackFinished) - { - autoPlayNextMediaIfEnabled(); - } - }); - - setScaleIcon(); - updateDanmakuButtonState(); - updateMediaSwitcherButton(); - m_rightSidebar->raise(); - if (m_rightSidebarShadow) - m_rightSidebarShadow->stackUnder(m_rightSidebar); -} - - -void PlayerView::updateLoadingState() -{ - if (m_isViewTearingDown) - { - if (m_loadingOverlay) - { - m_loadingOverlay->forceStop(); - } - return; - } - - if (m_isBuffering || m_isSeeking) - { - m_loadingOverlay->start(); - - - - m_topHUD->raise(); - m_bottomHUD->raise(); - m_logoLabel->raise(); - m_networkSpeedLabel->raise(); - m_rightSidebar->raise(); - if (m_rightSidebarShadow) - m_rightSidebarShadow->stackUnder(m_rightSidebar); - m_rightTrigger->raise(); - if (m_activePopup) - m_activePopup->raise(); - } - else - { - m_loadingOverlay->stop(); - } -} - -QString PlayerView::formatDanmakuProviderLabel(QString provider) const -{ - provider = provider.trimmed(); - if (provider == QLatin1String("local-file")) - { - return tr("Local File"); - } - if (provider == QLatin1String("dandanplay")) - { - return tr("DandanPlay"); - } - return provider.isEmpty() ? tr("Unknown Source") : provider; -} - -QString PlayerView::formatDanmakuSourceServiceLabel(QString provider, QString serverName) const -{ - provider = provider.trimmed(); - serverName = serverName.trimmed(); - if (!serverName.isEmpty()) - { - return tr("Server: %1").arg(serverName); - } - return formatDanmakuProviderLabel(provider); -} - -QString PlayerView::buildDanmakuSummaryText() const -{ - if (!m_danmakuController) - { - return tr("No danmaku loaded"); - } - - if (m_danmakuController->isLoading()) - { - return tr("Loading danmaku..."); - } - - if (!m_danmakuController->hasPreparedDanmaku()) - { - return tr("No danmaku loaded"); - } - - const QString sourceTitle = m_danmakuController->sourceTitle().trimmed().isEmpty() - ? tr("Unknown Danmaku") - : m_danmakuController->sourceTitle().trimmed(); - const QString countText = m_danmakuController->commentCount() > 0 - ? tr("%1 comments").arg(m_danmakuController->commentCount()) - : tr("Comment count unavailable"); - const QString sourceService = - formatDanmakuSourceServiceLabel(m_danmakuController->sourceProvider(), m_danmakuController->sourceServerName()); - return tr("Current: %1 | %2 | %3").arg(sourceTitle, countText, sourceService); -} - -QString PlayerView::buildDanmakuTooltipText() const -{ - if (!m_danmakuController) - { - return tr("Danmaku"); - } - - if (m_danmakuController->isLoading()) - { - return tr("Danmaku (Loading...)"); - } - - if (!m_danmakuController->isDanmakuVisible()) - { - return tr("Danmaku (Hidden)"); - } - - if (!m_danmakuController->hasPreparedDanmaku()) - { - return tr("Danmaku"); - } - - const QString sourceTitle = m_danmakuController->sourceTitle().trimmed().isEmpty() - ? tr("Unknown Danmaku") - : m_danmakuController->sourceTitle().trimmed(); - const QString countText = m_danmakuController->commentCount() > 0 - ? tr("%1 comments").arg(m_danmakuController->commentCount()) - : tr("Comment count unavailable"); - const QString serverName = m_danmakuController->sourceServerName().trimmed(); - if (!serverName.isEmpty()) - { - return tr("Danmaku\nSource: %1\nServer: %2\nProvider: %3\nComments: %4") - .arg(sourceTitle, serverName, formatDanmakuProviderLabel(m_danmakuController->sourceProvider()), countText); - } - return tr("Danmaku\nSource: %1\nProvider: %2\nComments: %3") - .arg(sourceTitle, formatDanmakuProviderLabel(m_danmakuController->sourceProvider()), countText); -} - -void PlayerView::closeActivePlayerDialog() -{ - if (m_activePlayerDialog) - { - m_activePlayerDialog->close(); - m_activePlayerDialog = nullptr; - } -} - -void PlayerView::trackPlayerDialog(PlayerOverlayDialog *dialog) -{ - if (!dialog) - { - return; - } - - closeActivePlayerDialog(); - m_activePlayerDialog = dialog; - - connect(dialog, &PlayerOverlayDialog::finished, this, - [this, dialog](int) - { - if (m_activePlayerDialog == dialog) - { - m_activePlayerDialog = nullptr; - if (!m_isViewTearingDown) - { - showControls(); - } - } - }); - - showControls(); - dialog->open(); -} - -bool PlayerView::shouldShowDanmakuHudControls() const -{ - if (m_danmakuController) - { - return m_danmakuController->isDanmakuEnabled(); - } - - return ConfigStore::instance()->get(ConfigKeys::PlayerDanmakuEnabled, false); -} - -void PlayerView::updateDanmakuButtonState() -{ - if (m_isViewTearingDown || !m_danmakuBtn) - { - return; - } - - const bool showDanmakuControls = shouldShowDanmakuHudControls(); - m_danmakuBtn->setVisible(showDanmakuControls); - if (!showDanmakuControls || !m_danmakuController) - { - return; - } - - const bool visible = m_danmakuController->isDanmakuVisible(); - const bool loading = m_danmakuController->isLoading(); - const bool loaded = m_danmakuController->hasDanmakuTrack() || m_danmakuController->hasPreparedDanmaku(); - - - - - - m_danmakuBtn->setEnabled(true); - if (loading) - { - m_danmakuBtn->setToolTip(tr("Danmaku (Loading...)")); - } - else if (!visible) - { - m_danmakuBtn->setToolTip(tr("Danmaku (Hidden)")); - } - else if (loaded && !m_danmakuController->sourceTitle().isEmpty()) - { - m_danmakuBtn->setToolTip(buildDanmakuTooltipText()); - } - else if (loaded) - { - m_danmakuBtn->setToolTip(buildDanmakuTooltipText()); - } - else - { - m_danmakuBtn->setToolTip(tr("Danmaku")); - } -} - - -void PlayerView::showCenteredPopup(QWidget *popup, QPushButton *btn) -{ - if (m_isViewTearingDown) - { - if (popup) - { - popup->deleteLater(); - } - return; - } - - hideHudMediaSwitcher(); - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - m_activePopup = popup; - connect(popup, &QObject::destroyed, this, - [this, popup]() - { - if (m_activePopup == popup) - { - m_activePopup = nullptr; - } - }); - popup->adjustSize(); - - QPoint btnPos = btn->mapTo(this, QPoint(btn->width() / 2, 0)); - int mx = btnPos.x() - popup->sizeHint().width() / 2; - int my = btnPos.y() - popup->sizeHint().height() - 10; - - - if (mx < 10) - { - mx = 10; - } - const int maxX = width() - popup->sizeHint().width() - 10; - if (mx > maxX) - { - mx = qMax(10, maxX); - } - if (my < 10) - { - my = 10; - } - - popup->move(mx, my); - popup->show(); - popup->raise(); - - showControls(); -} - -void PlayerView::populateRightSidebarFromCache() -{ - if (!m_resumeList || !m_sidebarTitleLabel) - { - return; - } - - m_resumeList->clear(); - - if (m_isSeriesMode && !m_seriesId.isEmpty()) - { - m_sidebarTitleLabel->setText(m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName); - - QListWidgetItem *scrollTarget = nullptr; - for (const MediaItem &season : m_switcherSeriesSeasons) - { - auto *seasonHeader = new QListWidgetItem(season.name); - seasonHeader->setFlags(Qt::NoItemFlags); - QFont headerFont = m_resumeList->font(); - headerFont.setBold(true); - headerFont.setPixelSize(13); - seasonHeader->setFont(headerFont); - seasonHeader->setForeground(QColor(140, 140, 140)); - m_resumeList->addItem(seasonHeader); - - const auto episodes = m_switcherSeasonEpisodes.value(season.id); - for (const MediaItem &episode : episodes) - { - QString label = tr(" S%1E%2 %3") - .arg(episode.parentIndexNumber, 2, 10, QChar('0')) - .arg(episode.indexNumber, 2, 10, QChar('0')) - .arg(episode.name); - auto *epItem = new QListWidgetItem(label); - epItem->setData(Qt::UserRole, episode.id); - epItem->setData(Qt::UserRole + 1, episode.userData.playbackPositionTicks); - epItem->setData(Qt::UserRole + 2, formatMediaSwitcherPlaybackTitle(episode)); - epItem->setToolTip(formatMediaSwitcherPlaybackTitle(episode)); - - if (episode.id == m_currentMediaId) - { - QFont boldFont = m_resumeList->font(); - boldFont.setBold(true); - epItem->setFont(boldFont); - epItem->setForeground(QColor(100, 180, 255)); - scrollTarget = epItem; - } - - m_resumeList->addItem(epItem); - } - } - - if (m_resumeList->count() == 0) - { - auto *empty = new QListWidgetItem(tr("No episodes found.")); - empty->setFlags(Qt::NoItemFlags); - m_resumeList->addItem(empty); - return; - } - - if (scrollTarget) - { - m_resumeList->setCurrentItem(scrollTarget); - m_resumeList->scrollToItem(scrollTarget, QAbstractItemView::PositionAtCenter); - } - return; - } - - m_sidebarTitleLabel->setText(tr("Continue Watching")); - for (const MediaItem &item : m_switcherResumeItems) - { - const QString displayTitle = formatMediaSwitcherPlaybackTitle(item); - auto *listItem = new QListWidgetItem(displayTitle); - listItem->setData(Qt::UserRole, item.id); - listItem->setData(Qt::UserRole + 1, item.userData.playbackPositionTicks); - listItem->setData(Qt::UserRole + 2, displayTitle); - listItem->setToolTip(displayTitle); - m_resumeList->addItem(listItem); - } - - if (m_switcherResumeItems.isEmpty()) - { - auto *empty = new QListWidgetItem(tr("No active media found.")); - empty->setFlags(Qt::NoItemFlags); - m_resumeList->addItem(empty); - } -} - -QCoro::Task PlayerView::ensureMediaSwitcherDataLoaded() -{ - if (m_currentMediaId.isEmpty()) - { - co_return; - } - - if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId) - { - qDebug().noquote() << "[PlayerView] Reuse cached media switcher data" - << "| mediaId:" << m_currentMediaId << "| seriesMode:" << m_isSeriesMode; - if (useHudMediaSwitcher() && m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible()) - { - syncHudMediaSwitcherContent(); - } - if (!useHudMediaSwitcher() && m_isRightSidebarVisible) - { - populateRightSidebarFromCache(); - } - co_return; - } - - QPointer guard(this); - const QString currentMediaId = m_currentMediaId; - const bool seriesMode = m_isSeriesMode && !m_seriesId.isEmpty(); - const QString seriesId = m_seriesId; - - clearMediaSwitcherCache(); - - qDebug().noquote() << "[PlayerView] Loading media switcher data" - << "| mediaId:" << currentMediaId << "| seriesMode:" << seriesMode << "| seriesId:" << seriesId; - - try - { - if (seriesMode) - { - const QList seasons = co_await m_core->mediaService()->getSeasons(seriesId); - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->m_switcherSeriesSeasons = seasons; - for (const MediaItem &season : seasons) - { - const QList episodes = co_await m_core->mediaService()->getEpisodes(seriesId, season.id); - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->m_switcherSeasonEpisodes.insert(season.id, episodes); - } - - try - { - const QList items = co_await m_core->mediaService()->getResumeItems(30); - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->m_switcherResumeItems = items; - } - catch (const std::exception &e) - { - qDebug() << "[PlayerView] Resume media fallback fetch failed:" << e.what(); - } - } - else - { - const QList items = co_await m_core->mediaService()->getResumeItems(30); - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->m_switcherResumeItems = items; - } - - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->m_switcherCacheMediaId = currentMediaId; - guard->m_switcherCacheReady = true; - qDebug().noquote() << "[PlayerView] Media switcher data ready" - << "| mediaId:" << currentMediaId << "| resumeItems:" << guard->m_switcherResumeItems.size() - << "| seasons:" << guard->m_switcherSeriesSeasons.size(); - if (guard->useHudMediaSwitcher() && guard->m_mediaSwitchDrawer && guard->m_mediaSwitchDrawer->isVisible()) - { - guard->syncHudMediaSwitcherContent(); - } - if (!guard->useHudMediaSwitcher() && guard->m_isRightSidebarVisible) - { - guard->populateRightSidebarFromCache(); - } - } - catch (const std::exception &e) - { - if (!guard || guard->m_currentMediaId != currentMediaId) - { - co_return; - } - - guard->clearMediaSwitcherCache(); - qDebug() << "[PlayerView] Media switcher data fetch failed:" << e.what(); - if (guard->useHudMediaSwitcher() && guard->m_mediaSwitchDrawer && guard->m_mediaSwitchDrawer->isVisible()) - { - guard->syncHudMediaSwitcherContent(); - } - if (!guard->useHudMediaSwitcher() && guard->m_isRightSidebarVisible) - { - guard->populateRightSidebarFromCache(); - } - } -} - -QCoro::Task PlayerView::switchFromMediaSwitcher(QString mediaId, QString title, long long startPositionTicks) -{ - QPointer guard(this); - if (mediaId.isEmpty()) - { - co_return; - } - - if (mediaId == m_currentMediaId) - { - hideRightSidebar(); - hideHudMediaSwitcher(); - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - co_return; - } - - m_switcherPendingItemId = mediaId; - m_switcherPendingTitle = title; - m_switcherPendingTicks = startPositionTicks; - - try - { - MediaItem detail = co_await m_core->mediaService()->getItemDetail(mediaId); - - if (!guard) - { - co_return; - } - if (m_switcherPendingItemId.isEmpty() || detail.id != m_switcherPendingItemId) - { - co_return; - } - - if (detail.mediaSources.isEmpty()) - { - PlaybackInfo playbackInfo = co_await m_core->mediaService()->getPlaybackInfo(detail.id); - - if (!guard) - { - co_return; - } - if (m_switcherPendingItemId.isEmpty() || detail.id != m_switcherPendingItemId) - { - co_return; - } - - detail.mediaSources = playbackInfo.mediaSources; - } - - MediaSourceInfo selectedSource; - QString mediaSourceId = detail.id; - if (!detail.mediaSources.isEmpty()) - { - int sourceIdx = MediaSourcePreferenceUtils::resolvePreferredMediaSourceIndex( - detail.mediaSources, - ConfigStore::instance()->get(ConfigKeys::PlayerPreferredVersion).trimmed()); - if (sourceIdx < 0 || sourceIdx >= detail.mediaSources.size()) - { - sourceIdx = 0; - } - - selectedSource = detail.mediaSources.at(sourceIdx); - PlayerPreferenceUtils::applyPreferredStreamRules( - selectedSource, ConfigStore::instance()->get(ConfigKeys::PlayerAudioLang, "auto"), - ConfigStore::instance()->get(ConfigKeys::PlayerSubLang, "auto")); - mediaSourceId = selectedSource.id; - } - - QString streamUrl = selectedSource.id.isEmpty() - ? m_core->mediaService()->getStreamUrl(detail.id, mediaSourceId) - : m_core->mediaService()->getStreamUrl(detail.id, selectedSource); - const QString titleFallback = detail.seriesId == m_seriesId ? m_seriesName : QString(); - QString resolvedTitle = MediaItemUtils::playbackTitle(detail, titleFallback); - if (resolvedTitle.isEmpty()) - { - resolvedTitle = m_switcherPendingTitle.trimmed(); - } - - qDebug().noquote() << "[PlayerView] Switching playback item" - << "| mediaId:" << detail.id << "| sourceId:" << mediaSourceId << "| title:" << resolvedTitle - << "| startTicks:" << m_switcherPendingTicks; - - hideRightSidebar(); - hideHudMediaSwitcher(); - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - stopAndReport(); - m_toastLabel->hide(); - - PlayerLaunchContext launchContext; - launchContext.mediaItem = detail; - launchContext.selectedSource = selectedSource; - const QVariant sourceInfoVar = QVariant::fromValue(launchContext); - - QTimer::singleShot(250, this, - [this, id = detail.id, resolvedTitle, streamUrl, startTicks = m_switcherPendingTicks, - sourceInfoVar]() { playMedia(id, resolvedTitle, streamUrl, startTicks, sourceInfoVar); }); - - m_switcherPendingItemId.clear(); - m_switcherPendingTitle.clear(); - m_switcherPendingTicks = 0; - } - catch (const std::exception &e) - { - if (!guard) - { - co_return; - } - qDebug() << "[PlayerView] Media switch failed:" << e.what(); - m_switcherPendingItemId.clear(); - m_switcherPendingTitle.clear(); - m_switcherPendingTicks = 0; - } -} - -void PlayerView::setupRightSidebar() -{ - m_rightSidebar = new QWidget(this); - m_rightSidebar->setObjectName("playerRightSidebar"); - - m_rightSidebar->setFixedWidth(220); - m_rightSidebar->setGeometry(20000, 0, 220, 100); - - // Drop shadow via a pre-baked 9-patch overlay instead of - // QGraphicsDropShadowEffect, which would re-run a software Gaussian blur on - // every frame of the 350ms slide. Body cutout is on because the sidebar - // background is semi-transparent (the shadow must not bleed through it). - // The overlay tracks the sidebar's geometry in syncRightSidebarShadow(). - m_rightSidebarShadow = new ShadowOverlay(this); - m_rightSidebarShadow->setShadow(35, 0, QColor(0, 0, 0, 150)); - m_rightSidebarShadow->setBodyCutout(true); - m_rightSidebarShadow->hide(); - - auto *layout = new QVBoxLayout(m_rightSidebar); - layout->setContentsMargins(0, 15, 0, 15); - layout->setSpacing(10); - - m_sidebarTitleLabel = new QLabel(tr("Continue Watching"), m_rightSidebar); - m_sidebarTitleLabel->setObjectName("playerSidebarTitle"); - layout->addWidget(m_sidebarTitleLabel); - - m_resumeList = new QListWidget(m_rightSidebar); - m_resumeList->setObjectName("playerResumeList"); - m_resumeList->setFocusPolicy(Qt::NoFocus); - m_resumeList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_resumeList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - m_resumeList->setTextElideMode(Qt::ElideRight); - layout->addWidget(m_resumeList); - - connect(m_resumeList, &QListWidget::itemDoubleClicked, this, - [this](QListWidgetItem *item) -> QCoro::Task - { - if (!item || item->flags() == Qt::NoItemFlags) - { - co_return; - } - - co_await switchFromMediaSwitcher(item->data(Qt::UserRole).toString(), - item->data(Qt::UserRole + 2).toString(), - item->data(Qt::UserRole + 1).toLongLong()); - }); - - m_rightSidebarAnim = new QPropertyAnimation(m_rightSidebar, "pos", this); - m_rightSidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow); - m_rightSidebarAnim->setEasingCurve(AnimationTokens::Curve::enter()); - connect(m_rightSidebarAnim, &QPropertyAnimation::finished, this, - [this]() - { - if (!m_rightSidebar || m_isRightSidebarVisible) - { - return; - } - m_rightSidebar->hide(); - }); - - m_rightSidebar->installEventFilter(this); - m_rightSidebar->hide(); -} - -QCoro::Task PlayerView::showRightSidebar() -{ - if (m_isViewTearingDown || useHudMediaSwitcher() || m_hasReportedStop) - { - co_return; - } - - m_isRightSidebarVisible = true; - const int sidebarY = m_topHUD->height(); - const QPoint hiddenPos(width() + 30, sidebarY); - - if (!m_rightSidebar || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject() || - MotionPrefs::reduceMotion()) - { - if (m_rightSidebar) - { - m_rightSidebar->move(width() - m_rightSidebar->width(), sidebarY); - m_rightSidebar->show(); - } - } - else - { - if (m_rightSidebarAnim->state() == QAbstractAnimation::Running) - { - stopPropertyAnimationSafely(m_rightSidebarAnim); - } - if (!m_rightSidebar->isVisible()) - { - m_rightSidebar->move(hiddenPos); - } - m_rightSidebar->show(); - m_rightSidebarAnim->setStartValue(m_rightSidebar->pos()); - m_rightSidebarAnim->setEndValue(QPoint(width() - m_rightSidebar->width(), sidebarY)); - m_rightSidebarAnim->start(); - } - - showControls(); - - if (m_switcherCacheReady && m_switcherCacheMediaId == m_currentMediaId) - { - populateRightSidebarFromCache(); - co_return; - } - - m_sidebarTitleLabel->setText(m_isSeriesMode ? (m_seriesName.isEmpty() ? tr("Episodes") : m_seriesName) - : tr("Continue Watching")); - m_resumeList->clear(); - auto *loadingItem = new QListWidgetItem(tr("Loading...")); - loadingItem->setFlags(Qt::NoItemFlags); - loadingItem->setForeground(QColor(140, 140, 140)); - m_resumeList->addItem(loadingItem); - - co_await ensureMediaSwitcherDataLoaded(); - if (!m_switcherCacheReady || m_switcherCacheMediaId != m_currentMediaId) - { - co_return; - } - - populateRightSidebarFromCache(); -} - -void PlayerView::hideRightSidebar(bool immediate) -{ - immediate = immediate || m_isViewTearingDown; - m_isRightSidebarVisible = false; - if (!m_rightSidebar) - { - return; - } - - const int sidebarY = m_topHUD ? m_topHUD->height() : 0; - const QPoint hiddenPos(width() + 30, sidebarY); - - if (immediate || m_hasReportedStop || !m_rightSidebarAnim || !m_rightSidebarAnim->targetObject() || - MotionPrefs::reduceMotion()) - { - if (m_rightSidebarAnim) - { - stopPropertyAnimationSafely(m_rightSidebarAnim); - } - m_rightSidebar->move(hiddenPos); - m_rightSidebar->hide(); - return; - } - - if (!m_rightSidebar->isVisible()) - { - m_rightSidebar->move(hiddenPos); - return; - } - - if (m_rightSidebarAnim->state() == QAbstractAnimation::Running) - { - stopPropertyAnimationSafely(m_rightSidebarAnim); - } - m_rightSidebarAnim->setStartValue(m_rightSidebar->pos()); - m_rightSidebarAnim->setEndValue(hiddenPos); - m_rightSidebarAnim->start(); -} - -void PlayerView::syncRightSidebarShadow() -{ - if (!m_rightSidebarShadow || !m_rightSidebar) - { - return; - } - - if (!m_rightSidebar->isVisible()) - { - m_rightSidebarShadow->hide(); - return; - } - - const int reach = m_rightSidebarShadow->reach(); - m_rightSidebarShadow->setGeometry( - m_rightSidebar->geometry().adjusted(-reach, -reach, reach, reach)); - m_rightSidebarShadow->stackUnder(m_rightSidebar); - m_rightSidebarShadow->show(); -} - - - -void PlayerView::stopAndReport() -{ - - if (m_hasReportedStop) - { - return; - } - m_hasReportedStop = true; - updatePowerInhibition(); - m_longPressHandler->setTeardown(true); - stopTransientUiAnimations(m_isViewTearingDown); - - - if (m_reportTimer) - m_reportTimer->stop(); - m_longPressHandler->stopKeyLongPress(false); - m_longPressHandler->stopMouseEdgeLongPress(); - if (m_bufferTimer) - m_bufferTimer->stop(); - if (m_hideTimer) - m_hideTimer->stop(); - if (m_osdLayer) - m_osdLayer->forceHide(); - if (m_toastTimer) - m_toastTimer->stop(); - if (m_singleClickTimer) - m_singleClickTimer->stop(); - - hideRightSidebar(true); - hideHudMediaSwitcher(); - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - if (!m_currentMediaId.isEmpty() && m_core && m_core->mediaService()) - { - long long currentTicks = static_cast(m_currentPosition * 10000000.0); - - auto *service = m_core->mediaService(); - QString mediaId = m_currentMediaId; - QString sourceId = m_currentMediaSourceId; - - QString sessionId = m_currentPlaySessionId; - m_currentPlaySessionId.clear(); - - - - auto taskRoutine = [](MediaService *s, QString mId, QString sId, long long ticks, - QString sessId) -> QCoro::Task - { - try - { - co_await s->reportPlaybackProgress(mId, sId, ticks, true, sessId); - } - catch (const std::exception &e) - { - qDebug() << "Progress sync failed (ignored):" << e.what(); - } - try - { - co_await s->reportPlaybackStopped(mId, sId, ticks, sessId); - } - catch (const std::exception &e) - { - qDebug() << "Stop sync failed:" << e.what(); - } - }; - - - - auto *lingeringTask = new QCoro::Task(taskRoutine(service, mediaId, sourceId, currentTicks, sessionId)); - - - - QTimer::singleShot(10000, m_core, [lingeringTask]() { delete lingeringTask; }); - } - - if (m_mpvWidget) - { - if (m_danmakuController) - { - m_danmakuController->clearPlaybackContext(); - } - - disconnect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged); - m_mpvWidget->controller()->command(QVariantList{"stop"}); - } -} - -void PlayerView::beginViewTeardown() -{ - if (m_isViewTearingDown) - { - return; - } - - m_isViewTearingDown = true; - updatePowerInhibition(); - setPlayerChromeVisible(false); - qDebug() << "[PlayerView] Begin teardown: stop timers, disconnect late signals, detach animations"; - - if (m_reportTimer) - m_reportTimer->stop(); - if (m_bufferTimer) - m_bufferTimer->stop(); - if (m_hideTimer) - m_hideTimer->stop(); - if (m_toastTimer) - m_toastTimer->stop(); - if (m_singleClickTimer) - m_singleClickTimer->stop(); - - if (m_longPressHandler) - { - m_longPressHandler->setTeardown(true); - m_longPressHandler->stopKeyLongPress(false); - m_longPressHandler->stopMouseEdgeLongPress(); - } - - disconnect(ConfigStore::instance(), nullptr, this, nullptr); - if (m_danmakuController) - { - disconnect(m_danmakuController, nullptr, this, nullptr); - } - if (m_mpvWidget) - { - disconnect(m_mpvWidget, nullptr, this, nullptr); - if (m_mpvWidget->controller()) - { - disconnect(m_mpvWidget->controller(), nullptr, this, nullptr); - } - } - - stopTransientUiAnimations(true); - detachFadeGroupTargets(m_fadeGroup); - detachPropertyAnimationTarget(m_rightSidebarAnim); - - m_isRightSidebarVisible = false; - if (m_mediaSwitchDrawer) - { - m_mediaSwitchDrawer->hide(); - } - if (m_topHUD) - { - m_topHUD->hide(); - } - if (m_bottomHUD) - { - m_bottomHUD->hide(); - } - if (m_rightTrigger) - { - m_rightTrigger->hide(); - } - if (m_rightSidebar) - { - m_rightSidebar->hide(); - } - if (m_logoLabel) - { - m_logoLabel->hide(); - } - if (m_networkSpeedLabel) - { - m_networkSpeedLabel->hide(); - } - if (m_statisticsOverlay) - { - m_statisticsOverlay->hide(); - } - if (m_toastLabel) - { - m_toastLabel->hide(); - } - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - setCursorHidden(false); -} - -void PlayerView::keyPressEvent(QKeyEvent *event) -{ - if (m_isViewTearingDown) - { - event->ignore(); - return; - } - - bool isHudVisible = (m_topOpacity->opacity() > 0.0); - - if (event->key() == Qt::Key_Escape) - { - if (window()->isFullScreen()) - { - window()->showNormal(); - } - showControls(); - event->accept(); - } - else if (event->key() == Qt::Key_Space) - { - if (!event->isAutoRepeat()) - { - togglePlayPause(); - } - showControls(); - event->accept(); - } - else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) - { - if (!event->isAutoRepeat()) - { - toggleFullscreenWindow(); - } - showControls(); - event->accept(); - } - else if (event->key() == Qt::Key_Left) - { - if (!event->isAutoRepeat()) - { - m_longPressHandler->startKeyLongPress(-1, !isHudVisible); - } - event->accept(); - } - else if (event->key() == Qt::Key_Right) - { - if (!event->isAutoRepeat()) - { - m_longPressHandler->startKeyLongPress(1, !isHudVisible); - } - event->accept(); - } - else if (event->key() == Qt::Key_Up) - { - changeVolume(5, !isHudVisible); - event->accept(); - } - else if (event->key() == Qt::Key_Down) - { - changeVolume(-5, !isHudVisible); - event->accept(); - } - else if (event->key() == Qt::Key_PageUp) - { - if (!event->isAutoRepeat() && m_prevMediaBtn) - { - m_prevMediaBtn->click(); - } - event->accept(); - } - else if (event->key() == Qt::Key_PageDown) - { - if (!event->isAutoRepeat() && m_nextMediaBtn) - { - m_nextMediaBtn->click(); - } - event->accept(); - } - else - { - showControls(); - BaseView::keyPressEvent(event); - } -} - -void PlayerView::keyReleaseEvent(QKeyEvent *event) -{ - if (m_isViewTearingDown) - { - event->ignore(); - return; - } - - if (event->key() == Qt::Key_Left || event->key() == Qt::Key_Right) - { - if (!event->isAutoRepeat()) - { - m_longPressHandler->stopKeyLongPress(); - } - event->accept(); - } - else - { - BaseView::keyReleaseEvent(event); - } -} - - -void PlayerView::setEffectivePlaybackSpeed(double speed) -{ - if (!m_mpvWidget || !m_mpvWidget->controller()) - { - return; - } - - m_currentSpeed = speed; - m_mpvWidget->controller()->setProperty("speed", speed); - if (m_speedBtn) - { - m_speedBtn->setText(PlayerLongPressHandler::formatSpeedText(speed)); - } -} - -void PlayerView::stopTransientUiAnimations(bool immediate) -{ - if (m_longPressHandler) - { - m_longPressHandler->stopAllAnimations(); - } - - if (m_loadingOverlay) - { - if (immediate) - { - m_loadingOverlay->forceStop(); - } - else - { - m_loadingOverlay->stop(); - } - } - if (m_rightSidebarAnim) - { - stopPropertyAnimationSafely(m_rightSidebarAnim); - } - if (m_osdLayer) - { - m_osdLayer->stopAnimations(); - } - if (m_fadeGroup) - { - stopFadeGroupSafely(m_fadeGroup); - } -} - -void PlayerView::updateOverlayLayout() -{ - if (m_isViewTearingDown) - { - return; - } - - if (!m_mpvWidget || !m_topHUD || !m_bottomHUD) - { - return; - } - - const int drawerHeight = (m_mediaSwitchDrawer && m_mediaSwitchDrawer->isVisible() && useHudMediaSwitcher()) - ? m_mediaSwitchDrawer->preferredDrawerHeight() - : 0; - const int targetBottomHudHeight = m_bottomHudBaseHeight + (drawerHeight > 0 ? drawerHeight + 8 : 0); - - m_mpvWidget->setGeometry(0, 0, width(), height()); - if (m_nativeDanmakuOverlay) - { - m_nativeDanmakuOverlay->setGeometry(0, 0, width(), height()); - } - m_loadingOverlay->setGeometry(0, 0, width(), height()); - m_topHUD->setGeometry(0, 0, width(), m_topHUD->height()); - m_bottomHUD->setFixedHeight(targetBottomHudHeight); - m_bottomHUD->setGeometry(0, height() - m_bottomHUD->height(), width(), m_bottomHUD->height()); - - if (m_mediaSwitchDrawer) - { - m_mediaSwitchDrawer->setVisible(drawerHeight > 0 && useHudMediaSwitcher()); - } - - if (m_logoLabel) - { - m_logoLabel->move(24, m_topHUD->height()); - } - - if (m_networkSpeedLabel) - { - m_networkSpeedLabel->setGeometry(width() - 210, m_topHUD->height(), 200, 30); - } - - if (m_statisticsOverlay) - { - int statisticsY = m_topHUD->height() + 10; - if (m_logoLabel && m_logoLabel->isVisible()) - { - statisticsY = qMax(statisticsY, m_logoLabel->y() + m_logoLabel->height() + 8); - } - - const int statisticsWidth = qMax(280, qMin(width() - 24, 620)); - m_statisticsOverlay->setFixedWidth(statisticsWidth); - m_statisticsOverlay->setFixedHeight(m_statisticsOverlay->sizeHint().height()); - m_statisticsOverlay->move(12, statisticsY); - } - - const int sidebarY = m_topHUD->height(); - const int sidebarH = height() - m_topHUD->height() - m_bottomHUD->height(); - - m_rightTrigger->setGeometry(width() - 15, sidebarY, 15, sidebarH); - - if (!m_isRightSidebarVisible) - { - m_rightSidebar->setGeometry(width() + 30, sidebarY, m_rightSidebar->width(), sidebarH); - } - else - { - m_rightSidebar->setGeometry(width() - m_rightSidebar->width(), sidebarY, m_rightSidebar->width(), sidebarH); - } - - if (m_osdLayer) - { - m_osdLayer->updateGeometry(width(), height()); - } - - if (m_longPressHandler) - { - m_longPressHandler->updateGeometry(width(), height(), m_topHUD ? m_topHUD->height() : 0); - } - - if (m_activePopup) - { - m_activePopup->raise(); - } - - updateTitleElision(); -} - -void PlayerView::resizeEvent(QResizeEvent *event) -{ - BaseView::resizeEvent(event); - - if (m_isViewTearingDown) - { - return; - } - - - - - - QTimer::singleShot( - 0, this, - [this]() - { - if (window() && m_maxBtn) - { - bool isMax = window()->isMaximized(); - if (!m_maxBtn->property("isMax").isValid() || m_maxBtn->property("isMax").toBool() != isMax) - { - m_maxBtn->setIcon(QIcon(isMax ? ":/svg/player/restore.svg" : ":/svg/player/max.svg")); - m_maxBtn->setProperty("isMax", isMax); - } - } - }); - updateOverlayLayout(); -} - - -void PlayerView::updateTitleElision() -{ - if (m_fullTitle.isEmpty() || !m_titleLabel) - { - return; - } - - int safeWidth = m_titleLabel->contentsRect().width(); - if (safeWidth <= 0) - { - safeWidth = this->width() - 320; - } - if (safeWidth < 50) - { - safeWidth = 50; - } - - - QFontMetrics fm(m_titleLabel->font()); - QString elided = fm.elidedText(m_fullTitle, Qt::ElideRight, safeWidth); - - m_titleLabel->setText(elided); - m_titleLabel->setToolTip(m_fullTitle); -} - -bool PlayerView::eventFilter(QObject *watched, QEvent *event) -{ - if (m_isViewTearingDown) - { - return BaseView::eventFilter(watched, event); - } - - // Keep the drop-shadow overlay glued to the sidebar. The slide animation - // and layout both move/resize it, landing here as Move/Resize events, so the - // shadow follows every frame with no per-frame blur. - if (watched == m_rightSidebar && - (event->type() == QEvent::Move || event->type() == QEvent::Resize || - event->type() == QEvent::Show || event->type() == QEvent::Hide)) - { - syncRightSidebarShadow(); - } - - if (watched == m_mpvWidget || watched == this || watched == m_topHUD || watched == m_titleLabel || - watched == m_bottomHUD) - { - - - if (m_activePlayerDialog && m_activePlayerDialog->isVisible()) - { - const auto type = event->type(); - if (type == QEvent::MouseButtonPress || type == QEvent::MouseButtonRelease || - type == QEvent::MouseButtonDblClick || type == QEvent::MouseMove || type == QEvent::Wheel) - { - return true; - } - } - - if (event->type() == QEvent::MouseButtonPress) - { - auto *me = static_cast(event); - const QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint()); - const bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos); - - - - - if (me->button() == Qt::BackButton || me->button() == Qt::XButton1) - { - onBackClicked(); - return true; - } - - - if (m_activePopup) - { - QPoint localPos = this->mapFromGlobal(me->globalPosition().toPoint()); - if (!m_activePopup->geometry().contains(localPos)) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - return true; - } - } - - showControls(); - if (me->button() == Qt::LeftButton && watched != m_bottomHUD) - { - m_singleClickTimer->stop(); - - if (!isOnHud) - { - m_longPressHandler->startMouseEdgeLongPress(clickPos.x() < width() / 2 ? -1 : 1); - } - - - m_dragPos = me->globalPosition().toPoint(); - m_didDrag = false; - } - } - else if (event->type() == QEvent::MouseMove) - { - auto *me = static_cast(event); - handlePointerActivity(me->globalPosition().toPoint()); - const QPoint localPos = this->mapFromGlobal(me->globalPosition().toPoint()); - const bool isOnHud = m_topHUD->geometry().contains(localPos) || m_bottomHUD->geometry().contains(localPos); - const bool movedEnough = - (me->globalPosition().toPoint() - m_dragPos).manhattanLength() > QApplication::startDragDistance(); - - if (m_longPressHandler->mouseEdgeLongPressDirection() != 0 && (movedEnough || isOnHud)) - { - m_longPressHandler->stopMouseEdgeLongPress(); - } - - if ((me->buttons() & Qt::LeftButton) && watched != m_bottomHUD) - { - - - if (!window()->isFullScreen()) - { - - if (movedEnough) - { - m_didDrag = true; - if (window() && window()->windowHandle()) - { - - window()->windowHandle()->startSystemMove(); - } - } - } - return true; - } - } - else if (event->type() == QEvent::MouseButtonRelease) - { - auto *me = static_cast(event); - const bool consumedByMouseEdge = - me->button() == Qt::LeftButton && m_longPressHandler->stopMouseEdgeLongPress(); - if (consumedByMouseEdge) - { - m_singleClickTimer->stop(); - m_didDrag = true; - return true; - } - - - QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint()); - bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos); - if (me->button() == Qt::LeftButton && !isOnHud && !m_didDrag && - ConfigStore::instance()->get(ConfigKeys::PlayerClickToPause, true)) - { - m_singleClickTimer->start(); - } - } - else if (event->type() == QEvent::MouseButtonDblClick) - { - auto *me = static_cast(event); - if (me->button() == Qt::LeftButton) - { - m_longPressHandler->stopMouseEdgeLongPress(); - } - QPoint clickPos = this->mapFromGlobal(me->globalPosition().toPoint()); - bool isOnHud = m_topHUD->geometry().contains(clickPos) || m_bottomHUD->geometry().contains(clickPos); - if (me->button() == Qt::LeftButton && !isOnHud) - { - m_singleClickTimer->stop(); - m_didDrag = true; - toggleFullscreenWindow(); - return true; - } - } - else if (event->type() == QEvent::Enter) - { - handlePointerActivity(QCursor::pos()); - } - } - - - if (watched == m_rightTrigger && event->type() == QEvent::Enter) - { - showRightSidebar(); - return true; - } - - if (watched == m_rightSidebar && event->type() == QEvent::Leave) - { - QPoint globalPos = QCursor::pos(); - QPoint localPos = m_rightSidebar->mapFromGlobal(globalPos); - if (!m_rightSidebar->rect().contains(localPos)) - { - hideRightSidebar(); - } - return false; - } - - if (event->type() == QEvent::Wheel) - { - - if (m_activePlayerDialog && m_activePlayerDialog->isVisible()) - { - return true; - } - if (watched == m_volumeBtn || watched == m_volumeSlider || watched == m_bottomHUD || watched == m_mpvWidget || - watched == this) - { - showControls(); - auto *we = static_cast(event); - changeVolume(we->angleDelta().y() > 0 ? 5 : -5); - return true; - } - } - - return BaseView::eventFilter(watched, event); -} - -void PlayerView::handlePointerActivity(const QPoint &globalPos) -{ - if (m_isViewTearingDown || m_hasReportedStop) - { - return; - } - - const QPoint localPos = mapFromGlobal(globalPos); - if (!rect().contains(localPos)) - { - return; - } - - setCursorHidden(false); - - if (m_osdLayer && m_osdLayer->isVisible()) - { - m_osdLayer->forceHide(); - } - - if (m_isPlaying) - { - m_hideTimer->start(kHudAutoHideDelayMs); - } - else - { - m_hideTimer->stop(); - } - - if (areControlsFullyVisible()) - { - return; - } - - showControls(); -} - -void PlayerView::updateBufferProgress() -{ - if (!m_progressSlider || m_totalDuration <= 0) - { - return; - } - - double bufferedPos = m_bufferedPosition; - if (std::isnan(bufferedPos) || std::isinf(bufferedPos) || bufferedPos <= 0.0) - { - bufferedPos = m_currentPosition; - } - if (bufferedPos < m_currentPosition) - { - bufferedPos = m_currentPosition; - } - if (bufferedPos > m_totalDuration) - { - bufferedPos = m_totalDuration; - } - - m_progressSlider->setBufferValue(static_cast(bufferedPos)); -} - -void PlayerView::setCursorHidden(bool hidden) -{ - const auto targetShape = hidden ? Qt::BlankCursor : Qt::ArrowCursor; - - if (cursor().shape() != targetShape) - { - setCursor(targetShape); - } - - if (m_mpvWidget && m_mpvWidget->cursor().shape() != targetShape) - { - m_mpvWidget->setCursor(targetShape); - } -} - -void PlayerView::setPlayerChromeVisible(bool visible) -{ - if (m_playerChromeVisible == visible) - { - return; - } - - m_playerChromeVisible = visible; - Q_EMIT playerChromeVisibilityChanged(visible); -} - -bool PlayerView::areControlsFullyVisible() const -{ - return m_topOpacity && m_topOpacity->opacity() >= 1.0 && - (!m_fadeGroup || m_fadeGroup->state() != QAbstractAnimation::Running); -} - - -void PlayerView::showControls() -{ - if (m_isViewTearingDown || m_hasReportedStop) - { - return; - } - - setPlayerChromeVisible(true); - setCursorHidden(false); - - - if (m_osdLayer && m_osdLayer->isVisible()) - { - m_osdLayer->forceHide(); - } - - if (m_isPlaying) - { - m_hideTimer->start(kHudAutoHideDelayMs); - } - else - { - m_hideTimer->stop(); - } - - if (m_topOpacity->opacity() >= 1.0 && m_fadeGroup->state() != QAbstractAnimation::Running) - { - return; - } - - if (m_fadeGroup->state() == QAbstractAnimation::Running) - { - stopFadeGroupSafely(m_fadeGroup); - } - - // Reduce-motion routes through the existing snap-to-target fallback below. - bool canAnimate = !MotionPrefs::reduceMotion(); - for (int i = 0; i < m_fadeGroup->animationCount(); ++i) - { - auto *anim = qobject_cast(m_fadeGroup->animationAt(i)); - if (!anim || !anim->targetObject()) - { - canAnimate = false; - break; - } - anim->setStartValue(anim->targetObject()->property("opacity")); - anim->setEndValue(1.0); - } - if (!canAnimate) - { - if (m_topOpacity) - m_topOpacity->setOpacity(1.0); - if (m_bottomOpacity) - m_bottomOpacity->setOpacity(1.0); - if (m_logoOpacity) - m_logoOpacity->setOpacity(1.0); - if (m_speedOpacity) - m_speedOpacity->setOpacity(1.0); - return; - } - m_fadeGroup->setDirection(QAbstractAnimation::Forward); - m_fadeGroup->start(); -} - - -void PlayerView::hideControls() -{ - if (m_isViewTearingDown || m_hasReportedStop) - { - return; - } - - if (!m_isPlaying) - { - return; - } - - QPoint localPos = this->mapFromGlobal(QCursor::pos()); - bool isAppActive = this->window()->isActiveWindow(); - bool isMouseInside = this->rect().contains(localPos); - - bool isMouseInsidePopup = m_activePopup && m_activePopup->geometry().contains(localPos); - const bool hasActivePlayerDialog = m_activePlayerDialog && m_activePlayerDialog->isVisible(); - - - if (hasActivePlayerDialog) - { - m_hideTimer->start(1000); - return; - } - - if (isAppActive && isMouseInside && - (m_topHUD->geometry().contains(localPos) || m_bottomHUD->geometry().contains(localPos) || isMouseInsidePopup || - (m_isRightSidebarVisible && m_rightSidebar->geometry().contains(localPos)))) - { - m_hideTimer->start(1000); - return; - } - - hideRightSidebar(); - hideHudMediaSwitcher(); - setPlayerChromeVisible(false); - if (m_seekBubble) - { - m_seekBubble->hideBubble(); - } - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - if (m_topOpacity->opacity() <= 0.0) - { - return; - } - - if (m_fadeGroup->state() == QAbstractAnimation::Running) - { - stopFadeGroupSafely(m_fadeGroup); - } - - // Reduce-motion routes through the existing snap-to-target fallback below. - bool canAnimate = !MotionPrefs::reduceMotion(); - for (int i = 0; i < m_fadeGroup->animationCount(); ++i) - { - auto *anim = qobject_cast(m_fadeGroup->animationAt(i)); - if (!anim || !anim->targetObject()) - { - canAnimate = false; - break; - } - anim->setStartValue(anim->targetObject()->property("opacity")); - anim->setEndValue(0.0); - } - if (!canAnimate) - { - if (m_topOpacity) - m_topOpacity->setOpacity(0.0); - if (m_bottomOpacity) - m_bottomOpacity->setOpacity(0.0); - if (m_logoOpacity) - m_logoOpacity->setOpacity(0.0); - if (m_speedOpacity) - m_speedOpacity->setOpacity(0.0); - return; - } - m_fadeGroup->setDirection(QAbstractAnimation::Forward); - m_fadeGroup->start(); - - setCursorHidden(true); -} - -void PlayerView::onMpvPropertyChanged(const QString &property, const QVariant &value) -{ - if (m_isViewTearingDown) - { - return; - } - - if (property == "cache-speed") - { - qint64 speedBytes = value.toLongLong(); - if (!m_useRelayNetworkSpeed) - { - m_effectiveNetworkSpeed = speedBytes; - m_networkSpeedLabel->setText(formatDataRateValue(speedBytes)); - } - } - else if (property == "mute") - { - m_isMuted = value.toBool(); - m_volumeBtn->setIcon(QIcon(m_isMuted ? ":/svg/player/volume-mute.svg" : ":/svg/player/volume.svg")); - - m_volumeSlider->blockSignals(true); - if (m_isMuted) - { - m_volumeSlider->setValue(0); - } - else - { - m_volumeSlider->setValue(static_cast(m_currentVolume)); - } - m_volumeSlider->blockSignals(false); - } - else if (property == "volume") - { - m_currentVolume = value.toDouble(); - if (!m_isMuted) - { - m_volumeSlider->blockSignals(true); - m_volumeSlider->setValue(static_cast(m_currentVolume)); - m_volumeSlider->blockSignals(false); - } - } - else if (property == "speed") - { - m_currentSpeed = value.toDouble(); - if (m_speedBtn) - { - m_speedBtn->setText(PlayerLongPressHandler::formatSpeedText(m_currentSpeed)); - } - } - else if (property == "paused-for-cache") - { - - m_isBuffering = value.toBool(); - updateLoadingState(); - } - else if (property == "seeking") - { - - const bool wasSeeking = m_isSeeking; - m_isSeeking = value.toBool(); - updateLoadingState(); - if (wasSeeking && !m_isSeeking) - { - QTimer::singleShot(0, this, [this]() { reportProgressToServer(); }); - } - } - else if (property == "demuxer-cache-time") - { - const double cacheTime = value.toDouble(); - if (!std::isnan(cacheTime) && !std::isinf(cacheTime) && cacheTime >= 0.0) - { - m_bufferedPosition = cacheTime; - updateBufferProgress(); - } - } - else if (property == "eof-reached") - { - m_isPlaybackFinished = value.toBool(); - if (m_isPlaybackFinished) - { - qDebug() << "[PlayerView] MPV reached EOF" - << "| position=" << m_currentPosition - << "| duration=" << m_totalDuration; - m_isPlaying = false; - m_isBuffering = false; - m_isSeeking = false; - updatePowerInhibition(); - updateLoadingState(); - if (m_playPauseBtn) - { - m_playPauseBtn->setIcon(QIcon(":/svg/player/play.svg")); - } - autoPlayNextMediaIfEnabled(); - } - } - - if (m_showStatisticsOverlay && (property == QLatin1String("cache-speed") || - property == QLatin1String("track-list") || property == QLatin1String("pause"))) - { - updateStatisticsDisplay(); - } -} - -void PlayerView::toggleMute() -{ - m_isMuted = !m_isMuted; - m_mpvWidget->controller()->setProperty("mute", m_isMuted); - showToast(m_isMuted ? tr("Muted") : tr("Volume: %1%").arg(static_cast(m_currentVolume))); - showControls(); - - - ConfigStore::instance()->set(ConfigKeys::PlayerVolumeMuted, m_isMuted); -} - - -void PlayerView::changeVolume(int delta, bool silent) -{ - if (m_isMuted) - { - return; - } - - m_currentVolume += delta; - if (m_currentVolume < 0) - { - m_currentVolume = 0; - } - if (m_currentVolume > 100) - { - m_currentVolume = 100; - } - - m_mpvWidget->controller()->setProperty("volume", m_currentVolume); - - if (!silent) - { - showToast(tr("Volume: %1%").arg(static_cast(m_currentVolume))); - showControls(); - } - else - { - if (m_osdLayer && m_topOpacity->opacity() <= 0.0) - { - const int volumePercent = static_cast(m_currentVolume); - m_osdLayer->showVolume(volumePercent, tr("%1%").arg(volumePercent), m_isMuted); - } - } - - - ConfigStore::instance()->set(ConfigKeys::PlayerVolumeLevel, m_currentVolume); -} - -void PlayerView::onVolumeSliderMoved(int value) -{ - m_currentVolume = value; - if (m_isMuted && m_currentVolume > 0) - { - m_isMuted = false; - m_mpvWidget->controller()->setProperty("mute", false); - } - m_mpvWidget->controller()->setProperty("volume", m_currentVolume); - showToast(tr("Volume: %1%").arg(static_cast(m_currentVolume))); - showControls(); - - - ConfigStore::instance()->set(ConfigKeys::PlayerVolumeLevel, m_currentVolume); - ConfigStore::instance()->set(ConfigKeys::PlayerVolumeMuted, m_isMuted); -} - - - - - -void PlayerView::showSettingsMenu() -{ - auto *panel = new ModernScrollPanel(this); - const bool showDanmakuControls = shouldShowDanmakuHudControls(); - - - panel->addItem(tr("Network Speed"), "network_speed", m_showNetworkSpeed); - panel->addItem(tr("Statistics"), "statistics", m_showStatisticsOverlay); - panel->addItem(tr("Subtitle Settings"), "subtitle_settings", false); - if (showDanmakuControls) - { - panel->addItem(tr("Danmaku Settings"), "danmaku_settings", false); - } - - - ServerProfile profile = m_core->serverManager()->activeProfile(); - bool defaultStrmDirect = (profile.type == ServerProfile::Jellyfin); - QSettings settings("qEmby", "Player"); - bool strmDirect = settings.value("EnableStrmDirectPlay", defaultStrmDirect).toBool(); - - panel->addItem(tr("STRM Direct Play"), "strm_direct", strmDirect); - - int maxHeight = this->height() - m_bottomHUD->height() - 40; - - panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 300); - - - connect(panel, &ModernScrollPanel::itemTriggered, this, - [this](const QVariant &data, const QString &text) - { - Q_UNUSED(text); - QString action = data.toString(); - auto dismissPopup = [this]() - { - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - }; - - if (action == "network_speed") - { - m_showNetworkSpeed = !m_showNetworkSpeed; - m_networkSpeedLabel->setVisible(m_showNetworkSpeed); - showToast(m_showNetworkSpeed ? tr("Network Speed Enabled") : tr("Network Speed Disabled")); - } - else if (action == "statistics") - { - m_showStatisticsOverlay = !m_showStatisticsOverlay; - if (m_showStatisticsOverlay) - { - updateStatisticsDisplay(); - m_statisticsOverlay->show(); - updateOverlayLayout(); - } - else - { - m_statisticsOverlay->hide(); - } - } - else if (action == "strm_direct") - { - ServerProfile profile = m_core->serverManager()->activeProfile(); - bool defaultStrmDirect = (profile.type == ServerProfile::Jellyfin); - QSettings settings("qEmby", "Player"); - bool currentStrm = settings.value("EnableStrmDirectPlay", defaultStrmDirect).toBool(); - bool newStrm = !currentStrm; - - settings.setValue("EnableStrmDirectPlay", newStrm); - showToast(newStrm ? tr("STRM Direct Play: ON (Reloading...)") - : tr("STRM Direct Play: OFF (Reloading...)")); - - - QString mediaId = m_currentMediaId; - QString title = m_fullTitle; - QString origUrl = m_originalStreamUrl; - QVariant sourceInfo = m_currentSourceInfoVar; - long long currentTicks = static_cast(m_currentPosition * 10000000.0); - - - stopAndReport(); - - - QTimer::singleShot(150, this, [this, mediaId, title, origUrl, currentTicks, sourceInfo]() - { playMedia(mediaId, title, origUrl, currentTicks, sourceInfo); }); - } - else if (action == "subtitle_settings") - { - dismissPopup(); - openSubtitleSettingsDialog(); - return; - } - else if (action == "danmaku_settings") - { - dismissPopup(); - openDanmakuSettingsDialog(); - return; - } - - - dismissPopup(); - }); - - showCenteredPopup(panel, m_settingsBtn); -} - - - - - -void PlayerView::showSpeedMenu() -{ - auto *panel = new ModernScrollPanel(this); - - QList speeds = {0.5, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}; - - for (double s : speeds) - { - bool isSelected = qFuzzyCompare(m_currentSpeed, s); - panel->addItem(QString("%1X").arg(s), s, isSelected); - } - - int maxHeight = this->height() - m_bottomHUD->height() - 40; - panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 130); - - connect(panel, &ModernScrollPanel::itemTriggered, this, - [this](const QVariant &data, const QString &text) - { - Q_UNUSED(text); - double s = data.toDouble(); - m_currentSpeed = s; - setEffectivePlaybackSpeed(s); - showToast(tr("Speed: %1X").arg(PlayerLongPressHandler::formatSpeedText(s).chopped(1))); - - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - }); - - showCenteredPopup(panel, m_speedBtn); -} - -void PlayerView::showAudioMenu() -{ - auto *panel = new ModernScrollPanel(this); - - auto tracks = m_mpvWidget->controller()->getProperty("track-list").toList(); - bool anyAudioSelected = false; - - for (const QVariant &v : tracks) - { - auto map = v.toMap(); - if (map["type"].toString() == "audio") - { - int id = map["id"].toInt(); - QString title = map["title"].toString(); - QString lang = map["lang"].toString(); - bool selected = map["selected"].toBool(); - if (selected) - { - anyAudioSelected = true; - } - - QString text = title.isEmpty() ? (lang.isEmpty() ? tr("Track %1").arg(id) : lang) : title; - panel->addItem(text, id, selected); - } - } - - panel->addItem(tr("Disable Audio"), "no", !anyAudioSelected); - - int maxHeight = this->height() - m_bottomHUD->height() - 40; - panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 200); - - connect(panel, &ModernScrollPanel::itemTriggered, this, - [this](const QVariant &data, const QString &text) - { - QString valStr = data.toString(); - if (valStr == "no") - { - m_mpvWidget->controller()->setProperty("aid", "no"); - showToast(tr("Audio Disabled")); - } - else - { - int id = data.toInt(); - m_mpvWidget->controller()->setProperty("aid", id); - showToast(tr("Audio: %1").arg(text)); - } - - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - }); - - showCenteredPopup(panel, m_audioBtn); -} - -void PlayerView::showSubtitleMenu() -{ - auto *panel = new ModernScrollPanel(this); - const QList tracks = - m_danmakuController ? m_danmakuController->contentSubtitleTracks() : QList{}; - bool anySubSelected = false; - - for (const QVariantMap &map : tracks) - { - int id = map["id"].toInt(); - QString title = map["title"].toString(); - QString lang = map["lang"].toString(); - bool selected = map["selected"].toBool(); - if (selected) - { - anySubSelected = true; - } - - QString text = title.isEmpty() ? (lang.isEmpty() ? tr("Subtitle %1").arg(id) : lang) : title; - panel->addItem(text, id, selected); - } - - panel->addItem(tr("Disable Subtitles"), "no", !anySubSelected); - panel->addItem(tr("Load Local Subtitle File"), QStringLiteral("load_local"), false); - panel->addItem(tr("Subtitle Settings"), QStringLiteral("settings"), false); - - int maxHeight = this->height() - m_bottomHUD->height() - 40; - panel->finalizeLayout(maxHeight < 150 ? 150 : maxHeight, 240); - - connect(panel, &ModernScrollPanel::itemTriggered, this, - [this](const QVariant &data, const QString &text) - { - auto dismissPopup = [this]() - { - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - }; - - const QString action = data.toString(); - if (action == QLatin1String("settings")) - { - dismissPopup(); - openSubtitleSettingsDialog(); - return; - } - if (action == QLatin1String("load_local")) - { - dismissPopup(); - loadExternalSubtitleFile(); - return; - } - - - - clearPersistedExternalSubtitle(); - - if (m_danmakuController) - { - m_danmakuController->selectSubtitleTrack(data); - } - - showToast(action == "no" ? tr("Subtitles Disabled") : tr("Subtitle: %1").arg(text)); - dismissPopup(); - }); - - showCenteredPopup(panel, m_subtitleBtn); -} - -void PlayerView::showDanmakuMenu() -{ - if (!shouldShowDanmakuHudControls()) - { - return; - } - - auto *panel = new ModernScrollPanel(this); - - const bool visible = m_danmakuController && m_danmakuController->isDanmakuVisible(); - panel->addItem(buildDanmakuSummaryText(), QStringLiteral("info"), false); - panel->addItem(visible ? tr("Hide Danmaku") : tr("Show Danmaku"), QStringLiteral("toggle"), visible); - panel->addItem(tr("Reload Danmaku"), QStringLiteral("reload"), false); - panel->addItem(tr("Load Local Danmaku File"), QStringLiteral("load_local"), false); - panel->addItem(tr("Search Danmaku"), QStringLiteral("search"), false); - panel->addItem(tr("Danmaku Settings"), QStringLiteral("quick_settings"), false); - - int maxHeight = this->height() - m_bottomHUD->height() - 40; - panel->finalizeLayout(maxHeight < 160 ? 160 : maxHeight, 360); - - connect(panel, &ModernScrollPanel::itemTriggered, this, - [this](const QVariant &data, const QString &text) - { - Q_UNUSED(text); - if (!m_danmakuController) - { - return; - } - - auto dismissPopup = [this]() - { - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - }; - - const QString action = data.toString(); - if (action == QLatin1String("info")) - { - showToast(buildDanmakuSummaryText()); - } - else if (action == QLatin1String("toggle")) - { - m_danmakuController->setDanmakuVisible(!m_danmakuController->isDanmakuVisible()); - } - else if (action == QLatin1String("reload")) - { - m_danmakuController->reload(); - } - else if (action == QLatin1String("load_local")) - { - dismissPopup(); - loadLocalDanmakuFile(); - return; - } - else if (action == QLatin1String("search")) - { - dismissPopup(); - showDanmakuIdentifyDialog(); - return; - } - else if (action == QLatin1String("quick_settings")) - { - dismissPopup(); - openDanmakuSettingsDialog(); - return; - } - - dismissPopup(); - }); - - showCenteredPopup(panel, m_danmakuBtn); -} - -void PlayerView::showDanmakuIdentifyDialog() -{ - if (!m_danmakuController || !m_danmakuController->hasPlaybackContext()) - { - showToast(tr("Danmaku is unavailable for the current media")); - return; - } - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - const QString activeTargetId = m_danmakuController ? m_danmakuController->activeTargetId() : QString(); - const QString activeEndpointId = m_danmakuController ? m_danmakuController->activeEndpointId() : QString(); - auto *dialog = new PlayerDanmakuIdentifyDialog(m_core, m_danmakuController->mediaContext(), QString(), - activeTargetId, activeEndpointId, this); - connect(dialog, &PlayerDanmakuIdentifyDialog::finished, this, - [this, dialog](int result) - { - if (result != PlayerOverlayDialog::Accepted || !dialog->selectedCandidate().isValid() || - !m_danmakuController) - { - return; - } - - m_danmakuController->loadFromCandidate(dialog->selectedCandidate(), true); - }); - trackPlayerDialog(dialog); -} - -void PlayerView::loadLocalDanmakuFile() -{ - if (!m_danmakuController || !m_danmakuController->hasPlaybackContext()) - { - showToast(tr("Danmaku is unavailable for the current media")); - return; - } - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - const QString serverId = m_danmakuController->mediaContext().serverId; - const QString startDir = DanmakuService::ensureLocalDanmakuDirectory(serverId) - ? DanmakuService::localDanmakuDirectoryPath(serverId) - : QDir::homePath(); - const QString filePath = openPlayerFileDialog( - tr("Load Local Danmaku File"), startDir, - tr("Danmaku Files (*.ass *.json *.xml);;ASS Files (*.ass);;JSON Files (*.json);;XML Files (*.xml)")); - if (filePath.trimmed().isEmpty()) - { - return; - } - - qDebug().noquote() << "[Danmaku][PlayerView] Load local danmaku file" - << "| mediaId:" << m_danmakuController->mediaContext().mediaId << "| path:" << filePath; - m_danmakuController->loadLocalFile(filePath); -} - -void PlayerView::loadExternalSubtitleFile() -{ - if (!m_mpvWidget || !m_mpvWidget->controller()) - { - showToast(tr("Player is not ready")); - return; - } - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - const QString lastDir = ConfigStore::instance()->get(ConfigKeys::PlayerLastSubtitleDir, QString()); - const QString startDir = (!lastDir.isEmpty() && QDir(lastDir).exists()) ? lastDir : QDir::homePath(); - - const QString filter = tr("Subtitle Files (*.srt *.ass *.ssa *.sub *.idx *.vtt *.smi *.sup *.txt);;" - "SubRip (*.srt);;" - "Advanced SubStation Alpha (*.ass *.ssa);;" - "WebVTT (*.vtt);;" - "MicroDVD / SubViewer (*.sub);;" - "VobSub (*.idx);;" - "SAMI (*.smi);;" - "PGS / HDMV (*.sup);;" - "All Files (*)"); - - const QString filePath = openPlayerFileDialog(tr("Load Local Subtitle File"), startDir, filter); - if (filePath.trimmed().isEmpty()) - { - return; - } - - const QFileInfo info(filePath); - ConfigStore::instance()->set(ConfigKeys::PlayerLastSubtitleDir, info.absolutePath()); - - qDebug().noquote() << "[Subtitle][PlayerView] Load external subtitle file" - << "| path:" << filePath; - - - - - m_mpvWidget->controller()->command(QVariantList{QStringLiteral("sub-add"), QDir::toNativeSeparators(filePath), - QStringLiteral("auto"), info.fileName(), QString()}); - - - const int newTrackId = findSubtitleTrackIdByPath(info.absoluteFilePath()); - if (newTrackId > 0 && m_danmakuController) - { - m_danmakuController->selectSubtitleTrack(newTrackId); - } - else - { - qWarning().noquote() << "[Subtitle][PlayerView] Failed to locate newly added subtitle track" - << "| path:" << filePath; - } - - persistExternalSubtitle(info.absoluteFilePath()); - - showToast(tr("Subtitle Loaded: %1").arg(info.fileName())); -} - -QString PlayerView::externalSubtitleConfigKey() const -{ - if (!m_core) - { - return QString(); - } - const QString serverId = m_core->serverManager()->activeProfile().id; - if (serverId.isEmpty() || m_currentMediaId.isEmpty()) - { - return QString(); - } - return ConfigKeys::forServerMedia(serverId, m_currentMediaId, ConfigKeys::PlayerExternalSubtitle); -} - -QString PlayerView::readPersistedExternalSubtitle() const -{ - const QString key = externalSubtitleConfigKey(); - if (key.isEmpty()) - { - return QString(); - } - return ConfigStore::instance()->get(key, QString()).trimmed(); -} - -void PlayerView::persistExternalSubtitle(const QString &absPath) -{ - const QString key = externalSubtitleConfigKey(); - if (key.isEmpty()) - { - return; - } - ConfigStore::instance()->set(key, absPath); - qDebug().noquote() << "[Subtitle][PlayerView] Persist external subtitle" - << "| key:" << key << "| path:" << absPath; -} - -void PlayerView::clearPersistedExternalSubtitle() -{ - const QString key = externalSubtitleConfigKey(); - if (key.isEmpty()) - { - return; - } - const QString existing = ConfigStore::instance()->get(key, QString()).trimmed(); - if (existing.isEmpty()) - { - return; - } - ConfigStore::instance()->set(key, QString()); - qDebug().noquote() << "[Subtitle][PlayerView] Clear external subtitle record" - << "| key:" << key << "| previousPath:" << existing; -} - -void PlayerView::applyPersistedExternalSubtitleIfAny() -{ - if (!m_mpvWidget || !m_mpvWidget->controller()) - { - return; - } - - const QString persistedPath = readPersistedExternalSubtitle(); - if (persistedPath.isEmpty()) - { - return; - } - - const QFileInfo info(persistedPath); - if (!info.exists() || !info.isFile() || !info.isReadable()) - { - qDebug().noquote() << "[Subtitle][PlayerView] Persisted external subtitle missing, " - "fallback to default selection" - << "| path:" << persistedPath; - clearPersistedExternalSubtitle(); - return; - } - - qDebug().noquote() << "[Subtitle][PlayerView] Restore persisted external subtitle" - << "| path:" << persistedPath; - - - - m_mpvWidget->controller()->command(QVariantList{QStringLiteral("sub-add"), - QDir::toNativeSeparators(info.absoluteFilePath()), - QStringLiteral("auto"), info.fileName(), QString()}); - - const int newTrackId = findSubtitleTrackIdByPath(info.absoluteFilePath()); - if (newTrackId > 0 && m_danmakuController) - { - m_danmakuController->selectSubtitleTrack(newTrackId); - } - else - { - qWarning().noquote() << "[Subtitle][PlayerView] Failed to locate restored subtitle track" - << "| path:" << persistedPath; - } -} - -QString PlayerView::openPlayerFileDialog(const QString &title, const QString &startDir, const QString &filter) -{ - QFileDialog dialog(this); -#ifdef Q_OS_LINUX - dialog.setOption(QFileDialog::DontUseNativeDialog, true); - dialog.setOption(QFileDialog::DontUseCustomDirectoryIcons, true); -#endif - dialog.setWindowTitle(title); - dialog.setDirectory(startDir); - dialog.setNameFilter(filter); - dialog.setFileMode(QFileDialog::ExistingFile); - dialog.setAcceptMode(QFileDialog::AcceptOpen); - - QElapsedTimer timer; - timer.start(); - qDebug().noquote() << "[PlayerView] Open local file dialog" - << "| title:" << title << "| startDir:" << startDir; - - if (dialog.exec() != QDialog::Accepted) - { - qDebug().noquote() << "[PlayerView] Local file dialog canceled" - << "| title:" << title << "| elapsedMs:" << timer.elapsed(); - return QString(); - } - - const QStringList selectedFiles = dialog.selectedFiles(); - const QString filePath = selectedFiles.isEmpty() ? QString() : selectedFiles.first(); - qDebug().noquote() << "[PlayerView] Local file dialog accepted" - << "| title:" << title << "| elapsedMs:" << timer.elapsed() << "| path:" << filePath; - return filePath; -} - -int PlayerView::findSubtitleTrackIdByPath(const QString &absPath) const -{ - if (!m_mpvWidget || !m_mpvWidget->controller() || absPath.isEmpty()) - { - return -1; - } - const QString normalizedTarget = QDir::fromNativeSeparators(absPath); - const QVariantList tracks = m_mpvWidget->controller()->getProperty(QStringLiteral("track-list")).toList(); - for (const QVariant &v : tracks) - { - const QVariantMap m = v.toMap(); - if (m.value(QStringLiteral("type")).toString() != QLatin1String("sub")) - { - continue; - } - const QString external = m.value(QStringLiteral("external-filename")).toString(); - if (!external.isEmpty() && QDir::fromNativeSeparators(external) == normalizedTarget) - { - return m.value(QStringLiteral("id")).toInt(); - } - } - return -1; -} - -void PlayerView::openSubtitleSettingsDialog() -{ - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - auto *dialog = new PlayerSubtitleSettingsDialog(this); - trackPlayerDialog(dialog); -} - -void PlayerView::openDanmakuSettingsDialog() -{ - if (!shouldShowDanmakuHudControls()) - { - return; - } - - if (m_activePopup) - { - m_activePopup->hide(); - m_activePopup->close(); - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - - auto *dialog = new PlayerDanmakuSettingsDialog(this); - connect(dialog, &PlayerDanmakuSettingsDialog::liveReloadRequested, this, - [this]() - { - if (!m_danmakuController || !m_danmakuController->hasPlaybackContext() || - !m_danmakuController->isDanmakuEnabled()) - { - return; - } - m_danmakuController->reload(); - }); - connect(dialog, &PlayerDanmakuSettingsDialog::finished, this, - [this, dialog](int) - { - if (!m_danmakuController) - { - return; - } - - if (dialog->requiresReload() && m_danmakuController->hasPlaybackContext() && - m_danmakuController->isDanmakuEnabled()) - { - m_danmakuController->reload(); - } - }); - trackPlayerDialog(dialog); -} - -void PlayerView::applySubtitleStyleSettings() -{ - if (!m_mpvWidget || !m_mpvWidget->controller()) - { - return; - } - - const bool protectDanmakuPrimary = - m_danmakuController && m_danmakuController->isDanmakuEnabled() && m_danmakuController->hasDanmakuTrack(); - SubtitleStyleUtils::applyToController(m_mpvWidget->controller(), protectDanmakuPrimary); -} - -void PlayerView::resumePlaybackAfterFinishedSeek() -{ - if (!m_isPlaybackFinished || !m_mpvWidget) - { - return; - } - - qDebug() << "[PlayerView] Resume playback after finished seek" - << "| position=" << m_currentPosition - << "| duration=" << m_totalDuration; - - m_isPlaybackFinished = false; - m_isPlaying = true; - m_isBuffering = false; - m_isSeeking = false; - m_mpvWidget->play(); - updatePowerInhibition(); - updateLoadingState(); - if (m_playPauseBtn) - { - m_playPauseBtn->setIcon(QIcon(":/svg/player/pause.svg")); - } -} - - - -void PlayerView::seekRelative(double delta, bool silent) -{ - const bool shouldResumeFinishedPlayback = m_isPlaybackFinished && delta < 0.0; - m_mpvWidget->controller()->command(QVariantList{"seek", delta, "relative"}); - if (shouldResumeFinishedPlayback) - { - resumePlaybackAfterFinishedSeek(); - } - - if (!silent) - { - m_osdSeekPreviewPosition = -1.0; - showToast(delta > 0 ? tr("Forward %1s").arg(std::abs(delta)) : tr("Rewind %1s").arg(std::abs(delta))); - showControls(); - } - else - { - if (m_osdLayer && m_topOpacity && m_topOpacity->opacity() <= 0.0) - { - const bool continuePreview = m_osdLayer->isSeekLineVisible() && m_osdSeekPreviewPosition >= 0.0; - const double basePosition = continuePreview ? m_osdSeekPreviewPosition : m_currentPosition; - double previewPosition = basePosition + delta; - if (m_totalDuration > 0.0) - { - previewPosition = qBound(0.0, previewPosition, m_totalDuration); - } - else - { - previewPosition = qMax(0.0, previewPosition); - } - - m_osdSeekPreviewPosition = previewPosition; - m_osdLayer->showSeek(previewPosition, m_totalDuration, formatTime(previewPosition, m_totalDuration)); - } - } -} - -void PlayerView::cycleVideoScale() -{ - m_videoScaleMode = (m_videoScaleMode + 1) % 4; - auto *ctrl = m_mpvWidget->controller(); - QString modeStr; - - switch (m_videoScaleMode) - { - case 0: - ctrl->setProperty("keepaspect", true); - ctrl->setProperty("panscan", 0.0); - ctrl->setProperty("video-unscaled", false); - modeStr = tr("Scale: Fit"); - break; - case 1: - ctrl->setProperty("keepaspect", true); - ctrl->setProperty("panscan", 1.0); - ctrl->setProperty("video-unscaled", false); - modeStr = tr("Scale: Crop"); - break; - case 2: - ctrl->setProperty("keepaspect", false); - ctrl->setProperty("panscan", 0.0); - ctrl->setProperty("video-unscaled", false); - modeStr = tr("Scale: Stretch"); - break; - case 3: - ctrl->setProperty("keepaspect", true); - ctrl->setProperty("panscan", 0.0); - ctrl->setProperty("video-unscaled", true); - modeStr = tr("Scale: Original"); - break; - } - setScaleIcon(); - showToast(modeStr); - - - ConfigStore::instance()->set(ConfigKeys::PlayerDefaultScale, m_videoScaleMode); -} - -void PlayerView::setScaleIcon() -{ - switch (m_videoScaleMode) - { - case 0: - m_scaleBtn->setIcon(QIcon(":/svg/player/scale-fit.svg")); - break; - case 1: - m_scaleBtn->setIcon(QIcon(":/svg/player/scale-crop.svg")); - break; - case 2: - m_scaleBtn->setIcon(QIcon(":/svg/player/scale-stretch.svg")); - break; - case 3: - m_scaleBtn->setIcon(QIcon(":/svg/player/scale-original.svg")); - break; - } -} - -void PlayerView::showToast(const QString &msg) -{ - if (m_isViewTearingDown) - { - return; - } - - m_toastLabel->setText(msg); - m_toastLabel->show(); - m_toastTimer->start(2500); -} - -void PlayerView::updateStatisticsDisplay() -{ - if (!m_statisticsOverlay || !m_mpvWidget || !m_mpvWidget->controller()) - { - return; - } - - auto *ctrl = m_mpvWidget->controller(); - const QVariantList tracks = ctrl->getProperty("track-list").toList(); - const QVariantMap selectedVideoTrack = findSelectedTrackByType(tracks, QStringLiteral("video")); - const QVariantMap selectedAudioTrack = findSelectedTrackByType(tracks, QStringLiteral("audio")); - const QVariantMap selectedSubtitleTrack = findSelectedTrackByType(tracks, QStringLiteral("sub")); - - const MediaSourceInfo &source = m_currentMediaSourceInfo; - const MediaStreamInfo *videoStream = findFirstStreamByType(source, QStringLiteral("Video")); - const MediaStreamInfo *audioStream = findFirstStreamByType(source, QStringLiteral("Audio")); - - auto addLine = [](QStringList &lines, const QString &label, const QString &value) - { lines << (label + QStringLiteral(": ") + trimOrDash(value)); }; - - QStringList lines; - - QString sourcePath = firstNonEmpty({source.path, m_currentMediaItem.path}); - sourcePath = QUrl::fromPercentEncoding(sourcePath.toUtf8()).trimmed(); - - QString fileDisplay = sourcePath; - if (!fileDisplay.isEmpty()) - { - const QFileInfo fileInfo(fileDisplay); - if (!fileInfo.fileName().isEmpty()) - { - fileDisplay = fileInfo.fileName(); - } - } - if (fileDisplay.isEmpty()) - { - fileDisplay = firstNonEmpty({source.name, m_currentMediaItem.name, m_fullTitle}); - } - addLine(lines, tr("File"), fileDisplay); - - QStringList sessionParts; - if (!m_currentMediaId.isEmpty()) - { - sessionParts << tr("Item ID") + QStringLiteral("=") + m_currentMediaId; - } - if (!m_currentMediaSourceId.isEmpty()) - { - sessionParts << tr("Media Source ID") + QStringLiteral("=") + m_currentMediaSourceId; - } - if (!m_currentPlaySessionId.isEmpty()) - { - sessionParts << tr("Play Session ID") + QStringLiteral("=") + m_currentPlaySessionId; - } - if (!sessionParts.isEmpty()) - { - addLine(lines, tr("Session"), sessionParts.join(QStringLiteral(" "))); - } - - const QVariantList chapterList = ctrl->getProperty("chapter-list").toList(); - const int chapterIndex = ctrl->getProperty("chapter").toInt(); - if (!chapterList.isEmpty() && chapterIndex >= 0 && chapterIndex < chapterList.size()) - { - const QVariantMap chapterInfo = chapterList.at(chapterIndex).toMap(); - QString chapterTitle = chapterInfo.value(QStringLiteral("title")).toString().trimmed(); - if (chapterTitle.isEmpty()) - { - chapterTitle = tr("Chapter") + QStringLiteral(" ") + QString::number(chapterIndex + 1); - } - chapterTitle += QStringLiteral(" (%1 / %2)").arg(chapterIndex + 1).arg(chapterList.size()); - addLine(lines, tr("Chapter"), chapterTitle); - } - - const qint64 fileSize = source.size > 0 ? source.size : m_currentMediaItem.size; - const QString container = firstNonEmpty({source.container.toUpper(), m_currentMediaItem.container.toUpper()}); - addLine( - lines, tr("Size"), - joinNonEmpty({fileSize > 0 ? FileUtils::formatSize(fileSize) : QString(), container}, QStringLiteral(" "))); - - const QVariantMap cacheState = ctrl->getProperty("demuxer-cache-state").toMap(); - const qint64 cacheBytes = qMax(qMax(variantToLongLong(cacheState.value(QStringLiteral("fw-bytes"))), - variantToLongLong(cacheState.value(QStringLiteral("file-cache-bytes")))), - variantToLongLong(cacheState.value(QStringLiteral("total-bytes")))); - double cacheDuration = ctrl->getProperty("demuxer-cache-duration").toDouble(); - if (cacheDuration <= 0.0) - { - cacheDuration = variantToDouble(cacheState.value(QStringLiteral("cache-duration"))); - } - const qint64 cacheSpeed = - m_useRelayNetworkSpeed ? m_effectiveNetworkSpeed : ctrl->getProperty("cache-speed").toLongLong(); - addLine(lines, tr("Cache"), - joinNonEmpty({cacheBytes > 0 ? FileUtils::formatSize(cacheBytes) : QString(), - formatDurationValue(cacheDuration), formatDataRateValue(cacheSpeed)}, - QStringLiteral(" "))); - - const QString voName = ctrl->getProperty("current-vo").toString().trimmed(); - QString decoderName = ctrl->getProperty("hwdec-current").toString().trimmed(); - if (decoderName.isEmpty() || decoderName.compare(QStringLiteral("no"), Qt::CaseInsensitive) == 0) - { - decoderName = tr("Software (CPU)"); - } - else - { - decoderName = decoderName.toUpper(); - } - - QStringList playbackParts; - if (!voName.isEmpty()) - { - playbackParts << voName; - } - if (!decoderName.isEmpty()) - { - playbackParts << decoderName; - } - playbackParts << tr("Speed") + QStringLiteral(" ") + stripTrailingZeros(QString::number(m_currentSpeed, 'f', 2)) + - QStringLiteral("X"); - - const double avsync = ctrl->getProperty("avsync").toDouble(); - const QString avsyncText = formatAvSyncValue(avsync); - if (!avsyncText.isEmpty()) - { - playbackParts << tr("A-V") + QStringLiteral(" ") + avsyncText; - } - addLine(lines, tr("Playback"), playbackParts.join(QStringLiteral(" "))); - - double displayFps = ctrl->getProperty("display-fps").toDouble(); - if (displayFps <= 0.0) - { - displayFps = ctrl->getProperty("estimated-display-fps").toDouble(); - } - double videoFps = ctrl->getProperty("container-fps").toDouble(); - if (videoFps <= 0.0) - { - videoFps = selectedVideoTrack.value(QStringLiteral("demux-fps")).toDouble(); - } - if (videoFps <= 0.0 && videoStream) - { - videoFps = videoStream->realFrameRate; - } - - QStringList refreshParts; - if (displayFps > 0.0) - { - refreshParts << QStringLiteral("%1 Hz").arg(stripTrailingZeros(QString::number(displayFps, 'f', 2))); - } - const QString frameRateText = formatFrameRateValue(videoFps); - if (!frameRateText.isEmpty()) - { - refreshParts << frameRateText; - } - addLine(lines, tr("Refresh Rate"), refreshParts.join(QStringLiteral(" "))); - - const QString outputDrops = ctrl->getProperty("frame-drop-count").toString().trimmed(); - const QString decoderDrops = ctrl->getProperty("decoder-frame-drop-count").toString().trimmed(); - QStringList dropParts; - if (!outputDrops.isEmpty()) - { - dropParts << tr("Output") + QStringLiteral(" ") + outputDrops; - } - if (!decoderDrops.isEmpty()) - { - dropParts << tr("Decoder") + QStringLiteral(" ") + decoderDrops; - } - addLine(lines, tr("Dropped Frames"), dropParts.join(QStringLiteral(" "))); - - lines << QString(); - - const QString videoCodec = firstNonEmpty({ctrl->getProperty("video-codec").toString().toUpper(), - selectedVideoTrack.value(QStringLiteral("codec")).toString().toUpper(), - videoStream ? videoStream->codec.toUpper() : QString()}); - const QString videoFormat = ctrl->getProperty("video-format").toString().toUpper().trimmed(); - const QString videoProfile = videoStream ? videoStream->profile.trimmed() : QString(); - const QString videoLevel = videoStream ? formatLevelValue(videoStream->level) : QString(); - - QStringList videoCodecParts; - if (!videoCodec.isEmpty()) - { - videoCodecParts << videoCodec; - } - if (!videoFormat.isEmpty()) - { - videoCodecParts << videoFormat; - } - if (!videoProfile.isEmpty()) - { - videoCodecParts << videoProfile; - } - if (!videoLevel.isEmpty()) - { - videoCodecParts << QStringLiteral("L%1").arg(videoLevel); - } - addLine(lines, tr("Video"), videoCodecParts.join(QStringLiteral(" / "))); - - addLine(lines, tr("Frame Rate"), frameRateText); - - int sourceWidth = ctrl->getProperty("width").toInt(); - int sourceHeight = ctrl->getProperty("height").toInt(); - if ((sourceWidth <= 0 || sourceHeight <= 0) && videoStream) - { - sourceWidth = videoStream->width; - sourceHeight = videoStream->height; - } - const double displayWidthRaw = ctrl->getProperty("video-params/dw").toDouble(); - const double displayHeightRaw = ctrl->getProperty("video-params/dh").toDouble(); - const int displayWidth = displayWidthRaw > 0.0 ? qRound(displayWidthRaw) : sourceWidth; - const int displayHeight = displayHeightRaw > 0.0 ? qRound(displayHeightRaw) : sourceHeight; - - QString resolutionText = formatDimensionValue(sourceWidth, sourceHeight); - const QString displayDimensions = formatDimensionValue(displayWidth, displayHeight); - if (resolutionText.isEmpty()) - { - resolutionText = displayDimensions; - } - else if (!displayDimensions.isEmpty() && displayDimensions != resolutionText) - { - resolutionText += QStringLiteral(" -> ") + displayDimensions; - } - const QString aspectText = - formatAspectValue(displayWidth, displayHeight, videoStream ? videoStream->aspectRatio : QString()); - if (!aspectText.isEmpty()) - { - resolutionText += QStringLiteral(" (%1)").arg(aspectText); - } - addLine(lines, tr("Resolution"), resolutionText); - - const QString pixelFormat = firstNonEmpty( - {ctrl->getProperty("video-params/pixelformat").toString(), videoStream ? videoStream->pixelFormat : QString()}); - const QString colorLevels = ctrl->getProperty("video-params/colorlevels").toString().trimmed(); - const int bitDepth = videoStream ? videoStream->bitDepth : 0; - addLine(lines, tr("Format"), - joinNonEmpty({pixelFormat, colorLevels, bitDepth > 0 ? tr("%1-bit").arg(bitDepth) : QString()}, - QStringLiteral(" "))); - - const QString colorMatrix = ctrl->getProperty("video-params/colormatrix").toString().trimmed(); - const QString colorPrimaries = ctrl->getProperty("video-params/primaries").toString().trimmed(); - const QString colorTransfer = ctrl->getProperty("video-params/gamma").toString().trimmed(); - addLine(lines, tr("Color"), joinNonEmpty({colorMatrix, colorPrimaries, colorTransfer}, QStringLiteral(" "))); - - qint64 videoBitrate = ctrl->getProperty("video-bitrate").toLongLong(); - if (videoBitrate <= 0 && videoStream) - { - videoBitrate = videoStream->bitRate; - } - if (videoBitrate <= 0) - { - videoBitrate = m_currentMediaItem.bitrate; - } - addLine(lines, tr("Bitrate"), formatBitrateValue(videoBitrate)); - - lines << QString(); - - const QString audioCodec = firstNonEmpty({ctrl->getProperty("audio-codec").toString().toUpper(), - selectedAudioTrack.value(QStringLiteral("codec")).toString().toUpper(), - audioStream ? audioStream->codec.toUpper() : QString()}); - const QString currentAo = ctrl->getProperty("current-ao").toString().trimmed(); - addLine(lines, tr("Audio"), - joinNonEmpty({audioCodec, !currentAo.isEmpty() ? tr("AO %1").arg(currentAo) : QString()}, - QStringLiteral(" "))); - - addLine(lines, tr("Device"), ctrl->getProperty("audio-device").toString()); - - const int channelCount = - qMax(ctrl->getProperty("audio-params/channel-count").toInt(), audioStream ? audioStream->channels : 0); - const QString channelLayout = firstNonEmpty({ctrl->getProperty("audio-params/channels").toString(), - selectedAudioTrack.value(QStringLiteral("demux-channels")).toString(), - audioStream ? audioStream->channelLayout : QString()}); - addLine(lines, tr("Channels"), - joinNonEmpty({channelCount > 0 ? tr("%1 ch").arg(channelCount) : QString(), channelLayout}, - QStringLiteral(" "))); - - int sampleRate = ctrl->getProperty("audio-params/samplerate").toInt(); - if (sampleRate <= 0) - { - sampleRate = selectedAudioTrack.value(QStringLiteral("demux-samplerate")).toInt(); - } - if (sampleRate <= 0 && audioStream) - { - sampleRate = audioStream->sampleRate; - } - addLine(lines, tr("Sample Rate"), sampleRate > 0 ? QStringLiteral("%1 Hz").arg(sampleRate) : QString()); - - qint64 audioBitrate = ctrl->getProperty("audio-bitrate").toLongLong(); - if (audioBitrate <= 0 && audioStream) - { - audioBitrate = audioStream->bitRate; - } - addLine(lines, tr("Bitrate"), formatBitrateValue(audioBitrate)); - - if (!selectedSubtitleTrack.isEmpty()) - { - const QString subtitleLabel = firstNonEmpty({selectedSubtitleTrack.value(QStringLiteral("title")).toString(), - selectedSubtitleTrack.value(QStringLiteral("lang")).toString()}); - if (!subtitleLabel.isEmpty()) - { - addLine(lines, tr("Subtitle"), subtitleLabel); - } - } - - m_statisticsOverlay->setLines(lines); - if (m_showStatisticsOverlay) - { - updateOverlayLayout(); - } -} - -void PlayerView::toggleFullscreenWindow() -{ - if (window()->isFullScreen()) - { - window()->showNormal(); - } - else - { - window()->showFullScreen(); - } -} - - -QCoro::Task PlayerView::executeFetchLogo(QPointer safeThis, QEmbyCore *core, QString mediaId) -{ - try - { - - MediaItem detail = co_await core->mediaService()->getItemDetail(mediaId); - - - if (!safeThis || safeThis->m_currentMediaId != mediaId) - { - co_return; - } - - if (!detail.images.logoTag.isEmpty()) - { - QPixmap pix = co_await core->mediaService()->fetchImage(mediaId, "Logo", detail.images.logoTag, 400); - - if (safeThis && safeThis->m_currentMediaId == mediaId && !pix.isNull()) - { - - QPixmap scaledPix = pix.scaled(140, 55, Qt::KeepAspectRatio, Qt::SmoothTransformation); - safeThis->m_logoLabel->setPixmap(scaledPix); - safeThis->m_logoLabel->adjustSize(); - - - - safeThis->m_logoOpacity->setOpacity(safeThis->m_topOpacity->opacity()); - safeThis->m_logoLabel->show(); - safeThis->updateOverlayLayout(); - } - } - } - catch (...) - { - - } -} - -void PlayerView::playMedia(const QString &mediaId, const QString &title, const QString &streamUrl, - long long startPositionTicks, const QVariant &sourceInfoVar) -{ - PlayerLaunchContext launchContext; - MediaSourceInfo resolvedSourceInfo; - MediaItem resolvedItem; - if (sourceInfoVar.isValid() && sourceInfoVar.canConvert()) - { - launchContext = sourceInfoVar.value(); - resolvedItem = launchContext.mediaItem; - resolvedSourceInfo = launchContext.selectedSource; - } - else if (sourceInfoVar.isValid() && sourceInfoVar.canConvert()) - { - resolvedSourceInfo = sourceInfoVar.value(); - } - - connect(m_mpvWidget, &MpvWidget::positionChanged, this, &PlayerView::onPositionChanged, Qt::UniqueConnection); - - m_hasReportedStop = false; - m_isPlaybackFinished = false; - m_autoPlayAdvanceInProgress = false; - m_bufferedPosition = 0.0; - m_bufferTimerTick = 0; - m_currentMediaId = mediaId; - m_currentMediaSourceId = mediaId; - m_currentPlaySessionId.clear(); - m_currentMediaItem = resolvedItem; - m_currentMediaSourceInfo = resolvedSourceInfo; - hideRightSidebar(true); - hideHudMediaSwitcher(); - if (m_activePopup) - { - m_activePopup->deleteLater(); - m_activePopup = nullptr; - } - closeActivePlayerDialog(); - - - m_logoLabel->clear(); - m_logoLabel->hide(); - - - m_originalStreamUrl = streamUrl; - m_currentSourceInfoVar = sourceInfoVar; - - QString actualStreamUrl = streamUrl; - if (!resolvedSourceInfo.id.isEmpty()) - { - QString directUrl = m_core->mediaService()->getStreamUrl(mediaId, resolvedSourceInfo); - if (!directUrl.isEmpty()) - { - actualStreamUrl = directUrl; - } - } - - QUrl streamQUrl(actualStreamUrl); - QUrl origQUrl(streamUrl); - - QUrlQuery query(streamQUrl); - if (!query.hasQueryItem("mediaSourceId") && QUrlQuery(origQUrl).hasQueryItem("mediaSourceId")) - { - query = QUrlQuery(origQUrl); - } - - if (query.hasQueryItem("mediaSourceId")) - { - m_currentMediaSourceId = query.queryItemValue("mediaSourceId"); - } - else if (query.hasQueryItem("MediaSourceId")) - { - m_currentMediaSourceId = query.queryItemValue("MediaSourceId"); - } - - if (m_currentMediaSourceInfo.id.isEmpty() && !m_currentMediaItem.mediaSources.isEmpty()) - { - for (const MediaSourceInfo &candidate : m_currentMediaItem.mediaSources) - { - if (!m_currentMediaSourceId.isEmpty() && - candidate.id.compare(m_currentMediaSourceId, Qt::CaseInsensitive) == 0) - { - m_currentMediaSourceInfo = candidate; - break; - } - } - - if (m_currentMediaSourceInfo.id.isEmpty()) - { - m_currentMediaSourceInfo = m_currentMediaItem.mediaSources.first(); - } - } - - m_pendingSeekSeconds = startPositionTicks / 10000000.0; - - QString displayTitle = title.trimmed(); - if (!m_currentMediaItem.id.isEmpty()) - { - const QString resolvedTitle = MediaItemUtils::playbackTitle(m_currentMediaItem, displayTitle).trimmed(); - if (!resolvedTitle.isEmpty()) - { - displayTitle = resolvedTitle; - } - } - if (displayTitle.isEmpty()) - { - displayTitle = title; - } - - m_fullTitle = displayTitle; - Q_EMIT playbackTitleChanged(m_fullTitle); - updateTitleElision(); - - m_currentPosition = m_pendingSeekSeconds; - m_totalDuration = 0.0; - updateBufferProgress(); - - if (m_statisticsOverlay) - { - if (m_showStatisticsOverlay) - { - updateStatisticsDisplay(); - m_statisticsOverlay->show(); - } - else - { - m_statisticsOverlay->hide(); - } - } - - QWidget *win = window(); - if (win) - { - m_wasMaximized = win->isMaximized(); - m_originalGeometry = win->geometry(); - m_hasSetVideoSize = false; - - - if (m_wasMaximized) - { - win->showFullScreen(); - } - } - - - - - - m_isSeriesMode = false; - m_seriesId.clear(); - m_seriesName.clear(); - m_episodeSegments = {}; - m_introSkipped = false; - m_outroSkipped = false; - m_segmentsRequested = false; - clearMediaSwitcherCache(); - updateMediaSwitcherButton(); - - if (resolvedItem.type == "Episode" && !resolvedItem.seriesId.isEmpty()) - { - m_isSeriesMode = true; - m_seriesId = resolvedItem.seriesId; - m_seriesName = resolvedItem.seriesName; - updateMediaSwitcherButton(); - requestIntroDBSegments(); - - ensureMediaSwitcherDataLoaded(); - } - else - { - - auto detectSeriesMode = [](QPointer safeThis, QEmbyCore *core, QString mId) -> QCoro::Task - { - try - { - MediaItem detail = co_await core->mediaService()->getItemDetail(mId); - if (!safeThis || safeThis->m_currentMediaId != mId) - co_return; - if (detail.type == "Episode" && !detail.seriesId.isEmpty()) - { - safeThis->m_isSeriesMode = true; - safeThis->m_seriesId = detail.seriesId; - safeThis->m_seriesName = detail.seriesName; - safeThis->m_currentMediaItem = detail; - safeThis->clearMediaSwitcherCache(); - safeThis->updateMediaSwitcherButton(); - safeThis->requestIntroDBSegments(); - - safeThis->ensureMediaSwitcherDataLoaded(); - if (safeThis->useHudMediaSwitcher() && safeThis->m_mediaSwitchDrawer && - safeThis->m_mediaSwitchDrawer->isVisible()) - { - safeThis->showHudMediaSwitcher(); - } - else if (safeThis->m_isRightSidebarVisible) - { - safeThis->showRightSidebar(); - } - } - } - catch (...) - { - } - }; - detectSeriesMode(QPointer(this), m_core, mediaId); - - ensureMediaSwitcherDataLoaded(); - } - - - - - executeFetchLogo(QPointer(this), m_core, mediaId); - - - - const QString activeServerId = m_core->serverManager()->activeProfile().id; - m_mpvWidget->loadMedia(actualStreamUrl, activeServerId); - - if (!resolvedItem.id.isEmpty() || !resolvedSourceInfo.id.isEmpty()) - { - if (resolvedItem.id.isEmpty()) - { - resolvedItem.id = mediaId; - resolvedItem.name = title; - } - PlayerLaunchContext danmakuContext; - danmakuContext.mediaItem = resolvedItem; - danmakuContext.selectedSource = resolvedSourceInfo; - qDebug().noquote() << "[Danmaku][PlayerView] Prepared playback context" - << "| mediaId:" << danmakuContext.mediaItem.id - << "| sourceId:" << danmakuContext.selectedSource.id - << "| title:" << danmakuContext.mediaItem.name; - m_danmakuController->setPlaybackContext(danmakuContext); - } - else - { - qDebug().noquote() << "[Danmaku][PlayerView] No playback context available for danmaku" - << "| mediaId:" << mediaId << "| sourceInfoValid:" << sourceInfoVar.isValid(); - m_danmakuController->clearPlaybackContext(); - } - - - - - m_isBuffering = true; - m_isSeeking = false; - updateLoadingState(); - - - - - m_targetAudioStreamIndex = -2; - m_targetSubStreamIndex = -2; - - QVariantList pendingSubtitles; - - if (!resolvedSourceInfo.id.isEmpty()) - { - MediaSourceInfo sourceInfo = resolvedSourceInfo; - - m_targetAudioStreamIndex = -2; - m_targetSubStreamIndex = -2; - bool hasExternalDefault = false; - bool foundDefaultAudio = false; - - for (const auto &stream : sourceInfo.mediaStreams) - { - if (stream.type == "Audio" && stream.isDefault) - { - m_targetAudioStreamIndex = stream.index; - foundDefaultAudio = true; - } - else if (stream.type == "Subtitle" && stream.isDefault) - { - if (stream.isExternal) - { - hasExternalDefault = true; - } - else - { - m_targetSubStreamIndex = stream.index; - } - } - } - - - - - - if (hasExternalDefault) - { - m_targetSubStreamIndex = -2; - } - - QString tokenQuery; - if (query.hasQueryItem("api_key")) - { - tokenQuery = "api_key=" + query.queryItemValue("api_key"); - } - else if (query.hasQueryItem("X-Emby-Token")) - { - tokenQuery = "X-Emby-Token=" + query.queryItemValue("X-Emby-Token"); - } - else if (query.hasQueryItem("api_token")) - { - tokenQuery = "api_token=" + query.queryItemValue("api_token"); - } - - - - QString baseUrl; - int videosIdx = streamUrl.indexOf("/videos/", 0, Qt::CaseInsensitive); - if (videosIdx != -1) - { - baseUrl = streamUrl.left(videosIdx); - } - else - { - baseUrl = origQUrl.scheme() + "://" + origQUrl.authority(); - } - - for (const auto &stream : sourceInfo.mediaStreams) - { - - - if (stream.type == "Subtitle" && stream.isExternal) - { - QString subUrl = stream.deliveryUrl; - - - if (subUrl.isEmpty()) - { - QString codec = stream.codec.toLower(); - if (codec.isEmpty() || codec == "subrip") - { - codec = "srt"; - } - - - subUrl = QString("/Videos/%1/%2/Subtitles/%3/Stream.%4") - .arg(mediaId) - .arg(sourceInfo.id) - .arg(stream.index) - .arg(codec); - } - - - if (!subUrl.startsWith("http")) - { - if (!subUrl.startsWith("/")) - { - subUrl = "/" + subUrl; - } - subUrl = baseUrl + subUrl; - - - if (!tokenQuery.isEmpty()) - { - subUrl += (subUrl.contains("?") ? "&" : "?") + tokenQuery; - } - } - - QString subTitle = stream.displayTitle.isEmpty() ? stream.language : stream.displayTitle; - if (subTitle.isEmpty()) - { - subTitle = tr("External Sub %1").arg(stream.index); - } - - - - QString flag = stream.isDefault ? "select" : "auto"; - - - QVariantMap subMap; - subMap["url"] = subUrl; - subMap["flag"] = flag; - subMap["title"] = subTitle; - subMap["lang"] = stream.language; - pendingSubtitles.append(subMap); - } - } - } - - - setProperty("pendingSubtitles", pendingSubtitles); - - m_mpvWidget->play(); - - m_isPlaying = true; - updatePowerInhibition(); - m_playPauseBtn->setIcon(QIcon(":/svg/player/pause.svg")); - - - m_currentVolume = ConfigStore::instance()->get(ConfigKeys::PlayerVolumeLevel, 100.0); - m_isMuted = ConfigStore::instance()->get(ConfigKeys::PlayerVolumeMuted, false); - - m_mpvWidget->controller()->setProperty("volume", m_currentVolume); - m_mpvWidget->controller()->setProperty("mute", m_isMuted); - - - - m_volumeSlider->blockSignals(true); - m_volumeSlider->setValue(m_isMuted ? 0 : static_cast(m_currentVolume)); - m_volumeSlider->blockSignals(false); - m_volumeBtn->setIcon(QIcon(m_isMuted ? ":/svg/player/volume-mute.svg" : ":/svg/player/volume.svg")); - - - m_videoScaleMode = ConfigStore::instance()->get(ConfigKeys::PlayerDefaultScale, 1); - setScaleIcon(); - - switch (m_videoScaleMode) - { - case 0: - m_mpvWidget->controller()->setProperty("keepaspect", true); - m_mpvWidget->controller()->setProperty("panscan", 0.0); - m_mpvWidget->controller()->setProperty("video-unscaled", false); - break; - case 1: - m_mpvWidget->controller()->setProperty("keepaspect", true); - m_mpvWidget->controller()->setProperty("panscan", 1.0); - m_mpvWidget->controller()->setProperty("video-unscaled", false); - break; - case 2: - m_mpvWidget->controller()->setProperty("keepaspect", false); - m_mpvWidget->controller()->setProperty("panscan", 0.0); - m_mpvWidget->controller()->setProperty("video-unscaled", false); - break; - case 3: - m_mpvWidget->controller()->setProperty("keepaspect", true); - m_mpvWidget->controller()->setProperty("panscan", 0.0); - m_mpvWidget->controller()->setProperty("video-unscaled", true); - break; - } - - - auto startSessionTask = [](QPointer safeThis, MediaService *s, QString mId, QString sId, - long long ticks) -> QCoro::Task - { - QString sessionId = co_await s->reportPlaybackStart(mId, sId, ticks); - if (safeThis && safeThis->m_currentMediaId == mId) - { - safeThis->m_currentPlaySessionId = sessionId; - } - }; - startSessionTask(QPointer(this), m_core->mediaService(), m_currentMediaId, m_currentMediaSourceId, - startPositionTicks); - - m_reportTimer->start(); - m_bufferTimer->start(); - showControls(); -} - -void PlayerView::reportProgressToServer() -{ - if (m_isViewTearingDown || m_currentMediaId.isEmpty() || m_hasReportedStop || m_currentPlaySessionId.isEmpty()) - { - return; - } - long long currentTicks = static_cast(m_currentPosition * 10000000.0); - - m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks, !m_isPlaying, - m_currentPlaySessionId); - - if (m_showStatisticsOverlay) - { - updateStatisticsDisplay(); - } -} - -void PlayerView::onBackClicked() -{ - if (m_isViewTearingDown) - { - return; - } - - - - prepareForStackLeave(); - QTimer::singleShot(0, this, - [this]() - { - if (!m_hasReportedStop) - { - return; - } - Q_EMIT navigateBack(); - }); - - - - QWidget *win = window(); - bool isEmbedded = qobject_cast(win) != nullptr; - bool wasMaximized = m_wasMaximized; - QRect originalGeo = m_originalGeometry; - if (win) - { - QPointer safeWin(win); - QTimer::singleShot(0, this, - [safeWin, isEmbedded, wasMaximized, originalGeo]() - { - if (!safeWin) - return; - - if (isEmbedded) - { - - - if (safeWin->isFullScreen()) - { - safeWin->showMaximized(); - } - - } - else - { - - if (safeWin->isFullScreen()) - { - safeWin->showNormal(); - } - if (wasMaximized) - { - safeWin->showMaximized(); - } - else if (originalGeo.isValid()) - { - safeWin->setGeometry(originalGeo); - } - } - }); - } -} - -void PlayerView::onPositionChanged(double position) -{ - if (m_isViewTearingDown) - { - return; - } - - if (std::isnan(position) || std::isinf(position) || position < 0) - { - position = 0.0; - } - m_currentPosition = position; - - checkAndSkipSegment(position); - - if (!m_progressSlider->isSliderDown()) - { - m_progressSlider->blockSignals(true); - m_progressSlider->setValue(static_cast(position)); - m_progressSlider->blockSignals(false); - } - - m_currentTimeLabel->setText(formatTime(position, m_totalDuration)); - - - if (m_osdLayer && m_osdLayer->isSeekLineVisible()) - { - if (m_osdSeekPreviewPosition < 0.0 || std::abs(position - m_osdSeekPreviewPosition) <= 1.0) - { - m_osdSeekPreviewPosition = position; - m_osdLayer->updateSeekPosition(static_cast(position), formatTime(position, m_totalDuration)); - } - } -} - -void PlayerView::onDurationChanged(double duration) -{ - if (m_isViewTearingDown) - { - return; - } - - if (std::isnan(duration) || std::isinf(duration) || duration < 0) - { - duration = 0.0; - } - m_totalDuration = duration; - m_progressSlider->setMaximum(static_cast(duration)); - m_totalTimeLabel->setText(formatTime(duration, duration)); - - - - if (!m_hasSetVideoSize && duration > 0) - { - m_hasSetVideoSize = true; - - - double dw = m_mpvWidget->controller()->getProperty("video-params/dw").toDouble(); - double dh = m_mpvWidget->controller()->getProperty("video-params/dh").toDouble(); - if (dw <= 0 || dh <= 0) - { - dw = m_mpvWidget->controller()->getProperty("width").toDouble(); - dh = m_mpvWidget->controller()->getProperty("height").toDouble(); - } - - QWidget *win = window(); - - - if (win && !m_wasMaximized && !win->isFullScreen() && !qobject_cast(win)) - { - double aspect = (dw > 0 && dh > 0) ? (dw / dh) : (16.0 / 9.0); - - - int playerW = this->width(); - if (playerW < 100) - { - playerW = win->width(); - } - - - int targetPlayerH = qRound(playerW / aspect); - - - if (playerW % 2 != 0) - { - playerW++; - } - if (targetPlayerH % 2 != 0) - { - targetPlayerH++; - } - - - int deltaH = targetPlayerH - this->height(); - - - int targetWinW = win->width() + (playerW - this->width()); - int targetWinH = win->height() + deltaH; - - QRect currentGeo = win->geometry(); - win->setGeometry(currentGeo.center().x() - targetWinW / 2, currentGeo.center().y() - targetWinH / 2, - targetWinW, targetWinH); - } - - - - - if (m_targetAudioStreamIndex != -2 || m_targetSubStreamIndex != -2) - { - QVariantList tracks = m_mpvWidget->controller()->getProperty("track-list").toList(); - - - if (m_targetAudioStreamIndex == -1) - { - m_mpvWidget->controller()->setProperty("aid", "no"); - } - if (m_targetSubStreamIndex == -1) - { - m_mpvWidget->controller()->setProperty("sid", "no"); - } - - for (const QVariant &v : tracks) - { - QVariantMap map = v.toMap(); - QString type = map["type"].toString(); - int ffIndex = map["ff-index"].toInt(); - int mpvId = map["id"].toInt(); - - if (type == "audio" && m_targetAudioStreamIndex >= 0 && ffIndex == m_targetAudioStreamIndex) - { - m_mpvWidget->controller()->setProperty("aid", mpvId); - } - if (type == "sub" && m_targetSubStreamIndex >= 0 && ffIndex == m_targetSubStreamIndex) - { - m_mpvWidget->controller()->setProperty("sid", mpvId); - } - } - } - - - QVariantList pendingSubs = property("pendingSubtitles").toList(); - for (const QVariant &v : pendingSubs) - { - QVariantMap map = v.toMap(); - m_mpvWidget->controller()->command(QVariantList{"sub-add", map["url"].toString(), map["flag"].toString(), - map["title"].toString(), map["lang"].toString()}); - } - setProperty("pendingSubtitles", QVariantList()); - - - - applyPersistedExternalSubtitleIfAny(); - } - - if (m_pendingSeekSeconds > 0 && duration > 0) - { - m_mpvWidget->seek(m_pendingSeekSeconds); - m_pendingSeekSeconds = 0.0; - } -} - -void PlayerView::onPlaybackStateChanged(bool isPaused) -{ - if (m_isViewTearingDown) - { - return; - } - - m_isPlaying = !isPaused; - updatePowerInhibition(); - m_playPauseBtn->setIcon(QIcon(m_isPlaying ? ":/svg/player/pause.svg" : ":/svg/player/play.svg")); - - - showControls(); - - if (!m_hasReportedStop && !m_currentMediaId.isEmpty() && !m_currentPlaySessionId.isEmpty()) - { - long long currentTicks = static_cast(m_currentPosition * 10000000.0); - m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks, isPaused, - m_currentPlaySessionId); - } -} - -void PlayerView::togglePlayPause() -{ - if (m_isPlaying) - { - m_mpvWidget->pause(); - } - else if (m_isPlaybackFinished) - { - m_currentPosition = 0.0; - m_mpvWidget->seek(0.0); - if (m_progressSlider) - { - m_progressSlider->blockSignals(true); - m_progressSlider->setValue(0); - m_progressSlider->blockSignals(false); - } - m_currentTimeLabel->setText(formatTime(m_currentPosition, m_totalDuration)); - resumePlaybackAfterFinishedSeek(); - } - else - { - m_mpvWidget->play(); - } - - showControls(); -} - -void PlayerView::onSliderMoved(int value) -{ - const bool shouldResumeFinishedPlayback = - m_isPlaybackFinished && (!m_progressSlider || value < m_progressSlider->maximum()); - - m_mpvWidget->seek(static_cast(value)); - m_currentPosition = static_cast(value); - m_currentTimeLabel->setText(formatTime(m_currentPosition, m_totalDuration)); - - if (shouldResumeFinishedPlayback) - { - resumePlaybackAfterFinishedSeek(); - } - - if (!m_hasReportedStop && !m_currentMediaId.isEmpty() && !m_currentPlaySessionId.isEmpty()) - { - long long currentTicks = static_cast(value * 10000000.0); - m_core->mediaService()->reportPlaybackProgress(m_currentMediaId, m_currentMediaSourceId, currentTicks, - !m_isPlaying, m_currentPlaySessionId); - } - - showControls(); -} - -QString PlayerView::formatTime(double seconds, double totalSeconds) const -{ - QTime t(0, 0, 0); - t = t.addSecs(static_cast(seconds)); - QTime totalT(0, 0, 0); - totalT = totalT.addSecs(static_cast(totalSeconds)); - - if (totalT.hour() > 0) - { - return t.toString("hh:mm:ss"); - } - else - { - return t.toString("mm:ss"); - } -} diff --git a/src/qEmbyApp/views/media/playerview.h b/src/qEmbyApp/views/media/playerview.h deleted file mode 100644 index 5164fb5..0000000 --- a/src/qEmbyApp/views/media/playerview.h +++ /dev/null @@ -1,348 +0,0 @@ -#ifndef PLAYERVIEW_H -#define PLAYERVIEW_H - -#include "../baseview.h" -#include "../../components/mpvwidget.h" -#include "../../components/modernslider.h" -#include "../../components/loadingoverlay.h" -#include "../../components/playerdanmakucontroller.h" -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class QEmbyCore; -class PlayerOverlayDialog; -class ShadowOverlay; -class PlayerMediaSwitcherPanel; -class PlayerOsdLayer; -class PlayerLongPressHandler; -class PlayerStatisticsOverlay; -class NativeDanmakuOverlay; -class SeekTimeBubble; - -class PlayerView : public BaseView { - Q_OBJECT -public: - explicit PlayerView(QEmbyCore *core, QWidget *parent = nullptr); - ~PlayerView() override; - void prepareForStackLeave() override; - - - void playMedia(const QString &mediaId, const QString &title, const QString &streamUrl, long long startPositionTicks = 0, const QVariant& sourceInfoVar = QVariant()); - - - bool isMediaPlaying() const; - void pausePlayback(); - void resumePlayback(); - void stopAndReport(); - -signals: - void playerChromeVisibilityChanged(bool visible); - void playbackTitleChanged(const QString &title); - -protected: - void resizeEvent(QResizeEvent *event) override; - bool eventFilter(QObject *watched, QEvent *event) override; - - void keyPressEvent(QKeyEvent *event) override; - void keyReleaseEvent(QKeyEvent *event) override; - -private slots: - void onPositionChanged(double position); - void onDurationChanged(double duration); - void onPlaybackStateChanged(bool isPaused); - void onMpvPropertyChanged(const QString &property, const QVariant &value); - - void togglePlayPause(); - void onSliderMoved(int value); - - - void seekRelative(double delta, bool silent = false); - - - void toggleMute(); - void changeVolume(int delta, bool silent = false); - void onVolumeSliderMoved(int value); - - void showSpeedMenu(); - void showAudioMenu(); - void showSubtitleMenu(); - void showDanmakuMenu(); - void showDanmakuIdentifyDialog(); - void loadLocalDanmakuFile(); - void loadExternalSubtitleFile(); - void openSubtitleSettingsDialog(); - void openDanmakuSettingsDialog(); - void showSettingsMenu(); - - void cycleVideoScale(); - void toggleFullscreenWindow(); - - void showControls(); - void hideControls(); - void reportProgressToServer(); - void onBackClicked(); - - - - - void updateLoadingState(); - void updateDanmakuButtonState(); - -private: - void setupUi(); - void updateTitleElision(); - void updateOverlayLayout(); - void clearMediaSwitcherCache(); - void applyMediaSwitcherMode(); - void updateMediaSwitcherButton(); - bool shouldShowDanmakuHudControls() const; - bool useHudMediaSwitcher() const; - QString formatMediaSwitcherPlaybackTitle(const MediaItem &item) const; - bool findNextResumeMediaFromCache(QString &mediaId, - QString &title, - long long &startPositionTicks, - bool skipCurrentSeries) const; - void populateRightSidebarFromCache(); - void showHudMediaSwitcher(); - void hideHudMediaSwitcher(); - void syncHudMediaSwitcherContent(); - bool findAdjacentMediaFromCache(int direction, QString &mediaId, - QString &title, - long long &startPositionTicks) const; - - - void setupRightSidebar(); - - - QCoro::Task showRightSidebar(); - QCoro::Task ensureMediaSwitcherDataLoaded(); - QCoro::Task autoPlayNextMediaIfEnabled(); - QCoro::Task switchFromMediaSwitcher(QString mediaId, - QString title, - long long startPositionTicks); - - void hideRightSidebar(bool immediate = false); - void syncRightSidebarShadow(); - void setEffectivePlaybackSpeed(double speed); - void handlePointerActivity(const QPoint &globalPos); - void setCursorHidden(bool hidden); - bool areControlsFullyVisible() const; - void setPlayerChromeVisible(bool visible); - void updateBufferProgress(); - - void stopTransientUiAnimations(bool immediate = false); - void beginViewTeardown(); - - QPushButton* createHudButton(const QString& iconPath, const QSize& size = QSize(24, 24)); - QString formatTime(double seconds, double totalSeconds) const; - void applySubtitleStyleSettings(); - void resumePlaybackAfterFinishedSeek(); - - - QString externalSubtitleConfigKey() const; - QString readPersistedExternalSubtitle() const; - void persistExternalSubtitle(const QString &absPath); - void clearPersistedExternalSubtitle(); - void applyPersistedExternalSubtitleIfAny(); - QString openPlayerFileDialog(const QString &title, const QString &startDir, - const QString &filter); - - int findSubtitleTrackIdByPath(const QString &absPath) const; - - void showToast(const QString& msg); - void updateStatisticsDisplay(); - void setScaleIcon(); - QString formatDanmakuProviderLabel(QString provider) const; - QString formatDanmakuSourceServiceLabel(QString provider, - QString serverName) const; - QString buildDanmakuSummaryText() const; - QString buildDanmakuTooltipText() const; - void closeActivePlayerDialog(); - void trackPlayerDialog(PlayerOverlayDialog *dialog); - void updatePowerInhibition(); - QCoro::Task requestIntroDBSegments(); - void checkAndSkipSegment(double position); - - void showCenteredPopup(QWidget* popup, QPushButton* btn); - QWidget* m_activePopup = nullptr; - QPointer m_activePlayerDialog; - - - static QCoro::Task executeFetchLogo(QPointer safeThis, QEmbyCore* core, QString mediaId); - - MpvWidget *m_mpvWidget; - NativeDanmakuOverlay *m_nativeDanmakuOverlay = nullptr; - - - QWidget *m_topHUD; - QWidget *m_bottomHUD; - PlayerStatisticsOverlay *m_statisticsOverlay; - LoadingOverlay *m_loadingOverlay; - - - QLabel *m_logoLabel; - QGraphicsOpacityEffect *m_logoOpacity; - - - QGraphicsOpacityEffect *m_speedOpacity; - - - PlayerOsdLayer *m_osdLayer = nullptr; - - PlayerLongPressHandler *m_longPressHandler = nullptr; - - - QWidget *m_rightSidebar; - ShadowOverlay *m_rightSidebarShadow = nullptr; - QWidget *m_rightTrigger; - QPropertyAnimation *m_rightSidebarAnim; - - - QLabel *m_sidebarTitleLabel; - class QListWidget *m_resumeList; - - QPushButton *m_backBtn; - QLabel *m_titleLabel; - QPushButton *m_minBtn; - QPushButton *m_maxBtn; - QPushButton *m_closeBtn; - QLabel *m_networkSpeedLabel; - - - QLabel *m_currentTimeLabel; - ModernSlider *m_progressSlider; - QLabel *m_totalTimeLabel; - SeekTimeBubble *m_seekBubble = nullptr; - - - QPushButton *m_prevMediaBtn; - QPushButton *m_playPauseBtn; - QPushButton *m_rewindBtn; - QPushButton *m_forwardBtn; - QPushButton *m_nextMediaBtn; - - - QPushButton *m_volumeBtn; - ModernSlider *m_volumeSlider; - - QLabel *m_toastLabel; - QTimer *m_toastTimer; - - QPushButton *m_speedBtn = nullptr; - QPushButton *m_mediaSwitchBtn = nullptr; - QPushButton *m_audioBtn = nullptr; - QPushButton *m_subtitleBtn = nullptr; - QPushButton *m_danmakuBtn = nullptr; - QPushButton *m_settingsBtn = nullptr; - QPushButton *m_scaleBtn = nullptr; - QPushButton *m_fullscreenBtn = nullptr; - PlayerMediaSwitcherPanel *m_mediaSwitchDrawer = nullptr; - int m_bottomHudBaseHeight = 110; - - PlayerDanmakuController *m_danmakuController = nullptr; - - QGraphicsOpacityEffect *m_topOpacity; - QGraphicsOpacityEffect *m_bottomOpacity; - QParallelAnimationGroup *m_fadeGroup; - QTimer *m_hideTimer; - QTimer *m_reportTimer; - - - QTimer *m_bufferTimer; - int m_bufferTimerTick = 0; - - QString m_currentMediaId; - QString m_currentMediaSourceId; - QString m_currentPlaySessionId; - MediaItem m_currentMediaItem; - MediaSourceInfo m_currentMediaSourceInfo; - - - QString m_originalStreamUrl; - QVariant m_currentSourceInfoVar; - - bool m_isPlaying; - bool m_isBuffering = false; - bool m_isSeeking = false; - bool m_playerChromeVisible = true; - - double m_currentPosition; - double m_totalDuration = 0.0; - double m_bufferedPosition = 0.0; - double m_pendingSeekSeconds = 0.0; - double m_osdSeekPreviewPosition = -1.0; - - double m_currentSpeed = 1.0; - int m_videoScaleMode = 1; - - - double m_currentVolume = 100.0; - bool m_isMuted = false; - - bool m_showNetworkSpeed = true; - bool m_useRelayNetworkSpeed = false; - bool m_showStatisticsOverlay = false; - qint64 m_effectiveNetworkSpeed = 0; - bool m_hasSetVideoSize = false; - bool m_hasReportedStop = false; - bool m_isPlaybackFinished = false; - bool m_autoPlayAdvanceInProgress = false; - bool m_isViewTearingDown = false; - bool m_powerInhibitionHeld = false; - bool m_isRightSidebarVisible = false; - - - QString m_switcherPendingItemId; - QString m_switcherPendingTitle; - long long m_switcherPendingTicks = 0; - - - bool m_isSeriesMode = false; - QString m_seriesId; - QString m_seriesName; - - - IntroDBService::EpisodeSegments m_episodeSegments; - bool m_introSkipped = false; - bool m_outroSkipped = false; - bool m_segmentsRequested = false; - - - QString m_switcherCacheMediaId; - bool m_switcherCacheReady = false; - QList m_switcherResumeItems; - QList m_switcherSeriesSeasons; - QHash> m_switcherSeasonEpisodes; - - QString m_fullTitle; - - QRect m_originalGeometry; - bool m_wasMaximized = false; - QPoint m_dragPos; - bool m_didDrag = false; - QTimer *m_singleClickTimer = nullptr; - - - int m_targetAudioStreamIndex = -2; - int m_targetSubStreamIndex = -2; -}; - -#endif diff --git a/src/qEmbyApp/views/media/seasonview.cpp b/src/qEmbyApp/views/media/seasonview.cpp deleted file mode 100644 index 6e97423..0000000 --- a/src/qEmbyApp/views/media/seasonview.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include "seasonview.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "seasonviewmodel.h" - -#include -#include -#include -#include -#include - -SeasonView::SeasonView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setAttribute(Qt::WA_StyledBackground, true); - setObjectName(QStringLiteral("season-view")); - setProperty("showGlobalSearch", true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new SeasonViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("seasonPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SeasonPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, this, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, this, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &SeasonViewModel::episodeActivated, this, - [this](const MediaItem &item) { - emit navigateToDetail(item.id, item.name, item); - }); - connect(m_viewModel, &SeasonViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &SeasonViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &SeasonViewModel::moreRequested, this, - [this](const MediaItem &item) { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -QCoro::Task SeasonView::loadSeason(QString seriesId, QString seasonId, - QString seasonName) { - co_await m_viewModel->loadSeason(std::move(seriesId), std::move(seasonId), - std::move(seasonName)); -} - -void SeasonView::prepareForStackLeave() { m_viewModel->setActive(false); } - -void SeasonView::scrollToTop() { - if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) - QMetaObject::invokeMethod(root, "scrollToTop"); -} - -void SeasonView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void SeasonView::onMediaItemRemoved(const QString &itemId) { - m_viewModel->removeItem(itemId); -} diff --git a/src/qEmbyApp/views/media/seasonview.h b/src/qEmbyApp/views/media/seasonview.h deleted file mode 100644 index 3d56d25..0000000 --- a/src/qEmbyApp/views/media/seasonview.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SEASONVIEW_H -#define SEASONVIEW_H - -#include "../baseview.h" - -#include - -class QuickPageHost; -class SeasonViewModel; - -class SeasonView final : public BaseView { - Q_OBJECT - -public: - explicit SeasonView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task loadSeason(QString seriesId, QString seasonId, - QString seasonName); - void prepareForStackLeave() override; - -public slots: - void scrollToTop() override; - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - -private: - QuickPageHost *m_quickHost = nullptr; - SeasonViewModel *m_viewModel = nullptr; -}; - -#endif // SEASONVIEW_H diff --git a/src/qEmbyApp/views/public/loginview.cpp b/src/qEmbyApp/views/public/loginview.cpp deleted file mode 100644 index d535cbe..0000000 --- a/src/qEmbyApp/views/public/loginview.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "loginview.h" - -#include "../../quick/loginviewmodel.h" -#include "../../quick/quickpagehost.h" - -#include -#include -#include - -LoginView::LoginView(QEmbyCore *core, QWidget *parent) - : QWidget(parent), m_host(new QuickPageHost(this)), - m_viewModel(new LoginViewModel(core, this, this)) -{ - setProperty("showGlobalSearch", false); - setProperty("viewTitle", tr("qEmby")); - setProperty("showGlobalBack", false); - setProperty("showGlobalHome", false); - setProperty("showGlobalFav", false); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(m_host); - - m_host->setContextProperty(QStringLiteral("login"), m_viewModel); - m_host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/LoginPage.qml"))); - - connect(m_viewModel, &LoginViewModel::loginCompleted, - this, &LoginView::loginCompleted); -} - -void LoginView::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - m_viewModel->activate(); -} diff --git a/src/qEmbyApp/views/public/loginview.h b/src/qEmbyApp/views/public/loginview.h deleted file mode 100644 index 4754a01..0000000 --- a/src/qEmbyApp/views/public/loginview.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef LOGINVIEW_H -#define LOGINVIEW_H - -#include - -class LoginViewModel; -class QEmbyCore; -class QuickPageHost; -class QShowEvent; - -// Compatibility shell kept for MainWindow. The implementation is a QML page; -// authentication and profile mutations live in LoginViewModel. -class LoginView : public QWidget -{ - Q_OBJECT -public: - explicit LoginView(QEmbyCore *core, QWidget *parent = nullptr); - -signals: - void loginCompleted(); - -protected: - void showEvent(QShowEvent *event) override; - -private: - QuickPageHost *m_host = nullptr; - LoginViewModel *m_viewModel = nullptr; -}; - -#endif // LOGINVIEW_H diff --git a/src/qEmbyApp/views/search/searchview.cpp b/src/qEmbyApp/views/search/searchview.cpp deleted file mode 100644 index 2e32178..0000000 --- a/src/qEmbyApp/views/search/searchview.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "searchview.h" -#include "searchviewmodel.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" - -#include - -SearchView::SearchView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("search-view")); - setProperty("showGlobalSearch", true); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new SearchViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("searchPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SearchPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_viewModel, &SearchViewModel::itemActivated, this, - [this](const MediaItem &item) { - if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) { - emit navigateToFolder(item.id, item.name); - } else if (item.type == QLatin1String("Person")) { - emit navigateToPerson(item.id, item.name); - } else { - emit navigateToDetail(item.id, item.name, item); - } - }); - connect(m_viewModel, &SearchViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &SearchViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &SearchViewModel::moreRequested, this, - [this](const MediaItem &item, const QPoint &localPos) { - handleMoreMenuRequested(item, m_quickHost->mapToGlobal(localPos)); - }); -} - -QCoro::Task SearchView::performSearch(QString query) { - co_await m_viewModel->search(std::move(query)); -} - -void SearchView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} diff --git a/src/qEmbyApp/views/search/searchview.h b/src/qEmbyApp/views/search/searchview.h deleted file mode 100644 index 3790275..0000000 --- a/src/qEmbyApp/views/search/searchview.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef SEARCHVIEW_H -#define SEARCHVIEW_H - -#include "../baseview.h" -#include - -class QuickPageHost; -class SearchViewModel; - -class SearchView : public BaseView { - Q_OBJECT -public: - explicit SearchView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task performSearch(QString query); - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - -private: - QuickPageHost *m_quickHost = nullptr; - SearchViewModel *m_viewModel = nullptr; -}; - -#endif // SEARCHVIEW_H diff --git a/src/qEmbyApp/views/settings/pageabout.cpp b/src/qEmbyApp/views/settings/pageabout.cpp deleted file mode 100644 index 7622b94..0000000 --- a/src/qEmbyApp/views/settings/pageabout.cpp +++ /dev/null @@ -1,158 +0,0 @@ -#include "pageabout.h" -#include "../../components/flowlayout.h" -#include "../../components/licensesdialog.h" -#include -#include -#include -#include -#include -#include -#include - -namespace { - -struct ThanksEntry { - QString name; - QString contribution; - QString initials; -}; - -} - -PageAbout::PageAbout(QEmbyCore* core, QWidget *parent) - : SettingsPageBase(core, tr("About"), parent) -{ - m_mainLayout->addStretch(1); - - m_logoLabel = new QLabel(this); - QPixmap logoPixmap(":/svg/qemby_logo.svg"); - if (!logoPixmap.isNull()) { - m_logoLabel->setPixmap(logoPixmap.scaled(80, 80, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - } - m_logoLabel->setAlignment(Qt::AlignCenter); - m_logoLabel->setObjectName("AboutLogoLabel"); - m_mainLayout->addWidget(m_logoLabel); - - m_appNameLabel = new QLabel(tr("qEmby"), this); - m_appNameLabel->setAlignment(Qt::AlignCenter); - m_appNameLabel->setObjectName("AboutAppNameLabel"); - m_mainLayout->addWidget(m_appNameLabel); - - QString versionStr = QCoreApplication::applicationVersion(); - if (versionStr.isEmpty()) { - versionStr = "0.0.1"; - } - m_versionLabel = new QLabel(tr("Version %1").arg(versionStr), this); - m_versionLabel->setAlignment(Qt::AlignCenter); - m_versionLabel->setObjectName("AboutVersionLabel"); - m_mainLayout->addWidget(m_versionLabel); - - m_authorLabel = new QLabel(tr("© 2026 AlanHJ. All rights reserved."), this); - m_authorLabel->setAlignment(Qt::AlignCenter); - m_authorLabel->setObjectName("AboutAuthorLabel"); - m_mainLayout->addWidget(m_authorLabel); - - m_linkLabel = new QLabel(this); - m_linkLabel->setText( - QString("%1  |  " - "%2") - .arg(tr("GitHub Repository")) - .arg(tr("Report an Issue")) - ); - m_linkLabel->setOpenExternalLinks(true); - m_linkLabel->setAlignment(Qt::AlignCenter); - m_linkLabel->setObjectName("AboutLinkLabel"); - m_mainLayout->addWidget(m_linkLabel); - - QFrame *line = new QFrame(this); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - line->setObjectName("AboutSeparatorLine"); - m_mainLayout->addWidget(line); - - m_licenseLabel = new QLabel( - tr("This project is open-source software built with Qt 6 and modern C++.\n" - "Special thanks to the Emby and Jellyfin communities for their fantastic APIs."), this); - m_licenseLabel->setAlignment(Qt::AlignCenter); - m_licenseLabel->setWordWrap(true); - m_licenseLabel->setObjectName("AboutIntroLabel"); - m_mainLayout->addWidget(m_licenseLabel); - - m_mainLayout->addWidget(createAcknowledgementsPanel(), 0, Qt::AlignHCenter); - - m_mainLayout->addSpacing(25); - - m_licenseBtn = new QPushButton(tr("Open Source Licenses"), this); - m_licenseBtn->setObjectName("AboutLicenseBtn"); - m_licenseBtn->setCursor(Qt::PointingHandCursor); - connect(m_licenseBtn, &QPushButton::clicked, this, &PageAbout::showLicensesDialog); - - m_mainLayout->addWidget(m_licenseBtn, 0, Qt::AlignHCenter); - - m_mainLayout->addStretch(2); -} - -QWidget *PageAbout::createAcknowledgementsPanel() -{ - auto *thanksPanel = new QFrame(this); - thanksPanel->setObjectName("AboutThanksPanel"); - - auto *thanksLayout = new QVBoxLayout(thanksPanel); - thanksLayout->setContentsMargins(0, 0, 0, 0); - thanksLayout->setSpacing(10); - - auto *thanksTitle = new QLabel(tr("Acknowledgements"), thanksPanel); - thanksTitle->setAlignment(Qt::AlignCenter); - thanksTitle->setObjectName("AboutThanksTitle"); - thanksLayout->addWidget(thanksTitle); - - auto *thanksSubtitle = new QLabel(tr("People who help qEmby reach more users."), thanksPanel); - thanksSubtitle->setAlignment(Qt::AlignCenter); - thanksSubtitle->setObjectName("AboutThanksSubtitle"); - thanksLayout->addWidget(thanksSubtitle); - - auto *cardsWidget = new QWidget(thanksPanel); - cardsWidget->setObjectName("AboutThanksCards"); - auto *cardsLayout = new FlowLayout(cardsWidget, 0, 6, 6); - - const QVector thanksEntries = { - {QStringLiteral("Arkoth79"), tr("French translation"), QStringLiteral("A")}, - }; - for (const auto &entry : thanksEntries) { - auto *card = new QFrame(cardsWidget); - card->setObjectName("AboutThanksCard"); - auto *cardLayout = new QHBoxLayout(card); - cardLayout->setContentsMargins(5, 3, 8, 3); - cardLayout->setSpacing(6); - - auto *avatarLabel = new QLabel(entry.initials, card); - avatarLabel->setFixedSize(22, 22); - avatarLabel->setAlignment(Qt::AlignCenter); - avatarLabel->setObjectName("AboutThanksAvatar"); - cardLayout->addWidget(avatarLabel, 0, Qt::AlignVCenter); - - auto *textLayout = new QVBoxLayout(); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(1); - - auto *nameLabel = new QLabel(entry.name, card); - nameLabel->setObjectName("AboutThanksName"); - textLayout->addWidget(nameLabel); - - auto *roleLabel = new QLabel(entry.contribution, card); - roleLabel->setObjectName("AboutThanksRole"); - textLayout->addWidget(roleLabel); - - cardLayout->addLayout(textLayout); - cardsLayout->addWidget(card); - } - - thanksLayout->addWidget(cardsWidget, 0, Qt::AlignCenter); - return thanksPanel; -} - -void PageAbout::showLicensesDialog() -{ - LicensesDialog dialog(this->window()); - dialog.exec(); -} diff --git a/src/qEmbyApp/views/settings/pageabout.h b/src/qEmbyApp/views/settings/pageabout.h deleted file mode 100644 index f52a162..0000000 --- a/src/qEmbyApp/views/settings/pageabout.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifndef PAGEABOUT_H -#define PAGEABOUT_H - -#include "settingspagebase.h" -#include -#include - -class PageAbout : public SettingsPageBase -{ - Q_OBJECT - -public: - explicit PageAbout(QEmbyCore* core, QWidget *parent = nullptr); - ~PageAbout() override = default; - -private slots: - - void showLicensesDialog(); - -private: - QWidget *createAcknowledgementsPanel(); - - QLabel *m_logoLabel; - QLabel *m_appNameLabel; - QLabel *m_versionLabel; - QLabel *m_authorLabel; - QLabel *m_linkLabel; - QLabel *m_licenseLabel; - QPushButton *m_licenseBtn; -}; - -#endif diff --git a/src/qEmbyApp/views/settings/pageappearance.cpp b/src/qEmbyApp/views/settings/pageappearance.cpp deleted file mode 100644 index 7051952..0000000 --- a/src/qEmbyApp/views/settings/pageappearance.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "pageappearance.h" -#include "../../components/moderncombobox.h" -#include "../../components/modernmessagebox.h" -#include "../../components/moderntoast.h" -#include "../../components/modernswitch.h" -#include "../../components/settingscard.h" -#include "../../components/settingssubpanel.h" -#include "../../managers/searchhistorymanager.h" -#include "config/config_keys.h" -#include "config/configstore.h" -#include -#include - -PageAppearance::PageAppearance(QEmbyCore *core, QWidget *parent) : SettingsPageBase(core, tr("Appearance"), parent) -{ - - auto *themeCombo = new ModernComboBox(this); - themeCombo->addItem(tr("System Default"), "system"); - themeCombo->addItem(tr("Light Mode"), "light"); - themeCombo->addItem(tr("Dark Mode"), "dark"); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-theme.svg", tr("Theme Mode"), - tr("Switch application theme (takes effect immediately)"), themeCombo, - ConfigKeys::ThemeMode, this)); - - - auto *fontSizeCombo = new ModernComboBox(this); - fontSizeCombo->addItem(tr("Small"), "small"); - fontSizeCombo->addItem(tr("Medium"), "medium"); - fontSizeCombo->addItem(tr("Large"), "large"); - fontSizeCombo->setCurrentIndex(1); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-font-size.svg", tr("Font Size"), - tr("Adjust the overall font size of the interface"), fontSizeCombo, - ConfigKeys::FontSize, this)); - - - auto *sidebarCombo = new ModernComboBox(this); - sidebarCombo->addItem(tr("Left"), "left"); - sidebarCombo->addItem(tr("Right"), "right"); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-sidebar.svg", tr("Sidebar Position"), - tr("Place the navigation sidebar on the left or right side"), sidebarCombo, - ConfigKeys::SidebarPosition, this)); - - - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-sidebar-pin.svg", tr("Pin Sidebar"), - tr("Keep the sidebar always visible and integrated into the main layout"), - new ModernSwitch(this), ConfigKeys::SidebarPinned, this)); - - - auto *customSidebarSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-sidebar-custom.svg", tr("Custom Sidebar"), - tr("Customize which items appear in the navigation sidebar"), - customSidebarSwitch, ConfigKeys::SidebarCustomEnabled, this, - QVariant(false))); - - auto *hideSearchPanel = new SettingsSubPanel(":/svg/light/search.svg", this); - auto *hideSearchLabel = new QLabel(tr("Hide Search Bar"), this); - hideSearchLabel->setObjectName("SettingsCardDesc"); - auto *hideSearchSwitch = new ModernSwitch(this); - hideSearchSwitch->setChecked( - ConfigStore::instance()->get(ConfigKeys::SidebarHideSearch, false)); - hideSearchPanel->contentLayout()->addWidget(hideSearchLabel, 1); - hideSearchPanel->contentLayout()->addWidget(hideSearchSwitch, 0, Qt::AlignVCenter); - m_mainLayout->addWidget(hideSearchPanel); - - auto *hideHomePanel = new SettingsSubPanel(":/svg/light/home.svg", this); - auto *hideHomeLabel = new QLabel(tr("Hide Home Item"), this); - hideHomeLabel->setObjectName("SettingsCardDesc"); - auto *hideHomeSwitch = new ModernSwitch(this); - hideHomeSwitch->setChecked( - ConfigStore::instance()->get(ConfigKeys::SidebarHideHome, false)); - hideHomePanel->contentLayout()->addWidget(hideHomeLabel, 1); - hideHomePanel->contentLayout()->addWidget(hideHomeSwitch, 0, Qt::AlignVCenter); - m_mainLayout->addWidget(hideHomePanel); - - auto *hideFavPanel = new SettingsSubPanel(":/svg/light/heart.svg", this); - auto *hideFavLabel = new QLabel(tr("Hide Favorites Item"), this); - hideFavLabel->setObjectName("SettingsCardDesc"); - auto *hideFavSwitch = new ModernSwitch(this); - hideFavSwitch->setChecked( - ConfigStore::instance()->get(ConfigKeys::SidebarHideFavorites, false)); - hideFavPanel->contentLayout()->addWidget(hideFavLabel, 1); - hideFavPanel->contentLayout()->addWidget(hideFavSwitch, 0, Qt::AlignVCenter); - m_mainLayout->addWidget(hideFavPanel); - - if (ConfigStore::instance()->get(ConfigKeys::SidebarCustomEnabled, false)) - { - hideSearchPanel->initExpanded(); - hideHomePanel->initExpanded(); - hideFavPanel->initExpanded(); - } - - connect(customSidebarSwitch, &ModernSwitch::toggled, hideSearchPanel, &SettingsSubPanel::setExpanded); - connect(customSidebarSwitch, &ModernSwitch::toggled, hideHomePanel, &SettingsSubPanel::setExpanded); - connect(customSidebarSwitch, &ModernSwitch::toggled, hideFavPanel, &SettingsSubPanel::setExpanded); - - connect(hideSearchSwitch, &ModernSwitch::toggled, this, - [](bool checked) { ConfigStore::instance()->set(ConfigKeys::SidebarHideSearch, checked); }); - connect(hideHomeSwitch, &ModernSwitch::toggled, this, - [](bool checked) { ConfigStore::instance()->set(ConfigKeys::SidebarHideHome, checked); }); - connect(hideFavSwitch, &ModernSwitch::toggled, this, - [](bool checked) { ConfigStore::instance()->set(ConfigKeys::SidebarHideFavorites, checked); }); - - - auto *windowStateCombo = new ModernComboBox(this); - windowStateCombo->addItem(tr("Normal Window"), "normal"); - windowStateCombo->addItem(tr("Maximized"), "maximized"); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-window-state.svg", tr("Startup Window State"), - tr("Choose window state when the application launches"), windowStateCombo, - ConfigKeys::StartupWindowState, this)); - - - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-animations.svg", tr("Reduce Animations"), - tr("Reduce page transitions and hover effects for better performance"), - new ModernSwitch(this), ConfigKeys::UiAnimations, this)); - - - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/appearance-animations.svg", tr("Snapshot Navigation"), - tr("Use static screenshots during page transitions for smoother animation on complex views"), - new ModernSwitch(this), ConfigKeys::SnapshotNavigation, this)); - - - auto *searchHistorySwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard(":/svg/dark/search.svg", tr("Enable Search History"), - tr("Save recent searches locally for quick reuse"), searchHistorySwitch, - ConfigKeys::SearchHistoryEnabled, this, QVariant(false))); - - auto *searchAutocompletePanel = new SettingsSubPanel(":/svg/dark/search-autocomplete.svg", this); - auto *searchAutocompleteLabel = new QLabel(tr("Enable Search Autocomplete"), this); - searchAutocompleteLabel->setObjectName("SettingsCardDesc"); - auto *searchAutocompleteSwitch = new ModernSwitch(this); - searchAutocompleteSwitch->setChecked( - ConfigStore::instance()->get(ConfigKeys::SearchAutocompleteEnabled, false)); - searchAutocompletePanel->contentLayout()->addWidget(searchAutocompleteLabel, 1); - searchAutocompletePanel->contentLayout()->addWidget(searchAutocompleteSwitch, 0, Qt::AlignVCenter); - m_mainLayout->addWidget(searchAutocompletePanel); - - auto *clearHistoryPanel = new SettingsSubPanel(":/svg/dark/delete.svg", this); - auto *clearHistoryLabel = new QLabel(tr("Clear Search History"), this); - clearHistoryLabel->setObjectName("SettingsCardDesc"); - auto *clearHistoryBtn = new QPushButton(tr("Clear All"), this); - clearHistoryBtn->setObjectName("SettingsCardButton"); - clearHistoryBtn->setCursor(Qt::PointingHandCursor); - clearHistoryBtn->setFixedHeight(30); - clearHistoryPanel->contentLayout()->addWidget(clearHistoryLabel, 1); - clearHistoryPanel->contentLayout()->addWidget(clearHistoryBtn, 0, Qt::AlignVCenter); - m_mainLayout->addWidget(clearHistoryPanel); - - if (ConfigStore::instance()->get(ConfigKeys::SearchHistoryEnabled, false)) - { - searchAutocompletePanel->initExpanded(); - clearHistoryPanel->initExpanded(); - } - - connect(searchHistorySwitch, &ModernSwitch::toggled, searchAutocompletePanel, &SettingsSubPanel::setExpanded); - connect(searchHistorySwitch, &ModernSwitch::toggled, clearHistoryPanel, &SettingsSubPanel::setExpanded); - - connect(searchAutocompleteSwitch, &ModernSwitch::toggled, this, - [](bool checked) { ConfigStore::instance()->set(ConfigKeys::SearchAutocompleteEnabled, checked); }); - - connect( - clearHistoryBtn, &QPushButton::clicked, this, - [this]() - { - const bool confirmed = ModernMessageBox::question( - this, tr("Clear Search History"), - tr("Are you sure you want to clear search history for all servers?\n\nThis action cannot be undone."), - tr("Clear"), tr("Cancel"), ModernMessageBox::Danger, ModernMessageBox::Warning); - if (!confirmed) - { - return; - } - - SearchHistoryManager::instance()->clearAllHistory(); - ModernToast::showMessage(tr("Search history cleared"), 1500); - }); - - - m_mainLayout->addStretch(); -} diff --git a/src/qEmbyApp/views/settings/pageappearance.h b/src/qEmbyApp/views/settings/pageappearance.h deleted file mode 100644 index a687059..0000000 --- a/src/qEmbyApp/views/settings/pageappearance.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef PAGE_APPEARANCE_H -#define PAGE_APPEARANCE_H - -#include "settingspagebase.h" - -class PageAppearance : public SettingsPageBase { - Q_OBJECT -public: - explicit PageAppearance(QEmbyCore* core, QWidget* parent = nullptr); -}; - -#endif diff --git a/src/qEmbyApp/views/settings/pagegeneral.cpp b/src/qEmbyApp/views/settings/pagegeneral.cpp deleted file mode 100644 index 297c083..0000000 --- a/src/qEmbyApp/views/settings/pagegeneral.cpp +++ /dev/null @@ -1,255 +0,0 @@ -#include "pagegeneral.h" -#include "../../components/elidedlabel.h" -#include "../../components/moderncombobox.h" -#include "../../components/modernmessagebox.h" -#include "../../components/modernswitch.h" -#include "../../components/moderntoast.h" -#include "../../components/proxysettingsdialog.h" -#include "../../components/settingscard.h" -#include "../../components/settingssubpanel.h" -#include "../../managers/logmanager.h" -#include "api/proxymanager.h" -#include "config/config_keys.h" -#include "config/configstore.h" -#include "models/profile/proxyconfig.h" -#include "qembycore.h" -#include "services/manager/servermanager.h" -#include -#include -#include -#include -#include -#include - -PageGeneral::PageGeneral(QEmbyCore *core, QWidget *parent) - : SettingsPageBase(core, tr("General"), parent) { - - auto *langCombo = new ModernComboBox(this); - langCombo->addItem(tr("System"), "system"); - langCombo->addItem(tr("简体中文"), "zh_CN"); - langCombo->addItem(tr("English"), "en_US"); - langCombo->addItem(tr("French"), "fr_FR"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/language.svg", tr("Language"), - tr("App interface language (requires restart)"), - langCombo, ConfigKeys::Language, this)); - - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/remember-server.svg", tr("Remember Last Server"), - tr("Automatically connect to the last used server on startup"), - new ModernSwitch(this), ConfigKeys::RememberServer, this)); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/tray.svg", tr("Close to Tray"), - tr("Minimize to system tray instead of exiting the application"), - new ModernSwitch(this), ConfigKeys::CloseToTray, this)); - - - - - auto *proxyBtn = new QPushButton(tr("Configure..."), this); - proxyBtn->setObjectName("SettingsCardButton"); - proxyBtn->setCursor(Qt::PointingHandCursor); - proxyBtn->setFixedHeight(30); - - auto *proxyCard = new SettingsCard( - ":/svg/dark/proxy.svg", tr("Network Proxy"), - tr("Proxy for the current server (global or server-specific)"), - proxyBtn, QString(), this); - m_mainLayout->addWidget(proxyCard); - - auto proxyStateText = [](const ProxyConfig &cfg) -> QString { - switch (cfg.mode) { - case ProxyConfig::None: - return PageGeneral::tr("no proxy"); - case ProxyConfig::System: - return PageGeneral::tr("system proxy"); - case ProxyConfig::Custom: { - const QString typeLabel = - (cfg.type == ProxyConfig::Socks5) ? QStringLiteral("SOCKS5") - : QStringLiteral("HTTP"); - if (cfg.host.isEmpty() || cfg.port == 0) { - return PageGeneral::tr("custom (incomplete)"); - } - return PageGeneral::tr("%1 %2:%3") - .arg(typeLabel, cfg.host, QString::number(cfg.port)); - } - } - return QString(); - }; - - auto buildProxySummaryDesc = [this, proxyStateText]() -> QString { - if (m_core && m_core->serverManager()) { - const ServerProfile profile = m_core->serverManager()->activeProfile(); - if (profile.isValid()) { - if (profile.useGlobalProxy) { - return PageGeneral::tr( - "Proxy for the current server — using global: %1") - .arg(proxyStateText(ProxyManager::instance()->globalConfig())); - } - return PageGeneral::tr( - "Proxy for the current server — currently: %1") - .arg(proxyStateText(profile.proxy)); - } - } - - return PageGeneral::tr("Default proxy for all servers — currently: %1") - .arg(proxyStateText(ProxyManager::instance()->globalConfig())); - }; - - - - - auto refreshProxyDesc = [proxyCard, buildProxySummaryDesc]() { - const auto labels = - proxyCard->findChildren("SettingsCardDesc"); - for (ElidedLabel *lbl : labels) { - lbl->setFullText(buildProxySummaryDesc()); - } - }; - refreshProxyDesc(); - - - connect(ProxyManager::instance(), &ProxyManager::proxyChanged, proxyCard, - refreshProxyDesc); - if (m_core && m_core->serverManager()) { - connect(m_core->serverManager(), &ServerManager::activeServerChanged, - proxyCard, - [refreshProxyDesc](const ServerProfile &) { refreshProxyDesc(); }); - } - - connect(proxyBtn, &QPushButton::clicked, this, [this, refreshProxyDesc]() { - ProxySettingsDialog *dlg = nullptr; - if (m_core && m_core->serverManager()) { - const ServerProfile profile = m_core->serverManager()->activeProfile(); - if (profile.isValid()) { - dlg = ProxySettingsDialog::createForServer(m_core->serverManager(), - profile.id, this); - } - } - if (!dlg) { - dlg = ProxySettingsDialog::createForGlobal(this); - } - dlg->exec(); - refreshProxyDesc(); - dlg->deleteLater(); - }); - - - auto *logSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/log.svg", tr("Enable Logging"), - tr("Record debug information to a log file for troubleshooting"), - logSwitch, ConfigKeys::LogEnable, this)); - - - auto *logPanel = new SettingsSubPanel(":/svg/dark/log-path.svg", this); - - - auto *logPathLabel = new QLabel( - tr("Log path: %1") - .arg(QDir::toNativeSeparators(LogManager::instance()->logFilePath())), - this); - logPathLabel->setObjectName("SettingsCardDesc"); - logPathLabel->setWordWrap(true); - logPanel->contentLayout()->addWidget(logPathLabel, 1); - - m_mainLayout->addWidget(logPanel); - - - auto *logActionsPanel = new SettingsSubPanel(":/svg/dark/log-tools.svg", this); - - - auto *logSizeLabel = - new QLabel(tr("Size: %1").arg(LogManager::instance()->logFileSize()), - this); - logSizeLabel->setObjectName("SettingsCardDesc"); - logActionsPanel->contentLayout()->addWidget(logSizeLabel, 1); - - - auto *openLogBtn = new QPushButton(tr("Open Log"), this); - openLogBtn->setObjectName("SettingsCardButton"); - openLogBtn->setCursor(Qt::PointingHandCursor); - openLogBtn->setFixedHeight(30); - logActionsPanel->contentLayout()->addWidget(openLogBtn, 0, - Qt::AlignVCenter); - - - auto *clearLogBtn = new QPushButton(tr("Clear Log"), this); - clearLogBtn->setObjectName("SettingsCardButton"); - clearLogBtn->setCursor(Qt::PointingHandCursor); - clearLogBtn->setFixedHeight(30); - logActionsPanel->contentLayout()->addWidget(clearLogBtn, 0, - Qt::AlignVCenter); - - - auto *openDirBtn = new QPushButton(tr("Open Location"), this); - openDirBtn->setObjectName("SettingsCardButton"); - openDirBtn->setCursor(Qt::PointingHandCursor); - openDirBtn->setFixedHeight(30); - logActionsPanel->contentLayout()->addWidget(openDirBtn, 0, - Qt::AlignVCenter); - - m_mainLayout->addWidget(logActionsPanel); - - - if (ConfigStore::instance()->get(ConfigKeys::LogEnable, false)) { - logPanel->initExpanded(); - logActionsPanel->initExpanded(); - } - - - connect(logSwitch, &ModernSwitch::toggled, logPanel, - &SettingsSubPanel::setExpanded); - connect(logSwitch, &ModernSwitch::toggled, logActionsPanel, - &SettingsSubPanel::setExpanded); - - - connect(logSwitch, &ModernSwitch::toggled, this, - [logSizeLabel](bool checked) { - if (checked) { - LogManager::instance()->enable(); - } else { - LogManager::instance()->disable(); - } - logSizeLabel->setText(QObject::tr("Size: %1").arg( - LogManager::instance()->logFileSize())); - }); - - - connect(openLogBtn, &QPushButton::clicked, this, []() { - QString logPath = LogManager::instance()->logFilePath(); - if (QFileInfo::exists(logPath)) { - QDesktopServices::openUrl(QUrl::fromLocalFile(logPath)); - } - }); - - - connect(clearLogBtn, &QPushButton::clicked, this, [this, logSizeLabel]() { - const bool confirmed = ModernMessageBox::question( - this, tr("Clear Log"), - tr("Are you sure you want to clear all log files?\n\n" - "This will remove the current log and rotated backups. This action " - "cannot be undone."), - tr("Clear"), tr("Cancel"), ModernMessageBox::Danger, - ModernMessageBox::Warning); - if (!confirmed) { - return; - } - - LogManager::instance()->clearLog(); - logSizeLabel->setText( - QObject::tr("Size: %1").arg(LogManager::instance()->logFileSize())); - ModernToast::showMessage(tr("Log files cleared"), 1500); - }); - - - connect(openDirBtn, &QPushButton::clicked, this, []() { - QString logDir = - QFileInfo(LogManager::instance()->logFilePath()).absolutePath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(logDir)); - }); - - m_mainLayout->addStretch(); -} diff --git a/src/qEmbyApp/views/settings/pagegeneral.h b/src/qEmbyApp/views/settings/pagegeneral.h deleted file mode 100644 index 445fd78..0000000 --- a/src/qEmbyApp/views/settings/pagegeneral.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef PAGE_GENERAL_H -#define PAGE_GENERAL_H - -#include "settingspagebase.h" - -class PageGeneral : public SettingsPageBase { - Q_OBJECT -public: - explicit PageGeneral(QEmbyCore* core, QWidget* parent = nullptr); -}; - -#endif diff --git a/src/qEmbyApp/views/settings/pagelibrary.cpp b/src/qEmbyApp/views/settings/pagelibrary.cpp deleted file mode 100644 index 68bda5c..0000000 --- a/src/qEmbyApp/views/settings/pagelibrary.cpp +++ /dev/null @@ -1,340 +0,0 @@ -#include "pagelibrary.h" -#include "../../components/dashboardsectionorderwidget.h" -#include "../../components/modernnumberinput.h" -#include "../../components/moderntoast.h" -#include "../../components/moderncombobox.h" -#include "../../components/modernswitch.h" -#include "../../components/settingscard.h" -#include "../../components/settingssubpanel.h" -#include "../../utils/dashboardsectionorderutils.h" -#include "config/config_keys.h" -#include "config/configstore.h" -#include "fileutils.h" -#include "qembycore.h" -#include "services/manager/servermanager.h" -#include "services/media/mediaservice.h" -#include -#include -#include -#include -#include - -namespace { -const QString kHomeSectionOrderIconPath = - QStringLiteral(":/svg/dark/home-section-order.svg"); -const QString kHomeSectionOrderDragIconPath = - QStringLiteral(":/svg/dark/home-section-drag.svg"); - -ModernNumberInput *createRequestLimitInput(const QString &zeroText, - QWidget *parent) { - auto *input = new ModernNumberInput(parent); - input->setSpecialValueText(zeroText); - return input; -} -} - -PageLibrary::PageLibrary(QEmbyCore *core, QWidget *parent) - : SettingsPageBase(core, tr("Library"), parent) { - - QString sid = m_core->serverManager()->activeProfile().id; - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/play-history.svg", tr("Show Continue Watching"), - tr("Display unfinished media on the home screen"), new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowContinueWatching), this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/latest-added.svg", tr("Show Latest Added"), - tr("Display recently added media on the home screen"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowLatestAdded), this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/star.svg", tr("Show Recommended"), - tr("Display recommended media on the home screen"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowRecommended), this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/library.svg", tr("Show Media Libraries"), - tr("Display media libraries on the home screen"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowMediaLibraries), this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/appearance-library-view.svg", tr("Show Each Library"), - tr("Display each library section individually on the home screen"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowEachLibrary), this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/completed-watching.svg", tr("Show Completed Watching"), - tr("Display completed media on the home screen"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowCompletedWatching), this, - QVariant(false))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/play-history-request-limit.svg", - tr("Continue Watching Request Limit"), - tr("Full list request count. 0 means unlimited; home sections use the same value up to 50."), - createRequestLimitInput(tr("Unlimited"), this), - ConfigKeys::forServer(sid, ConfigKeys::ContinueWatchingRequestLimit), - this, QVariant(0))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/latest-added-request-limit.svg", - tr("Latest Media Request Limit"), - tr("Full list request count. 0 means unlimited; home sections use the same value up to 50."), - createRequestLimitInput(tr("Unlimited"), this), - ConfigKeys::forServer(sid, ConfigKeys::LatestMediaRequestLimit), this, - QVariant(1000))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/recommended-request-limit.svg", - tr("Recommended Request Limit"), - tr("Full list request count. 0 means unlimited; home sections use the same value up to 50."), - createRequestLimitInput(tr("Unlimited"), this), - ConfigKeys::forServer(sid, ConfigKeys::RecommendedRequestLimit), this, - QVariant(1000))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/completed-watching-request-limit.svg", - tr("Completed Watching Request Limit"), - tr("Full list request count. 0 means unlimited; home sections use the same value up to 50."), - createRequestLimitInput(tr("Unlimited"), this), - ConfigKeys::forServer(sid, ConfigKeys::CompletedWatchingRequestLimit), - this, QVariant(0))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/adaptive-image.svg", tr("Adaptive Images"), - tr("When a specific image type is missing, automatically use another available type as fallback"), - new ModernSwitch(this), - ConfigKeys::AdaptiveImages, this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/appearance-library-view.svg", tr("Show Media Tooltips"), - tr("Show media details in tooltips when hovering over media cards"), - new ModernSwitch(this), - ConfigKeys::ShowMediaTooltips, this, - QVariant(true))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/folder-heart.svg", tr("Show Favorite Folders"), - tr("Display favorite folders in the favorites page"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::ShowFavoriteFolders), this, - QVariant(false))); - - auto *viewCombo = new ModernComboBox(this); - viewCombo->addItem(tr("Poster Wall"), "poster"); - viewCombo->addItem(tr("List View"), "tile"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/appearance-library-view.svg", - tr("Default Library View"), - tr("Choose how media items are presented by default"), - viewCombo, ConfigKeys::DefaultLibraryView, this)); - - auto *qualityCombo = new ModernComboBox(this); - qualityCombo->addItem(tr("Low"), "low"); - qualityCombo->addItem(tr("Medium"), "medium"); - qualityCombo->addItem(tr("High"), "high"); - qualityCombo->addItem(tr("Original"), "original"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/image-quality.svg", tr("Image Quality"), - tr("Set image download quality (higher uses more bandwidth)"), - qualityCombo, ConfigKeys::ImageQuality, this)); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/image-quality.svg", tr("Shimmer Loading Animation"), - tr("Show shimmer skeleton animation while loading dashboard content"), - new ModernSwitch(this), - ConfigKeys::ShimmerAnimation, this, - QVariant(false))); - - const QString customHomeSectionOrderEnabledKey = - ConfigKeys::forServer(sid, ConfigKeys::CustomHomeSectionOrderEnabled); - const QString homeSectionOrderKey = - ConfigKeys::forServer(sid, ConfigKeys::HomeSectionOrder); - auto* configStore = ConfigStore::instance(); - const QStringList storedHomeSectionOrder = - configStore->get(homeSectionOrderKey, {}); - const QStringList initialHomeSectionOrder = - storedHomeSectionOrder.isEmpty() - ? DashboardSectionOrderUtils::defaultSectionOrder() - : DashboardSectionOrderUtils::normalizeSectionOrder( - storedHomeSectionOrder); - - auto* customHomeOrderSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard( - kHomeSectionOrderIconPath, tr("Custom Home Section Order"), - tr("Override the default order of the main home screen sections"), - customHomeOrderSwitch, customHomeSectionOrderEnabledKey, this, - QVariant(false))); - - m_homeSectionOrderPanel = - new SettingsSubPanel(kHomeSectionOrderDragIconPath, this); - m_homeSectionOrderWidget = - new DashboardSectionOrderWidget(m_homeSectionOrderPanel); - m_homeSectionOrderWidget->setSectionOrder(initialHomeSectionOrder); - m_homeSectionOrderPanel->contentLayout()->addWidget( - m_homeSectionOrderWidget, 1); - m_mainLayout->addWidget(m_homeSectionOrderPanel); - - if (!storedHomeSectionOrder.isEmpty() && - storedHomeSectionOrder != initialHomeSectionOrder) { - configStore->set(homeSectionOrderKey, initialHomeSectionOrder); - } - - connect(customHomeOrderSwitch, &ModernSwitch::toggled, - m_homeSectionOrderPanel, &SettingsSubPanel::setExpanded); - connect(m_homeSectionOrderWidget, - &DashboardSectionOrderWidget::sectionOrderChanged, this, - [configStore, homeSectionOrderKey](const QStringList& order) { - configStore->set( - homeSectionOrderKey, - DashboardSectionOrderUtils::normalizeSectionOrder(order)); - }); - connect(configStore, &ConfigStore::valueChanged, this, - [this, configStore, customHomeSectionOrderEnabledKey, - homeSectionOrderKey](const QString& key, const QVariant& value) { - if (key == customHomeSectionOrderEnabledKey && - m_homeSectionOrderPanel) { - m_homeSectionOrderPanel->setExpanded(value.toBool()); - return; - } - - if (key != homeSectionOrderKey || !m_homeSectionOrderWidget) { - return; - } - - const QStringList normalizedOrder = - DashboardSectionOrderUtils::normalizeSectionOrder( - value.toStringList()); - m_homeSectionOrderWidget->setSectionOrder(normalizedOrder); - - if (normalizedOrder != value.toStringList()) { - configStore->set(homeSectionOrderKey, normalizedOrder); - } - }); - - if (configStore->get(customHomeSectionOrderEnabledKey, false)) { - m_homeSectionOrderPanel->initExpanded(); - } - - - - - auto *cacheTitle = new QLabel(tr("Cache Management"), this); - cacheTitle->setObjectName("SettingsSubTitle"); - m_mainLayout->addWidget(cacheTitle); - - - auto *dataCacheCombo = new ModernComboBox(this); - dataCacheCombo->addItem(tr("6 Hours"), "6"); - dataCacheCombo->addItem(tr("12 Hours"), "12"); - dataCacheCombo->addItem(tr("1 Day"), "24"); - dataCacheCombo->addItem(tr("3 Days"), "72"); - dataCacheCombo->addItem(tr("7 Days"), "168"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/cache-data.svg", tr("Recommendation Cache Duration"), - tr("Set how long to cache recommended content before refreshing"), - dataCacheCombo, ConfigKeys::DataCacheDuration, this)); - - - m_dataCacheSizeLabel = new QLabel(this); - m_dataCacheSizeLabel->setObjectName("SettingsCardDesc"); - auto *clearDataBtn = new QPushButton(tr("Clear"), this); - clearDataBtn->setObjectName("SettingsCardButton"); - - auto *dataClearWidget = new QWidget(this); - auto *dataClearLayout = new QHBoxLayout(dataClearWidget); - dataClearLayout->setContentsMargins(0, 0, 0, 0); - dataClearLayout->setSpacing(12); - dataClearLayout->addWidget(m_dataCacheSizeLabel); - dataClearLayout->addWidget(clearDataBtn); - - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/cache-clear.svg", tr("Clear Recommendation Cache"), - tr("Clear cached recommendations and refresh on next visit"), - dataClearWidget, QString(), this)); - connect(clearDataBtn, &QPushButton::clicked, this, [this]() { - if (m_core && m_core->mediaService()) { - m_core->mediaService()->clearRecommendCache(); - updateCacheSizes(); - ModernToast::showMessage(tr("Recommendation cache cleared"), 1500); - } - }); - - - auto *imgCacheCombo = new ModernComboBox(this); - imgCacheCombo->addItem(tr("1 Day"), "1"); - imgCacheCombo->addItem(tr("3 Days"), "3"); - imgCacheCombo->addItem(tr("7 Days"), "7"); - imgCacheCombo->addItem(tr("14 Days"), "14"); - imgCacheCombo->addItem(tr("30 Days"), "30"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/cache-image.svg", tr("Image Cache Duration"), - tr("How long to keep downloaded images in local cache"), - imgCacheCombo, ConfigKeys::ImageCacheDuration, this)); - - - m_imgCacheSizeLabel = new QLabel(this); - m_imgCacheSizeLabel->setObjectName("SettingsCardDesc"); - auto *clearImgBtn = new QPushButton(tr("Clear"), this); - clearImgBtn->setObjectName("SettingsCardButton"); - - auto *imgClearWidget = new QWidget(this); - auto *imgClearLayout = new QHBoxLayout(imgClearWidget); - imgClearLayout->setContentsMargins(0, 0, 0, 0); - imgClearLayout->setSpacing(12); - imgClearLayout->addWidget(m_imgCacheSizeLabel); - imgClearLayout->addWidget(clearImgBtn); - - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/cache-clear.svg", tr("Clear Image Cache"), - tr("Remove all cached images from disk"), - imgClearWidget, QString(), this)); - connect(clearImgBtn, &QPushButton::clicked, this, [this]() { - - QString cachePath = QStandardPaths::writableLocation( - QStandardPaths::CacheLocation) - + "/qEmby_ImageCache"; - QDir cacheDir(cachePath); - if (cacheDir.exists()) { - cacheDir.removeRecursively(); - QDir().mkpath(cachePath); - } - updateCacheSizes(); - ModernToast::showMessage(tr("Image cache cleared"), 1500); - }); - - m_mainLayout->addStretch(); - - - updateCacheSizes(); -} - -void PageLibrary::updateCacheSizes() { - - QString sid = m_core->serverManager()->activeProfile().id; - QString dataCachePath = - QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + - QStringLiteral("/qEmby_RecommendCache_%1.json").arg(sid); - QFileInfo dataInfo(dataCachePath); - qint64 dataSize = dataInfo.exists() ? dataInfo.size() : 0; - m_dataCacheSizeLabel->setText(FileUtils::formatSize(dataSize)); - - - QString imgCachePath = - QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + - "/qEmby_ImageCache"; - qint64 imgSize = FileUtils::calcDirSize(imgCachePath); - m_imgCacheSizeLabel->setText(FileUtils::formatSize(imgSize)); -} diff --git a/src/qEmbyApp/views/settings/pagelibrary.h b/src/qEmbyApp/views/settings/pagelibrary.h deleted file mode 100644 index f19e23b..0000000 --- a/src/qEmbyApp/views/settings/pagelibrary.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef PAGE_LIBRARY_H -#define PAGE_LIBRARY_H - -#include "settingspagebase.h" - -class DashboardSectionOrderWidget; -class QLabel; -class SettingsSubPanel; - -class PageLibrary : public SettingsPageBase { - Q_OBJECT -public: - explicit PageLibrary(QEmbyCore* core, QWidget* parent = nullptr); - -private: - void updateCacheSizes(); - - QLabel *m_dataCacheSizeLabel = nullptr; - QLabel *m_imgCacheSizeLabel = nullptr; - SettingsSubPanel* m_homeSectionOrderPanel = nullptr; - DashboardSectionOrderWidget* m_homeSectionOrderWidget = nullptr; -}; - -#endif diff --git a/src/qEmbyApp/views/settings/pageplayer.cpp b/src/qEmbyApp/views/settings/pageplayer.cpp deleted file mode 100644 index b0b018b..0000000 --- a/src/qEmbyApp/views/settings/pageplayer.cpp +++ /dev/null @@ -1,1302 +0,0 @@ -#include "pageplayer.h" -#include "../../components/danmakuserverdialog.h" -#include "../../components/danmakuoptionslider.h" -#include "../../components/moderncombobox.h" -#include "../../components/modernswitch.h" -#include "../../components/moderntaginput.h" -#include "../../components/moderntoast.h" -#include "../../components/playercombobox.h" -#include "../../components/settingscard.h" -#include "../../components/settingssubpanel.h" -#include "../../utils/danmakuoptionutils.h" -#include "../../utils/danmakurendererutils.h" -#include "../../managers/externalplayerdetector.h" -#include "../../managers/thememanager.h" -#include "config/config_keys.h" -#include "config/configstore.h" -#include "services/danmaku/danmakuservice.h" -#include "services/danmaku/danmakusettings.h" -#include "services/manager/servermanager.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "../../tokens/AnimationTokens.h" -#include -#include -#include -#include - -PagePlayer::PagePlayer(QEmbyCore *core, QWidget *parent) - : SettingsPageBase(core, tr("Player"), parent) { - const QString sid = m_core->serverManager()->activeProfile().id; - - auto *hwCombo = new ModernComboBox(this); - hwCombo->addItem(tr("Auto"), "auto-copy"); - hwCombo->addItem(tr("VAAPI"), "vaapi-copy"); - hwCombo->addItem(tr("VDPAU"), "vdpau-copy"); - hwCombo->addItem(tr("CUDA (NVIDIA)"), "cuda-copy"); - hwCombo->addItem(tr("None (Software)"), "no"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/hw-decode.svg", tr("Hardware Decoding"), - tr("Select GPU acceleration method for video decoding"), - hwCombo, ConfigKeys::PlayerHwDec, this)); - - - auto *voCombo = new ModernComboBox(this); - voCombo->addItem(tr("libmpv (Embedded)"), "libmpv"); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/video-output.svg", tr("Video Output Driver"), - tr("Rendering backend for embedded player (Render API mode)"), voCombo, - ConfigKeys::PlayerVo, this)); - - - auto *vsyncCombo = new ModernComboBox(this); - vsyncCombo->addItem(tr("Audio"), "audio"); - vsyncCombo->addItem(tr("Display Resample (Default)"), "display-resample"); - vsyncCombo->addItem(tr("Display VSync"), "display-vdrop"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/video-sync.svg", tr("Video Sync Mode"), - tr("Method for synchronizing video frames to display"), - vsyncCombo, ConfigKeys::PlayerVideoSync, this, - QVariant("display-resample"))); - - auto *toneMappingCombo = new ModernComboBox(this); - toneMappingCombo->addItem(tr("Auto"), "auto"); - toneMappingCombo->addItem(tr("Mobius"), "mobius"); - toneMappingCombo->addItem(tr("BT.2390"), "bt.2390"); - toneMappingCombo->addItem(tr("Hable"), "hable"); - toneMappingCombo->addItem(tr("Reinhard"), "reinhard"); - m_mainLayout->addWidget( - new SettingsCard(":/svg/dark/video-output.svg", tr("Tone Mapping"), - tr("HDR to SDR tone mapping algorithm"), - toneMappingCombo, ConfigKeys::PlayerToneMapping, this, - QVariant("auto"))); - - auto createLanguageRuleInput = [this](bool allowNone) { - auto *input = new ModernTagInput(this); - input->setPopupMode(ModernTagInput::ForcePopup); - input->addPreset(tr("Auto"), "auto"); - if (allowNone) { - input->addPreset(tr("None"), "none"); - } - input->addPreset(tr("Chinese"), "chi"); - input->addPreset(tr("Chinese (Hong Kong)"), "chi-hk"); - input->addPreset(tr("Chinese (Macau)"), "chi-mo"); - input->addPreset(tr("Chinese (Taiwan)"), "chi-tw"); - input->addPreset(tr("English"), "eng"); - input->addPreset(tr("Japanese"), "jpn"); - input->addPreset(tr("Korean"), "kor"); - input->addPreset(tr("French"), "fre"); - input->addPreset(tr("German"), "ger"); - input->addPreset(tr("Spanish"), "spa"); - input->addPreset(tr("Russian"), "rus"); - return input; - }; - - - auto *audioLangInput = createLanguageRuleInput(false); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/audio-lang.svg", tr("Preferred Audio Language"), - tr("Rules are matched in order; choose presets or type custom keywords and press Enter"), - audioLangInput, ConfigKeys::PlayerAudioLang, this, QVariant("auto"))); - - - auto *subLangInput = createLanguageRuleInput(true); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/subtitle-lang.svg", tr("Preferred Subtitle Language"), - tr("Rules are matched in order; choose presets or type custom keywords and press Enter"), - subLangInput, ConfigKeys::PlayerSubLang, this, QVariant("auto"))); - - - auto *versionInput = new ModernTagInput(this); - versionInput->addPreset(tr("Original"), "original"); - versionInput->addPreset(tr("4K"), "2160p"); - versionInput->addPreset(tr("1080p"), "1080p"); - versionInput->addPreset(tr("720p"), "720p"); - versionInput->addPreset(tr("480p"), "480p"); - versionInput->addPreset(tr("Remux"), "remux"); - versionInput->addPreset(tr("Bitrate High to Low"), "bitrate-desc"); - versionInput->addPreset(tr("Bitrate Low to High"), "bitrate-asc"); - versionInput->addPreset(tr("File Size Large to Small"), "size-desc"); - versionInput->addPreset(tr("File Size Small to Large"), "size-asc"); - versionInput->addPreset(tr("Date New to Old"), "date-desc"); - versionInput->addPreset(tr("Date Old to New"), "date-asc"); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/video-version.svg", tr("Preferred Video Version"), - tr("Choose keyword or sort rules in order; type custom keywords and press Enter"), - versionInput, ConfigKeys::PlayerPreferredVersion, this)); - - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/player-continuous-play.svg", tr("Continuous Playback"), - tr("Automatically play the next episode or media when playback ends"), - new ModernSwitch(this), ConfigKeys::PlayerContinuousPlay, this, - QVariant(true))); - - - auto *longPressSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/player-long-press.svg", tr("Long Press Left/Right"), - tr("Enable long press actions for left/right keys during playback"), - longPressSwitch, ConfigKeys::PlayerLongPressSeek, this, QVariant(true))); - - auto bindPlayerSubPanelCombo = [](QComboBox *combo, const QString &configKey, - const QString &defaultValue) { - if (!combo || configKey.isEmpty()) { - return; - } - - auto *store = ConfigStore::instance(); - QString currentValue = store->get(configKey, defaultValue); - int currentIndex = combo->findData(currentValue); - if (currentIndex < 0) { - currentIndex = combo->findData(defaultValue); - } - if (currentIndex >= 0) { - combo->setCurrentIndex(currentIndex); - } - - QObject::connect(combo, QOverload::of(&QComboBox::currentIndexChanged), - combo, [store, configKey, combo](int index) { - store->set(configKey, combo->itemData(index)); - }); - - QObject::connect(store, &ConfigStore::valueChanged, combo, - [combo, configKey](const QString &key, - const QVariant &newValue) { - if (key != configKey || !combo) { - return; - } - - QSignalBlocker blocker(combo); - const int index = combo->findData(newValue); - if (index >= 0) { - combo->setCurrentIndex(index); - } - }); - }; - - auto createLongPressSubPanel = [this](const QString &iconPath, - const QString &title, - const QString &description, - QWidget *control) { - auto *panel = new SettingsSubPanel(iconPath, this); - - auto *textContainer = new QWidget(panel); - textContainer->setSizePolicy(QSizePolicy::Expanding, - QSizePolicy::Preferred); - auto *textLayout = new QVBoxLayout(textContainer); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(2); - - auto *titleLabel = new QLabel(title, panel); - titleLabel->setObjectName("SettingsCardTitle"); - titleLabel->setWordWrap(true); - textLayout->addWidget(titleLabel); - - auto *descLabel = new QLabel(description, panel); - descLabel->setObjectName("SettingsCardDesc"); - descLabel->setWordWrap(true); - textLayout->addWidget(descLabel); - - panel->contentLayout()->addWidget(textContainer, 1); - - if (control) { - control->setParent(panel); - if (auto *combo = qobject_cast(control)) { - combo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - } - panel->contentLayout()->addWidget(control, 0, Qt::AlignVCenter); - } - - m_mainLayout->addWidget(panel); - return panel; - }; - - auto *longPressStepCombo = new ModernComboBox(this); - longPressStepCombo->addItem(tr("3 Seconds"), "3"); - longPressStepCombo->addItem(tr("5 Seconds"), "5"); - longPressStepCombo->addItem(tr("10 Seconds"), "10"); - longPressStepCombo->addItem(tr("15 Seconds"), "15"); - longPressStepCombo->addItem(tr("30 Seconds"), "30"); - bindPlayerSubPanelCombo(longPressStepCombo, ConfigKeys::PlayerSeekStep, - "10"); - auto *longPressStepPanel = createLongPressSubPanel( - ":/svg/dark/player-seek-step.svg", tr("Seek Step"), - tr("Number of seconds to skip when pressing forward or rewind"), - longPressStepCombo); - - auto *longPressTriggerCombo = new ModernComboBox(this); - longPressTriggerCombo->addItem(tr("300 ms"), "300"); - longPressTriggerCombo->addItem(tr("500 ms"), "500"); - longPressTriggerCombo->addItem(tr("800 ms"), "800"); - longPressTriggerCombo->addItem(tr("1000 ms"), "1000"); - bindPlayerSubPanelCombo(longPressTriggerCombo, - ConfigKeys::PlayerLongPressTriggerMs, "500"); - auto *longPressTriggerPanel = createLongPressSubPanel( - ":/svg/dark/player-long-press-trigger.svg", tr("Long Press Trigger Time"), - tr("Set how long left/right keys must be held before the long press action starts"), - longPressTriggerCombo); - - auto *longPressModeCombo = new ModernComboBox(this); - longPressModeCombo->addItem(tr("Time Jump Playback"), "seek"); - longPressModeCombo->addItem(tr("Speed Playback"), "speed"); - bindPlayerSubPanelCombo(longPressModeCombo, ConfigKeys::PlayerLongPressMode, - "seek"); - auto *longPressModePanel = createLongPressSubPanel( - ":/svg/dark/player-long-press-mode.svg", tr("Long Press Mode"), - tr("Choose whether holding left/right keys performs time jump seek or temporary speed playback"), - longPressModeCombo); - - if (ConfigStore::instance()->get(ConfigKeys::PlayerLongPressSeek, - true)) { - longPressStepPanel->initExpanded(); - longPressTriggerPanel->initExpanded(); - longPressModePanel->initExpanded(); - } - - connect(longPressSwitch, &ModernSwitch::toggled, longPressStepPanel, - &SettingsSubPanel::setExpanded); - connect(longPressSwitch, &ModernSwitch::toggled, longPressTriggerPanel, - &SettingsSubPanel::setExpanded); - connect(longPressSwitch, &ModernSwitch::toggled, longPressModePanel, - &SettingsSubPanel::setExpanded); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/player-mouse-edge-long-press.svg", - tr("Mouse Edge Long Press"), - tr("Hold the left or right side of the playback area for speed rewind or fast forward"), - new ModernSwitch(this), ConfigKeys::PlayerMouseEdgeLongPress, this, - QVariant(true))); - - auto *mediaSwitcherCombo = new ModernComboBox(this); - mediaSwitcherCombo->addItem(tr("Right Sidebar"), "sidebar"); - mediaSwitcherCombo->addItem(tr("Bottom HUD"), "hud"); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/player.svg", tr("Media Switcher Position"), - tr("Choose whether recent movies and season or episode switching appear in the right sidebar or the bottom HUD"), - mediaSwitcherCombo, ConfigKeys::PlayerMediaSwitcherMode, this, - QVariant("sidebar"))); - - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/click-pause.svg", tr("Click to Pause"), - tr("Single click on the video area to toggle play/pause"), - new ModernSwitch(this), ConfigKeys::PlayerClickToPause, this, - QVariant(true))); - - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/skip-intro.svg", tr("Auto Skip Intro"), - tr("Automatically skip the intro sequence when playback starts"), - new ModernSwitch(this), ConfigKeys::PlayerSkipIntro, this, - QVariant(false))); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/skip-outro.svg", tr("Auto Skip Outro"), - tr("Automatically skip the ending credits during playback"), - new ModernSwitch(this), ConfigKeys::PlayerSkipOutro, this, - QVariant(false))); - - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/window-pop.svg", tr("Independent Player Window"), - tr("Open video in a separate detached window"), new ModernSwitch(this), - ConfigKeys::PlayerIndependentWindow, this)); - - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/volume-normalize.svg", tr("Volume Normalization"), - tr("Automatically adjust volume to prevent sudden loud noises"), - new ModernSwitch(this), ConfigKeys::PlayerVolNormal, this)); - - - auto *mpvConfSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/mpv-conf.svg", tr("Use Custom mpv.conf"), - tr("Load settings from an external mpv.conf configuration file"), - mpvConfSwitch, ConfigKeys::PlayerUseMpvConf, this)); - - - auto *confPanel = new SettingsSubPanel(":/svg/dark/mpv-conf.svg", this); - - QString mpvConfDir = - QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation) - .replace(QRegularExpression("[^/]+$"), "mpv"); - QString mpvConfPath = mpvConfDir + "/mpv.conf"; - - auto *confPathLabel = new QLabel( - tr("Config path: %1").arg(QDir::toNativeSeparators(mpvConfPath)), this); - confPathLabel->setObjectName("SettingsCardDesc"); - confPathLabel->setWordWrap(true); - confPanel->contentLayout()->addWidget(confPathLabel, 1); - - auto *openConfBtn = new QPushButton(tr("Open File"), this); - openConfBtn->setObjectName("SettingsCardButton"); - openConfBtn->setCursor(Qt::PointingHandCursor); - openConfBtn->setFixedHeight(30); - confPanel->contentLayout()->addWidget(openConfBtn, 0, Qt::AlignVCenter); - - m_mainLayout->addWidget(confPanel); - - - if (ConfigStore::instance()->get(ConfigKeys::PlayerUseMpvConf, false)) { - confPanel->initExpanded(); - } - - - connect(mpvConfSwitch, &ModernSwitch::toggled, confPanel, - &SettingsSubPanel::setExpanded); - - - connect(openConfBtn, &QPushButton::clicked, this, - [mpvConfDir, mpvConfPath]() { - QDir().mkpath(mpvConfDir); - if (!QFile::exists(mpvConfPath)) { - QFile file(mpvConfPath); - if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&file); - out << "# MPV Configuration File for qEmby\n"; - out << "# Uncomment or add options below, one per line.\n"; - out << "# Reference: https://mpv.io/manual/stable/#options\n"; - out << "#\n"; - out << "# hwdec=auto-copy\n"; - out << "# video-sync=display-resample\n"; - out << "# deband=yes\n"; - file.close(); - } - } - QDesktopServices::openUrl(QUrl::fromLocalFile(mpvConfPath)); - }); - - auto bindSettingsControl = [](QWidget *control, const QString &configKey, - const QVariant &defaultValue = QVariant()) { - if (!control || configKey.isEmpty()) { - return; - } - - auto *store = ConfigStore::instance(); - - if (auto *switchControl = qobject_cast(control)) { - switchControl->setChecked( - store->get(configKey, defaultValue.toBool())); - connect(switchControl, &ModernSwitch::toggled, control, - [store, configKey](bool checked) { - store->set(configKey, checked); - }); - } else if (auto *comboControl = qobject_cast(control)) { - QString currentVal = store->get(configKey); - if (currentVal.isEmpty() && defaultValue.isValid()) { - currentVal = defaultValue.toString(); - } - const int idx = comboControl->findData(currentVal); - if (idx >= 0) { - comboControl->setCurrentIndex(idx); - } - - connect(comboControl, - QOverload::of(&QComboBox::currentIndexChanged), control, - [store, configKey, comboControl](int index) { - store->set(configKey, comboControl->itemData(index)); - }); - } else if (auto *lineEdit = qobject_cast(control)) { - lineEdit->setText( - store->get(configKey, defaultValue.toString())); - connect(lineEdit, &QLineEdit::editingFinished, control, - [store, configKey, lineEdit]() { - store->set(configKey, lineEdit->text()); - }); - } else if (auto *tagInput = qobject_cast(control)) { - tagInput->setValue( - store->get(configKey, defaultValue.toString())); - connect(tagInput, &ModernTagInput::valueChanged, control, - [store, configKey](const QString &value) { - store->set(configKey, value); - }); - } - - connect(store, &ConfigStore::valueChanged, control, - [control, configKey](const QString &key, const QVariant &newValue) { - if (key != configKey || !control) { - return; - } - - QSignalBlocker blocker(control); - if (auto *switchControl = qobject_cast(control)) { - switchControl->setChecked(newValue.toBool()); - } else if (auto *comboControl = - qobject_cast(control)) { - const int idx = comboControl->findData(newValue); - if (idx >= 0) { - comboControl->setCurrentIndex(idx); - } - } else if (auto *lineEdit = - qobject_cast(control)) { - lineEdit->setText(newValue.toString()); - } else if (auto *tagInput = - qobject_cast(control)) { - tagInput->setValue(newValue.toString()); - } - }); - }; - - auto *danmakuTitle = new QLabel(tr("Danmaku"), this); - danmakuTitle->setObjectName("SettingsSubTitle"); - m_mainLayout->addWidget(danmakuTitle); - - const QString danmakuEnabledKey = - ConfigKeys::forServer(sid, ConfigKeys::PlayerDanmakuEnabled); - const bool danmakuEnabled = ConfigStore::instance()->get( - danmakuEnabledKey, - ConfigStore::instance()->get(ConfigKeys::PlayerDanmakuEnabled, - false)); - auto *danmakuSwitch = new ModernSwitch(this); - auto *danmakuSettingsCard = new SettingsCard( - ":/svg/dark/danmaku.svg", tr("Enable Danmaku"), - tr("Display streaming danmaku in the embedded player"), - danmakuSwitch, danmakuEnabledKey, this, - QVariant(danmakuEnabled)); - m_mainLayout->addWidget(danmakuSettingsCard); - - QList danmakuPanels; - auto addDanmakuSubPanel = [&](const QString &iconPath, const QString &title, - const QString &description, QWidget *control, - const QString &configKey = QString(), - const QVariant &defaultValue = QVariant()) { - auto *panel = new SettingsSubPanel(iconPath, this); - - auto *textContainer = new QWidget(panel); - textContainer->setSizePolicy(QSizePolicy::Expanding, - QSizePolicy::Preferred); - auto *textLayout = new QVBoxLayout(textContainer); - textLayout->setContentsMargins(0, 0, 0, 0); - textLayout->setSpacing(2); - - auto *titleLabel = new QLabel(title, panel); - titleLabel->setObjectName("SettingsCardTitle"); - titleLabel->setWordWrap(true); - textLayout->addWidget(titleLabel); - - if (!description.isEmpty()) { - auto *descLabel = new QLabel(description, panel); - descLabel->setObjectName("SettingsCardDesc"); - descLabel->setWordWrap(true); - textLayout->addWidget(descLabel); - } - - panel->contentLayout()->addWidget(textContainer, 1); - - if (control) { - control->setParent(panel); - if (auto *combo = qobject_cast(control)) { - combo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - } else if (auto *btn = qobject_cast(control)) { - btn->setObjectName("SettingsCardButton"); - btn->setCursor(Qt::PointingHandCursor); - btn->setFixedHeight(30); - } else if (auto *lineEdit = qobject_cast(control)) { - lineEdit->setMinimumWidth(200); - lineEdit->setFixedHeight(32); - lineEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - } else if (qobject_cast(control)) { - control->setMinimumWidth(qMax(control->minimumWidth(), 220)); - control->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - } - - panel->contentLayout()->addWidget(control, 0, Qt::AlignVCenter); - bindSettingsControl(control, configKey, defaultValue); - } - - m_mainLayout->addWidget(panel); - danmakuPanels.append(panel); - return panel; - }; - - auto addDanmakuSliderSubPanel = - [&](const QString &iconPath, const QString &title, - const QString &description, DanmakuOptionUtils::SliderKind kind, - const QString &configKey) { - auto *control = new DanmakuOptionSlider(kind, configKey, this); - return addDanmakuSubPanel(iconPath, title, description, control); - }; - - auto *danmakuRendererCombo = new ModernComboBox(this); - danmakuRendererCombo->addItem(tr("ASS Subtitle Track"), - DanmakuRendererUtils::assTrackRendererId()); - danmakuRendererCombo->addItem( - tr("Native Smooth Renderer"), - DanmakuRendererUtils::nativeSmoothRendererId()); - addDanmakuSubPanel( - ":/svg/dark/danmaku.svg", tr("Danmaku Renderer"), - tr("Choose whether danmaku is rendered through the ASS subtitle track or qEmby's adaptive native renderer"), - danmakuRendererCombo, ConfigKeys::PlayerDanmakuRenderer, - QVariant(DanmakuRendererUtils::defaultRendererId())); - - auto *danmakuServerPanel = - new SettingsSubPanel(":/svg/dark/danmaku-server.svg", this); - auto *danmakuServerTextContainer = new QWidget(danmakuServerPanel); - danmakuServerTextContainer->setSizePolicy(QSizePolicy::Expanding, - QSizePolicy::Preferred); - auto *danmakuServerTextLayout = - new QVBoxLayout(danmakuServerTextContainer); - danmakuServerTextLayout->setContentsMargins(0, 0, 0, 0); - danmakuServerTextLayout->setSpacing(2); - - auto *danmakuServerTitleLabel = - new QLabel(tr("Danmaku Server"), danmakuServerPanel); - danmakuServerTitleLabel->setObjectName("SettingsCardTitle"); - danmakuServerTitleLabel->setWordWrap(true); - danmakuServerTextLayout->addWidget(danmakuServerTitleLabel); - - auto *danmakuServerDescLabel = new QLabel(danmakuServerPanel); - danmakuServerDescLabel->setObjectName("SettingsCardDesc"); - danmakuServerDescLabel->setWordWrap(true); - danmakuServerTextLayout->addWidget(danmakuServerDescLabel); - - danmakuServerPanel->contentLayout()->addWidget(danmakuServerTextContainer, - 1); - - auto *manageDanmakuServerBtn = - new QPushButton(tr("Manage"), danmakuServerPanel); - manageDanmakuServerBtn->setObjectName("SettingsCardButton"); - manageDanmakuServerBtn->setCursor(Qt::PointingHandCursor); - manageDanmakuServerBtn->setFixedHeight(30); - danmakuServerPanel->contentLayout()->addWidget(manageDanmakuServerBtn, 0, - Qt::AlignVCenter); - - m_mainLayout->addWidget(danmakuServerPanel); - danmakuPanels.append(danmakuServerPanel); - - const QString danmakuServersKey = - ConfigKeys::forServer(sid, ConfigKeys::DanmakuServers); - const QString danmakuSelectedServerKey = - ConfigKeys::forServer(sid, ConfigKeys::DanmakuSelectedServer); - const auto updateDanmakuServerSummary = - [this, sid, danmakuServerDescLabel]() { - const DanmakuServerDefinition selectedServer = - DanmakuSettings::selectedServer(sid); - QString serverName = selectedServer.name.trimmed(); - if (serverName == QStringLiteral("DandanPlay Open API")) { - serverName = tr("DandanPlay Open API"); - } - if (serverName.isEmpty()) { - serverName = selectedServer.baseUrl.trimmed(); - } - - const QUrl serverUrl = - QUrl::fromUserInput(selectedServer.baseUrl.trimmed()); - const bool isOfficialDandanPlay = - serverUrl.host().trimmed().compare( - QStringLiteral("api.dandanplay.net"), Qt::CaseInsensitive) == 0; - QStringList summaryLines; - summaryLines.append(tr("Current server: %1").arg(serverName)); - summaryLines.append( - tr("Address: %1").arg(selectedServer.baseUrl.trimmed())); - if (selectedServer.builtIn && - selectedServer.contentScope.trimmed().compare( - QStringLiteral("anime"), Qt::CaseInsensitive) == 0) { - summaryLines.append(tr("Supported content: Anime only")); - } - if (!selectedServer.builtIn && - !selectedServer.description.trimmed().isEmpty()) { - summaryLines.append( - tr("Description: %1").arg(selectedServer.description.trimmed())); - } - if (isOfficialDandanPlay && - (selectedServer.appId.trimmed().isEmpty() || - selectedServer.appSecret.trimmed().isEmpty())) { - summaryLines.append( - tr("App ID and App Secret are required for official DandanPlay Open API access")); - } - - danmakuServerDescLabel->setText(summaryLines.join(QStringLiteral("\n"))); - }; - updateDanmakuServerSummary(); - - connect(manageDanmakuServerBtn, &QPushButton::clicked, this, - [this, sid, updateDanmakuServerSummary]() { - DanmakuServerDialog dialog(this); - dialog.setServers(DanmakuSettings::loadServers(sid), - DanmakuSettings::selectedServerId(sid)); - if (dialog.exec() != QDialog::Accepted) { - return; - } - - DanmakuSettings::saveServers(sid, dialog.servers(), - dialog.selectedServerId()); - updateDanmakuServerSummary(); - }); - - connect(ConfigStore::instance(), &ConfigStore::valueChanged, - danmakuServerPanel, - [danmakuServersKey, danmakuSelectedServerKey, - updateDanmakuServerSummary](const QString &key, const QVariant &) { - if (key == danmakuServersKey || key == danmakuSelectedServerKey) { - updateDanmakuServerSummary(); - } - }); - - auto *danmakuSourceModeCombo = new ModernComboBox(this); - danmakuSourceModeCombo->addItem(tr("Prefer Local"), "prefer-local"); - danmakuSourceModeCombo->addItem(tr("Prefer Online"), "prefer-online"); - danmakuSourceModeCombo->addItem(tr("Online Only"), "online-only"); - danmakuSourceModeCombo->addItem(tr("Local Only"), "local-only"); - addDanmakuSubPanel( - ":/svg/dark/danmaku-related.svg", tr("Danmaku Source"), - tr("Choose whether local danmaku files or online danmaku should be preferred during matching"), - danmakuSourceModeCombo, - ConfigKeys::forServer(sid, ConfigKeys::DanmakuSourceMode), - QVariant("prefer-local")); - - auto *localDanmakuPanel = - new SettingsSubPanel(":/svg/dark/danmaku.svg", this); - auto *localDanmakuTextContainer = new QWidget(localDanmakuPanel); - localDanmakuTextContainer->setSizePolicy(QSizePolicy::Expanding, - QSizePolicy::Preferred); - auto *localDanmakuTextLayout = - new QVBoxLayout(localDanmakuTextContainer); - localDanmakuTextLayout->setContentsMargins(0, 0, 0, 0); - localDanmakuTextLayout->setSpacing(2); - - auto *localDanmakuTitleLabel = - new QLabel(tr("Local Danmaku Folder"), localDanmakuPanel); - localDanmakuTitleLabel->setObjectName("SettingsCardTitle"); - localDanmakuTitleLabel->setWordWrap(true); - localDanmakuTextLayout->addWidget(localDanmakuTitleLabel); - - const QString localDanmakuDir = - DanmakuService::localDanmakuDirectoryPath(sid); - auto *localDanmakuDescLabel = new QLabel(localDanmakuPanel); - localDanmakuDescLabel->setObjectName("SettingsCardDesc"); - localDanmakuDescLabel->setWordWrap(true); - localDanmakuDescLabel->setText( - tr("Place .ass, .json, or .xml danmaku files here. Matching uses the current media title and manual keyword, and the selected danmaku source mode decides whether local files or online results are preferred first.\nFolder: %1") - .arg(QDir::toNativeSeparators(localDanmakuDir))); - localDanmakuTextLayout->addWidget(localDanmakuDescLabel); - - localDanmakuPanel->contentLayout()->addWidget(localDanmakuTextContainer, 1); - - auto *openLocalDanmakuFolderBtn = - new QPushButton(tr("Open Folder"), localDanmakuPanel); - openLocalDanmakuFolderBtn->setObjectName("SettingsCardButton"); - openLocalDanmakuFolderBtn->setCursor(Qt::PointingHandCursor); - openLocalDanmakuFolderBtn->setFixedHeight(30); - localDanmakuPanel->contentLayout()->addWidget(openLocalDanmakuFolderBtn, 0, - Qt::AlignVCenter); - - m_mainLayout->addWidget(localDanmakuPanel); - danmakuPanels.append(localDanmakuPanel); - - connect(openLocalDanmakuFolderBtn, &QPushButton::clicked, this, - [sid]() { - if (!DanmakuService::ensureLocalDanmakuDirectory(sid)) { - return; - } - - const QString folderPath = - DanmakuService::localDanmakuDirectoryPath(sid); - const QString readmePath = folderPath + QStringLiteral("/README.txt"); - if (!QFile::exists(readmePath)) { - QFile readmeFile(readmePath); - if (readmeFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - QTextStream out(&readmeFile); - out << "qEmby local danmaku folder\n"; - out << "\n"; - out << "Supported file types:\n"; - out << "- .ass : attached directly to the embedded player\n"; - out << "- .json: parsed into danmaku comments and rendered to ASS\n"; - out << "- .xml : Bilibili-style XML danmaku parsed and rendered to ASS\n"; - out << "\n"; - out << "Source mode:\n"; - out << "- Set Danmaku Source in Player settings to choose local-first, online-first, or local-only behavior\n"; - out << "\n"; - out << "Matching rules:\n"; - out << "- Filenames are matched against the current media title\n"; - out << "- The player's manual danmaku keyword is also matched against filenames\n"; - out << "- Use filenames close to the video title for best results\n"; - out << "\n"; - out << "Simple JSON example:\n"; - out << "{\n"; - out << " \"comments\": [\n"; - out << " {\"timeMs\": 1200, \"text\": \"First danmaku\"},\n"; - out << " {\"timeMs\": 3500, \"text\": \"Top danmaku\", \"mode\": 5, \"color\": \"#FFD54F\"},\n"; - out << " {\"timeMs\": 5200, \"text\": \"Bottom danmaku\", \"mode\": 4}\n"; - out << " ]\n"; - out << "}\n"; - } - } - - QDesktopServices::openUrl(QUrl::fromLocalFile(folderPath)); - }); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-auto-load.svg", tr("Auto Load Danmaku"), - tr("Automatically search and load danmaku when playback starts"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::DanmakuAutoLoad), QVariant(true)); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-auto-match.svg", tr("Auto Match Danmaku"), - tr("Automatically choose the best danmaku match when no cached manual result exists"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::DanmakuAutoMatch), - QVariant(true)); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-related.svg", tr("Load Related Comments"), - tr("Include related third-party comment sources when the provider supports it"), - new ModernSwitch(this), - ConfigKeys::forServer(sid, ConfigKeys::DanmakuWithRelated), - QVariant(true)); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-dual-subtitle.svg", tr("Dual Subtitle Mode"), - tr("Keep regular subtitles visible alongside danmaku when possible"), - new ModernSwitch(this), ConfigKeys::PlayerDanmakuDualSubtitle, - QVariant(true)); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-opacity.svg", tr("Danmaku Opacity"), - tr("Control the transparency of rendered danmaku"), - DanmakuOptionUtils::SliderKind::Opacity, - ConfigKeys::PlayerDanmakuOpacity); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-size.svg", tr("Danmaku Size"), - tr("Scale the font size used for danmaku rendering"), - DanmakuOptionUtils::SliderKind::FontScale, - ConfigKeys::PlayerDanmakuFontScale); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-area.svg", tr("Display Area"), - tr("Limit how much of the screen height danmaku can occupy"), - DanmakuOptionUtils::SliderKind::Area, - ConfigKeys::PlayerDanmakuAreaPercent); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-density.svg", tr("Danmaku Density"), - tr("Reduce or increase the number of simultaneous lanes"), - DanmakuOptionUtils::SliderKind::Density, - ConfigKeys::PlayerDanmakuDensity); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-speed.svg", tr("Danmaku Speed"), - tr("Adjust how quickly scrolling danmaku travels across the screen"), - DanmakuOptionUtils::SliderKind::SpeedScale, - ConfigKeys::PlayerDanmakuSpeedScale); - - addDanmakuSliderSubPanel( - ":/svg/dark/danmaku-offset.svg", tr("Danmaku Offset"), - tr("Shift danmaku timing earlier or later to align with playback"), - DanmakuOptionUtils::SliderKind::OffsetMs, - ConfigKeys::PlayerDanmakuOffsetMs); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-hide-scroll.svg", tr("Hide Scrolling Danmaku"), - tr("Hide standard right-to-left scrolling comments"), - new ModernSwitch(this), ConfigKeys::PlayerDanmakuHideScroll, - QVariant(false)); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-hide-top.svg", tr("Hide Top Danmaku"), - tr("Hide fixed danmaku positioned at the top of the screen"), - new ModernSwitch(this), ConfigKeys::PlayerDanmakuHideTop, - QVariant(false)); - - addDanmakuSubPanel( - ":/svg/dark/danmaku-hide-bottom.svg", tr("Hide Bottom Danmaku"), - tr("Hide fixed danmaku positioned at the bottom of the screen"), - new ModernSwitch(this), ConfigKeys::PlayerDanmakuHideBottom, - QVariant(false)); - - auto *blockedKeywordsInput = new ModernTagInput(this); - blockedKeywordsInput->setMinimumWidth(360); - addDanmakuSubPanel( - ":/svg/dark/danmaku-blocked.svg", tr("Blocked Keywords"), - tr("Filter out danmaku containing these keywords"), - blockedKeywordsInput, ConfigKeys::PlayerDanmakuBlockedKeywords); - - auto *cacheHoursCombo = new ModernComboBox(this); - cacheHoursCombo->addItem(tr("6 Hours"), "6"); - cacheHoursCombo->addItem(tr("12 Hours"), "12"); - cacheHoursCombo->addItem(tr("1 Day"), "24"); - cacheHoursCombo->addItem(tr("3 Days"), "72"); - cacheHoursCombo->addItem(tr("7 Days"), "168"); - addDanmakuSubPanel( - ":/svg/dark/danmaku-cache-duration.svg", tr("Danmaku Cache Duration"), - tr("Reuse online match results, cached comment payloads, and generated ASS files for this long"), - cacheHoursCombo, - ConfigKeys::forServer(sid, ConfigKeys::DanmakuCacheHours), - QVariant("24")); - - auto *clearDanmakuCacheBtn = new QPushButton(tr("Clear"), this); - addDanmakuSubPanel( - ":/svg/dark/danmaku-cache-clear.svg", tr("Clear Danmaku Cache"), - tr("Remove cached online matches, cached comment payloads, and generated ASS files"), - clearDanmakuCacheBtn); - connect(clearDanmakuCacheBtn, &QPushButton::clicked, this, [this]() { - if (m_core && m_core->danmakuService()) { - m_core->danmakuService()->clearCache(); - ModernToast::showMessage(tr("Danmaku cache cleared")); - } - }); - - const auto updateDanmakuPanels = [danmakuPanels](bool enabled) { - for (auto *panel : danmakuPanels) { - if (!panel) { - continue; - } - panel->setExpandedImmediately(enabled); - } - }; - - if (danmakuEnabled) { - updateDanmakuPanels(true); - } - - connect(danmakuSwitch, &ModernSwitch::toggled, this, - [updateDanmakuPanels](bool enabled) { - updateDanmakuPanels(enabled); - }); - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [danmakuEnabledKey, updateDanmakuPanels](const QString &key, - const QVariant &newValue) { - if (key == danmakuEnabledKey) { - updateDanmakuPanels(newValue.toBool()); - } - }); - - - auto *extTitle = new QLabel(tr("External Player"), this); - extTitle->setObjectName("SettingsSubTitle"); - m_mainLayout->addWidget(extTitle); - - QStringList supportedExternalPlayers; - supportedExternalPlayers = {QStringLiteral("MPV"), - QStringLiteral("VLC"), - QStringLiteral("MPC-Qt")}; - const QString externalPlayerSupportText = - tr("Supported players on this platform: %1") - .arg(supportedExternalPlayers.join(QStringLiteral(", "))); - - auto *extSwitch = new ModernSwitch(this); - m_mainLayout->addWidget(new SettingsCard( - ":/svg/dark/external-player.svg", tr("Enable External Player"), - externalPlayerSupportText, extSwitch, ConfigKeys::ExtPlayerEnable, - this)); - - - - - auto *playerPanel = - new SettingsSubPanel(":/svg/dark/external-player.svg", this); - auto *playerCombo = new PlayerComboBox(this); - - - auto syncAllPlayersToConfig = [](PlayerComboBox *combo) { - QJsonArray arr; - for (int i = 0; i < combo->count(); ++i) { - QString source = - combo->itemData(i, PlayerComboBox::SourceRole).toString(); - if (source == PlayerComboBox::SourceCustom) - continue; - QString path = combo->itemData(i).toString(); - if (path.isEmpty()) - continue; - QJsonObject obj; - obj["name"] = QFileInfo(path).baseName(); - obj["path"] = path; - arr.append(obj); - } - ConfigStore::instance()->set( - ConfigKeys::ExtPlayerDetectedList, - QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact))); - }; - - - - - - - - QList detectedPlayers = - ExternalPlayerDetector::loadFromConfig(); - QString currentPath = - ConfigStore::instance()->get(ConfigKeys::ExtPlayerPath); - int selectedIdx = -1; - - for (int i = 0; i < detectedPlayers.size(); ++i) { - const auto &p = detectedPlayers[i]; - playerCombo->addPlayer(QString("%1 (%2)").arg(p.name, p.path), p.path, - PlayerComboBox::SourceAuto); - if (p.path == currentPath) - selectedIdx = i; - } - - playerCombo->addPlayer(tr("Custom..."), "custom", - PlayerComboBox::SourceCustom); - - - if (selectedIdx >= 0) { - playerCombo->setCurrentIndex(selectedIdx); - } else if (!currentPath.isEmpty() && currentPath != "custom") { - playerCombo->addPlayer(QDir::toNativeSeparators(currentPath), currentPath, - PlayerComboBox::SourceManual); - - int manualIdx = playerCombo->findData(currentPath); - if (manualIdx >= 0) - playerCombo->setCurrentIndex(manualIdx); - } - - - - qDebug() << "[PagePlayer] Async external player detection scheduled" - << "| cached count:" << detectedPlayers.size(); - auto *detectWatcher = new QFutureWatcher>(this); - QPointer safeCombo(playerCombo); - connect(detectWatcher, - &QFutureWatcher>::finished, this, - [detectWatcher, safeCombo, syncAllPlayersToConfig]() { - detectWatcher->deleteLater(); - if (!safeCombo) { - qDebug() << "[PagePlayer] Detection finished but combo was " - "destroyed; skip refresh"; - return; - } - const QList fresh = detectWatcher->result(); - qDebug() << "[PagePlayer] External player detection finished" - << "| detected count:" << fresh.size(); - - const QString prevPath = safeCombo->currentPlayerPath(); - - - - QSignalBlocker blocker(safeCombo); - safeCombo->removeAutoDetected(); - - - - for (const auto &p : fresh) { - safeCombo->addPlayer(QString("%1 (%2)").arg(p.name, p.path), - p.path, PlayerComboBox::SourceAuto); - } - - - const int restoreIdx = safeCombo->findData(prevPath); - if (restoreIdx >= 0) { - safeCombo->setCurrentIndex(restoreIdx); - } else if (safeCombo->count() > 0) { - safeCombo->setCurrentIndex(0); - } - - - syncAllPlayersToConfig(safeCombo); - ExternalPlayerDetector::saveToConfig(fresh); - }); - - detectWatcher->setFuture(QtConcurrent::run( - []() { return ExternalPlayerDetector::detect(); })); - - auto *playerLabel = new QLabel(tr("Player"), this); - playerLabel->setObjectName("SettingsCardDesc"); - - - auto *searchBtn = new QPushButton(this); - searchBtn->setObjectName("SettingsIconBtn"); - searchBtn->setCursor(Qt::PointingHandCursor); - searchBtn->setFixedSize(28, 28); - searchBtn->setToolTip(tr("Re-detect installed players")); - - playerPanel->contentLayout()->addWidget(playerLabel); - playerPanel->contentLayout()->addWidget(playerCombo); - playerPanel->contentLayout()->addWidget(searchBtn); - - m_mainLayout->addWidget(playerPanel); - - - connect( - searchBtn, &QPushButton::clicked, this, - [this, playerCombo, syncAllPlayersToConfig, searchBtn]() { - - bool reduceAnimations = - ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (!reduceAnimations) { - - auto originalPix = std::make_shared( - searchBtn->icon().pixmap(searchBtn->iconSize())); - - auto *rotAnim = new QVariantAnimation(searchBtn); - rotAnim->setStartValue(0.0); - rotAnim->setEndValue(360.0); - rotAnim->setDuration(AnimationTokens::DurationMs::kSlow); - rotAnim->setEasingCurve(AnimationTokens::Curve::enter()); - - connect(rotAnim, &QVariantAnimation::valueChanged, searchBtn, - [searchBtn, originalPix](const QVariant &val) { - qreal angle = val.toReal(); - QTransform rot; - rot.rotate(angle); - QPixmap rotated = - originalPix->transformed(rot, Qt::SmoothTransformation); - - int dx = (rotated.width() - originalPix->width()) / 2; - int dy = (rotated.height() - originalPix->height()) / 2; - QPixmap cropped = rotated.copy(dx, dy, originalPix->width(), - originalPix->height()); - searchBtn->setIcon(QIcon(cropped)); - }); - - connect(rotAnim, &QVariantAnimation::finished, searchBtn, - [searchBtn]() { - - searchBtn->style()->unpolish(searchBtn); - searchBtn->style()->polish(searchBtn); - }); - - rotAnim->start(QAbstractAnimation::DeleteWhenStopped); - } - - - QString prevPath = playerCombo->currentPlayerPath(); - - - - playerCombo->blockSignals(true); - playerCombo->removeAutoDetected(); - - QList newPlayers = ExternalPlayerDetector::detect(); - for (const auto &p : newPlayers) { - playerCombo->addPlayer(QString("%1 (%2)").arg(p.name, p.path), - p.path, PlayerComboBox::SourceAuto); - } - - - int prevIdx = playerCombo->findData(prevPath); - if (prevIdx >= 0) { - playerCombo->setCurrentIndex(prevIdx); - } else if (playerCombo->count() > 0) { - playerCombo->setCurrentIndex(0); - } - playerCombo->blockSignals(false); - - - syncAllPlayersToConfig(playerCombo); - - - QString syncPath = playerCombo->currentPlayerPath(); - if (syncPath != "custom") { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerPath, syncPath); - } - }); - - - connect(playerCombo, &PlayerComboBox::playerRemoved, this, - [playerCombo, syncAllPlayersToConfig]() { - syncAllPlayersToConfig(playerCombo); - ConfigStore::instance()->set(ConfigKeys::ExtPlayerPath, - playerCombo->currentPlayerPath()); - }); - - - connect( - playerCombo, QOverload::of(&QComboBox::activated), this, - [this, playerCombo, syncAllPlayersToConfig](int idx) { - if (idx < 0) - return; - QString source = - playerCombo->itemData(idx, PlayerComboBox::SourceRole).toString(); - QString data = playerCombo->itemData(idx).toString(); - - if (source == PlayerComboBox::SourceCustom) { - QString filePath = QFileDialog::getOpenFileName( - this, tr("Select External Player"), QString(), - tr("All Files (*)") - ); - if (!filePath.isEmpty()) { - filePath = QDir::toNativeSeparators(filePath); - playerCombo->blockSignals(true); - playerCombo->addPlayer(filePath, filePath, - PlayerComboBox::SourceManual); - int newIdx = playerCombo->findData(filePath); - playerCombo->setCurrentIndex(newIdx); - playerCombo->blockSignals(false); - syncAllPlayersToConfig(playerCombo); - ConfigStore::instance()->set(ConfigKeys::ExtPlayerPath, filePath); - } else { - - QString prevPath = ConfigStore::instance()->get( - ConfigKeys::ExtPlayerPath); - int prevIdx = playerCombo->findData(prevPath); - if (prevIdx >= 0) { - playerCombo->blockSignals(true); - playerCombo->setCurrentIndex(prevIdx); - playerCombo->blockSignals(false); - } - } - } else { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerPath, data); - } - }); - - - auto *argsPanel = new SettingsSubPanel(":/svg/dark/player-args.svg", this); - auto *argsLabel = new QLabel(tr("Arguments"), this); - argsLabel->setObjectName("SettingsCardDesc"); - auto *argsEdit = new QLineEdit(this); - argsEdit->setPlaceholderText(tr("e.g., --fullscreen --volume=100")); - argsEdit->setMinimumWidth(200); - argsEdit->setFixedHeight(32); - argsEdit->setText( - ConfigStore::instance()->get(ConfigKeys::ExtPlayerArgs)); - - connect(argsEdit, &QLineEdit::editingFinished, this, [argsEdit]() { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerArgs, argsEdit->text()); - }); - - argsPanel->contentLayout()->addWidget(argsLabel); - argsPanel->contentLayout()->addWidget(argsEdit, 1); - m_mainLayout->addWidget(argsPanel); - - - auto *quickPlayPanel = - new SettingsSubPanel(":/svg/dark/quick-play.svg", this); - auto *quickPlayLabel = - new QLabel(tr("Quick Play with External Player"), this); - quickPlayLabel->setObjectName("SettingsCardDesc"); - auto *quickPlayDesc = new QLabel( - tr("Use external player for quick play actions on cards and menus"), - this); - quickPlayDesc->setObjectName("SettingsCardDesc"); - quickPlayDesc->setWordWrap(true); - auto *quickPlaySwitch = new ModernSwitch(this); - quickPlaySwitch->setChecked(ConfigStore::instance()->get( - ConfigKeys::ExtPlayerQuickPlay, false)); - connect(quickPlaySwitch, &ModernSwitch::toggled, this, [](bool checked) { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerQuickPlay, checked); - }); - - quickPlayPanel->contentLayout()->addWidget(quickPlayLabel); - quickPlayPanel->contentLayout()->addWidget(quickPlayDesc, 1); - quickPlayPanel->contentLayout()->addWidget(quickPlaySwitch); - m_mainLayout->addWidget(quickPlayPanel); - - - auto *directPanel = - new SettingsSubPanel(":/svg/dark/direct-stream.svg", this); - auto *directLabel = new QLabel(tr("Direct Stream"), this); - directLabel->setObjectName("SettingsCardDesc"); - auto *directDesc = - new QLabel(tr("Bypass server proxy and stream directly from source. " - "May not work on external networks"), - this); - directDesc->setObjectName("SettingsCardDesc"); - directDesc->setWordWrap(true); - auto *directSwitch = new ModernSwitch(this); - directSwitch->setChecked(ConfigStore::instance()->get( - ConfigKeys::ExtPlayerDirectStream, false)); - connect(directSwitch, &ModernSwitch::toggled, this, [](bool checked) { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerDirectStream, checked); - }); - - directPanel->contentLayout()->addWidget(directLabel); - directPanel->contentLayout()->addWidget(directDesc, 1); - directPanel->contentLayout()->addWidget(directSwitch); - m_mainLayout->addWidget(directPanel); - - - - auto *replaceFrame = new QFrame(this); - replaceFrame->setObjectName("UrlReplaceFrame"); - auto *replaceOuterLayout = new QHBoxLayout(replaceFrame); - replaceOuterLayout->setContentsMargins(16, 10, 16, 10); - replaceOuterLayout->setSpacing(12); - - - auto *replaceIcon = new QLabel(replaceFrame); - replaceIcon->setFixedSize(16, 16); - replaceIcon->setAlignment(Qt::AlignCenter); - const QString replaceIconPath = ":/svg/dark/url-replace.svg"; - replaceIcon->setPixmap( - ThemeManager::getAdaptiveIcon(replaceIconPath).pixmap(16, 16)); - replaceOuterLayout->addWidget(replaceIcon, 0, Qt::AlignTop); - - - auto *replaceVLayout = new QVBoxLayout(); - replaceVLayout->setContentsMargins(0, 0, 0, 0); - replaceVLayout->setSpacing(4); - - auto *replaceLabel = - new QLabel(tr("URL Path Replacement one rule per line: source => target"), - replaceFrame); - replaceLabel->setObjectName("SettingsCardDesc"); - replaceLabel->setWordWrap(true); - - auto *replaceEdit = new QPlainTextEdit(replaceFrame); - replaceEdit->setPlaceholderText( - tr("e.g., http://192.168.2.1:19798/path/ => W:/mount/")); - replaceEdit->setFixedHeight(80); - replaceEdit->setPlainText( - ConfigStore::instance()->get(ConfigKeys::ExtPlayerUrlReplace)); - - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, replaceFrame, - [replaceIcon, replaceIconPath]() { - replaceIcon->setPixmap( - ThemeManager::getAdaptiveIcon(replaceIconPath).pixmap(16, 16)); - }); - - connect(replaceEdit, &QPlainTextEdit::textChanged, this, [replaceEdit]() { - ConfigStore::instance()->set(ConfigKeys::ExtPlayerUrlReplace, - replaceEdit->toPlainText()); - }); - - replaceVLayout->addWidget(replaceLabel); - replaceVLayout->addWidget(replaceEdit); - replaceOuterLayout->addLayout(replaceVLayout, 1); - m_mainLayout->addWidget(replaceFrame); - - - bool extEnabled = - ConfigStore::instance()->get(ConfigKeys::ExtPlayerEnable, false); - if (extEnabled) { - playerPanel->initExpanded(); - argsPanel->initExpanded(); - directPanel->initExpanded(); - quickPlayPanel->initExpanded(); - } - replaceFrame->setVisible(extEnabled); - - connect(extSwitch, &ModernSwitch::toggled, playerPanel, - &SettingsSubPanel::setExpanded); - connect(extSwitch, &ModernSwitch::toggled, argsPanel, - &SettingsSubPanel::setExpanded); - connect(extSwitch, &ModernSwitch::toggled, directPanel, - &SettingsSubPanel::setExpanded); - connect(extSwitch, &ModernSwitch::toggled, quickPlayPanel, - &SettingsSubPanel::setExpanded); - connect(extSwitch, &ModernSwitch::toggled, replaceFrame, - &QWidget::setVisible); - - - m_mainLayout->addStretch(); -} diff --git a/src/qEmbyApp/views/settings/pageplayer.h b/src/qEmbyApp/views/settings/pageplayer.h deleted file mode 100644 index ea45b20..0000000 --- a/src/qEmbyApp/views/settings/pageplayer.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef PAGE_PLAYER_H -#define PAGE_PLAYER_H - -#include "settingspagebase.h" - -class PagePlayer : public SettingsPageBase { - Q_OBJECT -public: - explicit PagePlayer(QEmbyCore* core, QWidget* parent = nullptr); -}; - -#endif diff --git a/src/qEmbyApp/views/settings/settingspagebase.cpp b/src/qEmbyApp/views/settings/settingspagebase.cpp deleted file mode 100644 index 1958472..0000000 --- a/src/qEmbyApp/views/settings/settingspagebase.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#include "settingspagebase.h" -#include - -SettingsPageBase::SettingsPageBase(QEmbyCore* core, const QString& pageTitle, QWidget* parent) - : QWidget(parent), m_core(core) -{ - - m_mainLayout = new QVBoxLayout(this); - m_mainLayout->setContentsMargins(16, 30, 16, 40); - m_mainLayout->setSpacing(16); - - - if (!pageTitle.isEmpty()) { - auto* titleLabel = new QLabel(pageTitle, this); - titleLabel->setObjectName("SettingsPageTitle"); - m_mainLayout->addWidget(titleLabel); - m_mainLayout->addSpacing(10); - } -} diff --git a/src/qEmbyApp/views/settings/settingspagebase.h b/src/qEmbyApp/views/settings/settingspagebase.h deleted file mode 100644 index 62992b1..0000000 --- a/src/qEmbyApp/views/settings/settingspagebase.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef SETTINGSPAGEBASE_H -#define SETTINGSPAGEBASE_H - -#include -#include -#include - -class QEmbyCore; -class QLabel; - -class SettingsPageBase : public QWidget { - Q_OBJECT -public: - explicit SettingsPageBase(QEmbyCore* core, const QString& pageTitle, QWidget* parent = nullptr); - ~SettingsPageBase() override = default; - - -protected: - QEmbyCore* m_core; - QVBoxLayout* m_mainLayout; -}; - -#endif diff --git a/src/qEmbyApp/views/settings/settingsview.cpp b/src/qEmbyApp/views/settings/settingsview.cpp deleted file mode 100644 index 7f3825f..0000000 --- a/src/qEmbyApp/views/settings/settingsview.cpp +++ /dev/null @@ -1,309 +0,0 @@ -#include "settingsview.h" -#include "../../quick/aboutviewmodel.h" -#include "../../quick/quickpagehost.h" -#include "../../quick/settingsviewmodel.h" -#include "../../tokens/AnimationTokens.h" -#include "../../utils/motionprefs.h" -#include "../../components/slidingstackedwidget.h" -#include "../../managers/thememanager.h" -#include "pageabout.h" -#include "pageappearance.h" -#include "pagegeneral.h" -#include "pageplayer.h" -#include "pagelibrary.h" -#include -#include -#include -#include -#include -#include -#include - - -SettingsView::SettingsView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - - setAttribute(Qt::WA_StyledBackground, true); - setObjectName("SettingsRootView"); - - m_settingsVm = new SettingsViewModel(this); - - setupUi(); - setupConnections(); - - - - m_navMenu->setCurrentRow(0); -} - -void SettingsView::setupUi() { - auto *mainLayout = new QHBoxLayout(this); - - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); - - - m_leftPanel = new QWidget(this); - m_leftPanel->setFixedWidth(260); - m_leftPanel->setObjectName("SettingsLeftPanel"); - auto *leftLayout = new QVBoxLayout(m_leftPanel); - - leftLayout->setContentsMargins(32, 32, 32, 32); - leftLayout->setSpacing(24); - - - m_titleLabel = new QLabel(tr("Settings"), m_leftPanel); - m_titleLabel->setObjectName("SettingsMainTitle"); - - - m_navMenu = new QListWidget(m_leftPanel); - m_navMenu->setObjectName("SettingsNavMenu"); - m_navMenu->setFocusPolicy(Qt::NoFocus); - - m_navMenu->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_navMenu->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - - auto *itemGeneral = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/general.svg"), tr(" General")); - itemGeneral->setData(Qt::UserRole, ":/svg/dark/general.svg"); - - auto *itemAppearance = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/appearance.svg"), - tr(" Appearance")); - itemAppearance->setData(Qt::UserRole, ":/svg/dark/appearance.svg"); - - auto *itemPlayer = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/player.svg"), tr(" Player")); - itemPlayer->setData(Qt::UserRole, ":/svg/dark/player.svg"); - - auto *itemLibrary = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/library.svg"), tr(" Library")); - itemLibrary->setData(Qt::UserRole, ":/svg/dark/library.svg"); - - auto *itemAbout = new QListWidgetItem( - ThemeManager::getAdaptiveIcon(":/svg/dark/about.svg"), tr(" About")); - itemAbout->setData(Qt::UserRole, ":/svg/dark/about.svg"); - - - itemGeneral->setSizeHint(QSize(220, 44)); - itemAppearance->setSizeHint(QSize(220, 44)); - itemPlayer->setSizeHint(QSize(220, 44)); - itemLibrary->setSizeHint(QSize(220, 44)); - itemAbout->setSizeHint(QSize(220, 44)); - - m_navMenu->addItem(itemGeneral); - m_navMenu->addItem(itemAppearance); - m_navMenu->addItem(itemLibrary); - m_navMenu->addItem(itemPlayer); - m_navMenu->addItem(itemAbout); - - leftLayout->addWidget(m_titleLabel); - leftLayout->addWidget(m_navMenu); - - - m_stack = new SlidingStackedWidget(this); - m_stack->setObjectName("SettingsStack"); - - - - - - const int kPageCount = 5; - m_scrollAreas.reserve(kPageCount); - m_scrollAnims.reserve(kPageCount); - m_scrollTargets.reserve(kPageCount); - m_pages.reserve(kPageCount); - for (int i = 0; i < kPageCount; ++i) { - auto *placeholder = new QWidget(m_stack); - placeholder->setAttribute(Qt::WA_StyledBackground, true); - placeholder->setObjectName("SettingsPagePlaceholder"); - m_stack->addWidget(placeholder); - - m_scrollAreas.append(nullptr); - m_scrollAnims.append(nullptr); - m_scrollTargets.append(0); - m_pages.append(QPointer()); - } - - mainLayout->addWidget(m_leftPanel); - mainLayout->addWidget(m_stack, 1); - - - qApp->postEvent(this, new QEvent(QEvent::StyleChange)); -} - -QScrollArea *SettingsView::wrapInScrollArea(QWidget *page, int row) { - - page->setAttribute(Qt::WA_StyledBackground, true); - - auto *scroll = new QScrollArea(m_stack); - scroll->setObjectName("SettingsScrollArea"); - scroll->setWidget(page); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - - - scroll->viewport()->setAutoFillBackground(false); - - - scroll->viewport()->installEventFilter(this); - - - if (row >= 0 && row < m_scrollAreas.size()) { - m_scrollAreas[row] = scroll; - - auto *anim = - new QPropertyAnimation(scroll->verticalScrollBar(), "value", this); - anim->setEasingCurve(AnimationTokens::Curve::enter()); - anim->setDuration(AnimationTokens::DurationMs::kSlow); - m_scrollAnims[row] = anim; - m_scrollTargets[row] = 0; - } - - return scroll; -} - -void SettingsView::ensurePageAt(int row) { - if (row < 0 || row >= m_pages.size()) { - return; - } - if (m_pages[row]) { - - return; - } - - qDebug() << "[SettingsView] Lazy instantiate page row=" << row; - - QWidget *pageWidget = nullptr; - switch (row) { - case 0: { - auto *host = new QuickPageHost(m_stack); - host->setContextProperty(QStringLiteral("settings"), m_settingsVm); - host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsGeneralPage.qml"))); - pageWidget = host; - break; - } - case 1: { - auto *host = new QuickPageHost(m_stack); - host->setContextProperty(QStringLiteral("settings"), m_settingsVm); - host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsAppearancePage.qml"))); - pageWidget = host; - break; - } - case 2: { - auto *host = new QuickPageHost(m_stack); - host->setContextProperty(QStringLiteral("settings"), m_settingsVm); - host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsLibraryPage.qml"))); - pageWidget = host; - break; - } - case 3: { - auto *host = new QuickPageHost(m_stack); - host->setContextProperty(QStringLiteral("settings"), m_settingsVm); - host->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsPlayerPage.qml"))); - pageWidget = host; - break; - } - case 4: { - // About 已迁移为 QML 试点页,内容自适应窗口高度,不套 QScrollArea - auto *aboutHost = new QuickPageHost(m_stack); - auto *aboutVm = new AboutViewModel(aboutHost, aboutHost); - aboutHost->setContextProperty(QStringLiteral("about"), aboutVm); - aboutHost->load(QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/SettingsAboutPage.qml"))); - pageWidget = aboutHost; - break; - } - default: - return; - } - - - - QWidget *placeholder = m_stack->widget(row); - const bool wasBlocked = m_stack->blockSignals(true); - m_stack->insertWidget(row, pageWidget); - m_stack->removeWidget(placeholder); - m_stack->blockSignals(wasBlocked); - m_stack->disposeWidgetWhenSafe(placeholder); - - m_pages[row] = pageWidget; -} - -void SettingsView::setupConnections() { - - connect(m_navMenu, &QListWidget::currentRowChanged, this, [this](int row) { - if (row >= 0) { - ensurePageAt(row); - // Vertical slide following the nav list's direction of travel. - m_stack->slideInIdx(row, row > m_stack->currentIndex() - ? SlidingStackedWidget::BottomToTop - : SlidingStackedWidget::TopToBottom); - } - }); - - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - &SettingsView::onThemeChanged); -} - - -void SettingsView::onThemeChanged() { - for (int i = 0; i < m_navMenu->count(); ++i) { - auto *item = m_navMenu->item(i); - - QString svgPath = item->data(Qt::UserRole).toString(); - if (!svgPath.isEmpty()) { - - item->setIcon(ThemeManager::getAdaptiveIcon(svgPath)); - } - } -} - -bool SettingsView::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::Wheel) { - - for (int i = 0; i < m_scrollAreas.size(); ++i) { - - if (!m_scrollAreas[i]) { - continue; - } - if (obj == m_scrollAreas[i]->viewport()) { - auto *we = static_cast(event); - auto *vBar = m_scrollAreas[i]->verticalScrollBar(); - auto *anim = m_scrollAnims[i]; - - if (vBar && anim) { - int currentVal = vBar->value(); - - - if (anim->state() == QAbstractAnimation::Running) { - currentVal = m_scrollTargets[i]; - } - - - int step = we->angleDelta().y(); - int newTarget = currentVal - step; - - - newTarget = qBound(vBar->minimum(), newTarget, vBar->maximum()); - - if (newTarget != vBar->value()) { - m_scrollTargets[i] = newTarget; - anim->stop(); - if (MotionPrefs::reduceMotion()) { - vBar->setValue(newTarget); - } else { - anim->setStartValue(vBar->value()); - anim->setEndValue(newTarget); - anim->start(); - } - } - } - return true; - } - } - } - - return QWidget::eventFilter(obj, event); -} diff --git a/src/qEmbyApp/views/settings/settingsview.h b/src/qEmbyApp/views/settings/settingsview.h deleted file mode 100644 index 69e4790..0000000 --- a/src/qEmbyApp/views/settings/settingsview.h +++ /dev/null @@ -1,58 +0,0 @@ -#ifndef SETTINGSVIEW_H -#define SETTINGSVIEW_H - -#include "../baseview.h" -#include -#include -#include -#include -#include -#include -#include - -class SlidingStackedWidget; -class SettingsViewModel; - -class SettingsView : public BaseView { - Q_OBJECT -public: - explicit SettingsView(QEmbyCore* core, QWidget* parent = nullptr); - ~SettingsView() override = default; - -protected: - bool eventFilter(QObject* obj, QEvent* event) override; - -private: - void setupUi(); - void setupConnections(); - - - - void ensurePageAt(int row); - - - - QScrollArea* wrapInScrollArea(QWidget* page, int row); - -private slots: - - void onThemeChanged(); - -private: - QWidget* m_leftPanel; - QLabel* m_titleLabel; - QListWidget* m_navMenu; - SlidingStackedWidget* m_stack; - SettingsViewModel* m_settingsVm; - - - - QList m_scrollAreas; - QList m_scrollAnims; - QList m_scrollTargets; - - - QList> m_pages; -}; - -#endif diff --git a/src/qEmbyApp/views/user/categoryview.cpp b/src/qEmbyApp/views/user/categoryview.cpp deleted file mode 100644 index 7f2835f..0000000 --- a/src/qEmbyApp/views/user/categoryview.cpp +++ /dev/null @@ -1,78 +0,0 @@ -#include "categoryview.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "categoryviewmodel.h" - -#include -#include -#include -#include -#include - -CategoryView::CategoryView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("category-view")); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new CategoryViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("categoryPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/CategoryPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, this, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, this, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &CategoryViewModel::itemActivated, this, - [this](const MediaItem &item) { - if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) { - emit navigateToFolder(item.id, item.name); - } else if (item.type == QLatin1String("Person")) { - emit navigateToPerson(item.id, item.name); - } else { - emit navigateToDetail(item.id, item.name, item); - } - }); - connect(m_viewModel, &CategoryViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &CategoryViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &CategoryViewModel::moreRequested, this, - [this](const MediaItem &item) { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -QCoro::Task CategoryView::loadCategory(const QString &categoryType, - const QString &title) { - co_await m_viewModel->loadCategory(categoryType, title); -} - -void CategoryView::prepareForStackLeave() { m_viewModel->setActive(false); } - -void CategoryView::scrollToTop() { - if (QQuickItem *root = m_quickHost->quickWidget()->rootObject()) - QMetaObject::invokeMethod(root, "scrollToTop"); -} - -void CategoryView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void CategoryView::onMediaItemRemoved(const QString &itemId) { - m_viewModel->removeItem(itemId); -} diff --git a/src/qEmbyApp/views/user/categoryview.h b/src/qEmbyApp/views/user/categoryview.h deleted file mode 100644 index 9bbabe4..0000000 --- a/src/qEmbyApp/views/user/categoryview.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef CATEGORYVIEW_H -#define CATEGORYVIEW_H - -#include "../baseview.h" - -#include - -class CategoryViewModel; -class QuickPageHost; - -class CategoryView final : public BaseView { - Q_OBJECT - -public: - explicit CategoryView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task loadCategory(const QString &categoryType, - const QString &title); - void prepareForStackLeave() override; - -public slots: - void scrollToTop() override; - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - -private: - QuickPageHost *m_quickHost = nullptr; - CategoryViewModel *m_viewModel = nullptr; -}; - -#endif // CATEGORYVIEW_H diff --git a/src/qEmbyApp/views/user/dashboardview.cpp b/src/qEmbyApp/views/user/dashboardview.cpp deleted file mode 100644 index ec1f56f..0000000 --- a/src/qEmbyApp/views/user/dashboardview.cpp +++ /dev/null @@ -1,249 +0,0 @@ -#include "dashboardview.h" -#include "dashboardviewmodel.h" - -#include "../../components/libraryactionmenu.h" -#include "../../components/mediaimageeditdialog.h" -#include "../../components/moderntoast.h" -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -#include - -DashboardView::DashboardView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("dashboard-view")); - setProperty("showGlobalSearch", true); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new UserDashboardViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("dashboardPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/DashboardPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, this, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, this, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &UserDashboardViewModel::itemActivated, this, - [this](const MediaItem &item) { - emit navigateToDetail(item.id, item.name, item); - }); - connect(m_viewModel, &UserDashboardViewModel::libraryActivated, this, - &DashboardView::navigateToLibrary); - connect(m_viewModel, &UserDashboardViewModel::categoryRequested, this, - &DashboardView::navigateToCategory); - connect(m_viewModel, &UserDashboardViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &UserDashboardViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &UserDashboardViewModel::moreRequested, this, - [this](const MediaItem &item, const QPoint &localPos) { - handleMoreMenuRequested(item, m_quickHost->mapToGlobal(localPos)); - }); -} - -void DashboardView::launchDashboardTask(QCoro::Task &&task) { - QCoro::connect(std::move(task), this, []() {}); -} - -QCoro::Task DashboardView::loadDashboardData() { - co_await m_viewModel->loadDashboardData(); -} - -void DashboardView::scrollToTop() { m_viewModel->scrollToTop(); } - -void DashboardView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void DashboardView::onMediaItemRemoved(const QString &itemId) { - m_viewModel->removeItem(itemId); -} - -bool DashboardView::isManageableDashboardLibraryCard( - const MediaItem &item) const { - if (!m_viewModel || item.id.trimmed().isEmpty() || - !m_viewModel->isLibraryItem(item)) { - return false; - } - const QString collectionType = item.collectionType.trimmed().toLower(); - const QString type = item.type.trimmed().toLower(); - return collectionType != QLatin1String("playlists") && - collectionType != QLatin1String("boxsets") && - type != QLatin1String("playlist") && type != QLatin1String("boxset"); -} - -CardContextMenuRequest -DashboardView::showCardContextMenu(const MediaItem &item, - const QPoint &globalPos) { - const bool canShowLibraryMenu = - isManageableDashboardLibraryCard(item) && m_core && - m_core->serverManager() && - m_core->serverManager()->activeProfile().isValid() && - m_core->serverManager()->activeProfile().isAdmin; - if (!canShowLibraryMenu) { - return BaseView::showCardContextMenu(item, globalPos); - } - - VirtualFolder folder; - folder.itemId = item.id; - folder.name = item.name; - LibraryActionMenu menu(folder, false, this); - const LibraryContextMenuRequest libraryRequest = menu.execRequest(globalPos); - - CardContextMenuRequest request; - switch (libraryRequest.action) { - case LibraryContextMenuAction::EditImages: - request.action = CardContextMenuAction::EditImages; - break; - case LibraryContextMenuAction::RefreshMetadata: - request.action = CardContextMenuAction::RefreshMetadata; - break; - case LibraryContextMenuAction::ScanLibraryFiles: - request.action = CardContextMenuAction::ScanLibraryFiles; - break; - case LibraryContextMenuAction::None: - break; - } - return request; -} - -void DashboardView::dispatchCardContextMenuRequest( - const MediaItem &item, const CardContextMenuRequest &request) { - if (!request.isValid()) { - return; - } - if (isManageableDashboardLibraryCard(item)) { - switch (request.action) { - case CardContextMenuAction::EditImages: - openDashboardLibraryImageEditor(item); - return; - case CardContextMenuAction::RefreshMetadata: - launchDashboardTask( - executeDashboardLibraryRefresh(item, true, true, true)); - return; - case CardContextMenuAction::ScanLibraryFiles: - launchDashboardTask( - executeDashboardLibraryRefresh(item, false, false, false)); - return; - default: - break; - } - } - BaseView::dispatchCardContextMenuRequest(item, request); -} - -void DashboardView::openDashboardLibraryImageEditor(const MediaItem &item) { - if (!m_core || !m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) { - ModernToast::showMessage( - tr("This action requires administrator privileges")); - return; - } - - MediaImageEditTarget target; - target.itemId = item.id; - target.imageItemId = item.images.primaryImageItemId.isEmpty() - ? item.id - : item.images.primaryImageItemId; - target.displayName = item.name; - target.itemType = item.type; - target.mediaType = item.mediaType; - target.collectionType = item.collectionType; - target.isLibrary = true; - - MediaImageEditDialog dialog(m_core, target, this); - dialog.exec(); - if (!dialog.hasChanges()) { - return; - } - if (m_core->mediaService()) { - m_core->mediaService()->clearUserViewsCache(); - } - m_viewModel->refresh(); -} - -QCoro::Task DashboardView::executeDashboardLibraryRefresh( - MediaItem item, bool replaceAllMetadata, bool replaceAllImages, - bool isMetadataRefresh) { - const QString itemId = item.id.trimmed(); - const QString itemName = - item.name.trimmed().isEmpty() ? tr("this item") : item.name.trimmed(); - if (itemId.isEmpty() || !m_core || !m_core->adminService() || - !m_core->mediaService()) { - co_return; - } - if (!m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) { - ModernToast::showMessage( - tr("This action requires administrator privileges")); - co_return; - } - - QPointer guard(this); - try { - co_await m_core->adminService()->refreshItemMetadata( - itemId, replaceAllMetadata, replaceAllImages); - if (!guard) { - co_return; - } - ModernToast::showMessage( - isMetadataRefresh - ? tr("Refreshing metadata for \"%1\"...").arg(itemName) - : tr("Scanning \"%1\"...").arg(itemName), - 2000); - } catch (const std::exception &error) { - if (guard) { - ModernToast::showMessage( - isMetadataRefresh - ? tr("Metadata refresh failed: %1") - .arg(QString::fromUtf8(error.what())) - : tr("Scan failed: %1").arg(QString::fromUtf8(error.what())), - 3000); - } - co_return; - } - - try { - const MediaItem latest = - co_await m_core->mediaService()->getItemDetail(itemId); - if (guard) { - m_viewModel->updateItem(latest); - } - } catch (const std::exception &error) { - qWarning() << "[DashboardView] failed to refresh library card" << itemId - << error.what(); - } - - if (guard) { - QTimer::singleShot(2000, this, [this]() { - if (m_core && m_core->mediaService()) { - m_core->mediaService()->clearUserViewsCache(); - m_viewModel->refresh(); - } - }); - } -} diff --git a/src/qEmbyApp/views/user/dashboardview.h b/src/qEmbyApp/views/user/dashboardview.h deleted file mode 100644 index d74a212..0000000 --- a/src/qEmbyApp/views/user/dashboardview.h +++ /dev/null @@ -1,46 +0,0 @@ -#ifndef DASHBOARDVIEW_H -#define DASHBOARDVIEW_H - -#include "../baseview.h" - -#include - -class QuickPageHost; -class UserDashboardViewModel; - -class DashboardView : public BaseView { - Q_OBJECT - -public: - explicit DashboardView(QEmbyCore *core, QWidget *parent = nullptr); - - QCoro::Task loadDashboardData(); - -public slots: - void scrollToTop() override; - -signals: - void navigateToLibrary(const QString &libraryId, const QString &libraryName); - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - CardContextMenuRequest showCardContextMenu(const MediaItem &item, - const QPoint &globalPos) override; - void dispatchCardContextMenuRequest( - const MediaItem &item, const CardContextMenuRequest &request) override; - -private: - void launchDashboardTask(QCoro::Task &&task); - bool isManageableDashboardLibraryCard(const MediaItem &item) const; - void openDashboardLibraryImageEditor(const MediaItem &item); - QCoro::Task executeDashboardLibraryRefresh(MediaItem item, - bool replaceAllMetadata, - bool replaceAllImages, - bool isMetadataRefresh); - - QuickPageHost *m_quickHost = nullptr; - UserDashboardViewModel *m_viewModel = nullptr; -}; - -#endif // DASHBOARDVIEW_H diff --git a/src/qEmbyApp/views/user/favoritesview.cpp b/src/qEmbyApp/views/user/favoritesview.cpp deleted file mode 100644 index b89fabb..0000000 --- a/src/qEmbyApp/views/user/favoritesview.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include "favoritesview.h" - -#include "../../quick/embyimageprovider.h" -#include "../../quick/quickpagehost.h" -#include "favoritesviewmodel.h" - -#include -#include -#include - -FavoritesView::FavoritesView(QEmbyCore *core, QWidget *parent) - : BaseView(core, parent) { - setObjectName(QStringLiteral("favorites-view")); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_quickHost = new QuickPageHost(this); - m_viewModel = new FavoritesViewModel(core, m_quickHost); - m_quickHost->setContextProperty(QStringLiteral("favoritesPage"), m_viewModel); - m_quickHost->addImageProvider(QStringLiteral("emby"), - new EmbyImageProvider(core)); - m_quickHost->addImageProvider(QStringLiteral("adaptiveicon"), - new AdaptiveIconProvider()); - m_quickHost->load( - QUrl(QStringLiteral("qrc:/qt/qml/qEmby/qml/FavoritesPage.qml"))); - layout->addWidget(m_quickHost); - - connect(m_quickHost, &QuickPageHost::pageShown, m_viewModel, - [this]() { m_viewModel->setActive(true); }); - connect(m_quickHost, &QuickPageHost::pageHidden, m_viewModel, - [this]() { m_viewModel->setActive(false); }); - - connect(m_viewModel, &FavoritesViewModel::itemActivated, this, - [this](const MediaItem &item) { - if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) { - Q_EMIT navigateToFolder(item.id, item.name); - } else if (item.type == QLatin1String("Person")) { - Q_EMIT navigateToPerson(item.id, item.name); - } else { - Q_EMIT navigateToDetail(item.id, item.name, item); - } - }); - connect(m_viewModel, &FavoritesViewModel::categoryRequested, this, - &BaseView::navigateToCategory); - connect(m_viewModel, &FavoritesViewModel::playRequested, this, - &BaseView::handlePlayRequested); - connect(m_viewModel, &FavoritesViewModel::favoriteRequested, this, - &BaseView::handleFavoriteRequested); - connect(m_viewModel, &FavoritesViewModel::moreRequested, this, - [this](const MediaItem &item) { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -void FavoritesView::scrollToTop() { m_viewModel->scrollToTop(); } - -QCoro::Task FavoritesView::loadFavoritesData() { - co_await m_viewModel->refreshAsync(); -} - -void FavoritesView::onMediaItemUpdated(const MediaItem &item) { - m_viewModel->updateItem(item); -} - -void FavoritesView::onMediaItemRemoved(const QString &itemId) { - m_viewModel->removeItem(itemId); -} diff --git a/src/qEmbyApp/views/user/favoritesview.h b/src/qEmbyApp/views/user/favoritesview.h deleted file mode 100644 index f936ea1..0000000 --- a/src/qEmbyApp/views/user/favoritesview.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef FAVORITESVIEW_H -#define FAVORITESVIEW_H - -#include "../baseview.h" - -#include - -class FavoritesViewModel; -class QuickPageHost; - -class FavoritesView final : public BaseView { - Q_OBJECT - -public: - explicit FavoritesView(QEmbyCore *core, QWidget *parent = nullptr); - QCoro::Task loadFavoritesData(); - -public Q_SLOTS: - void scrollToTop() override; - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - -private: - QuickPageHost *m_quickHost = nullptr; - FavoritesViewModel *m_viewModel = nullptr; -}; - -#endif // FAVORITESVIEW_H diff --git a/src/qEmbyApp/views/user/homequickrouterview.cpp b/src/qEmbyApp/views/user/homequickrouterview.cpp deleted file mode 100644 index 0cf0726..0000000 --- a/src/qEmbyApp/views/user/homequickrouterview.cpp +++ /dev/null @@ -1,550 +0,0 @@ -#include "homequickrouterview.h" - -#include "dashboardviewmodel.h" -#include "favoritesviewmodel.h" -#include "categoryviewmodel.h" - -#include "../../components/libraryactionmenu.h" -#include "../../components/mediaimageeditdialog.h" -#include "../../components/moderntoast.h" -#include "../../quick/navigationcontroller.h" -#include "../../quick/quickrouterhost.h" -#include "../search/searchviewmodel.h" -#include "../media/libraryviewmodel.h" - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include - -HomeQuickRouterView::HomeQuickRouterView(QEmbyCore *core, - NavigationController *navigation, - QWidget *parent) - : BaseView(core, parent), m_navigation(navigation) -{ - setObjectName(QStringLiteral("home-quick-router-view")); - setAttribute(Qt::WA_StyledBackground, true); - - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - m_host = new QuickRouterHost(core, navigation, this); - m_dashboardViewModel = new UserDashboardViewModel(core, this); - m_favoritesViewModel = new FavoritesViewModel(core, this); - m_searchViewModel = new SearchViewModel(core, this); - m_categoryViewModel = new CategoryViewModel(core, this); - m_libraryViewModel = new LibraryViewModel(core, this); - - m_host->setContextProperty(QStringLiteral("dashboardPage"), - m_dashboardViewModel); - m_host->setContextProperty(QStringLiteral("favoritesPage"), - m_favoritesViewModel); - m_host->setContextProperty(QStringLiteral("searchPage"), - m_searchViewModel); - m_host->setContextProperty(QStringLiteral("categoryPage"), - m_categoryViewModel); - m_host->setContextProperty(QStringLiteral("libraryPage"), - m_libraryViewModel); - m_host->loadRouter(); - layout->addWidget(m_host); - - connect(m_host, &QuickRouterHost::activated, this, - &HomeQuickRouterView::updateRouteActivity); - connect(m_host, &QuickRouterHost::deactivated, this, - &HomeQuickRouterView::deactivateRoutes); - if (m_navigation) - { - connect(m_navigation, &NavigationController::currentRouteChanged, - this, &HomeQuickRouterView::updateRouteActivity); - connect(m_navigation, &NavigationController::refreshRequested, this, - [this](const QString &key) - { - if (key == QLatin1String("home")) - refreshHome(); - else if (key == QLatin1String("favorites")) - refreshFavorites(); - }); - } - - connect(m_dashboardViewModel, &UserDashboardViewModel::itemActivated, - this, &HomeQuickRouterView::forwardItemActivation); - connect(m_dashboardViewModel, &UserDashboardViewModel::libraryActivated, - this, &HomeQuickRouterView::navigateToLibrary); - connect(m_dashboardViewModel, &UserDashboardViewModel::categoryRequested, - this, &BaseView::navigateToCategory); - connect(m_dashboardViewModel, &UserDashboardViewModel::playRequested, - this, &BaseView::handlePlayRequested); - connect(m_dashboardViewModel, &UserDashboardViewModel::favoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_dashboardViewModel, &UserDashboardViewModel::moreRequested, - this, [this](const MediaItem &item, const QPoint &localPos) - { - handleMoreMenuRequested(item, m_host->mapToGlobal(localPos)); - }); - - connect(m_favoritesViewModel, &FavoritesViewModel::itemActivated, - this, &HomeQuickRouterView::forwardItemActivation); - connect(m_favoritesViewModel, &FavoritesViewModel::categoryRequested, - this, [this](const QString &itemType, const QString &title) - { - emit navigateToCategory(QStringLiteral("Favorite_") + itemType, - title); - }); - connect(m_favoritesViewModel, &FavoritesViewModel::playRequested, - this, &BaseView::handlePlayRequested); - connect(m_favoritesViewModel, &FavoritesViewModel::favoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_favoritesViewModel, &FavoritesViewModel::moreRequested, - this, [this](const MediaItem &item) - { - handleMoreMenuRequested(item, QCursor::pos()); - }); - - connect(m_searchViewModel, &SearchViewModel::itemActivated, - this, &HomeQuickRouterView::forwardItemActivation); - connect(m_searchViewModel, &SearchViewModel::playRequested, - this, &BaseView::handlePlayRequested); - connect(m_searchViewModel, &SearchViewModel::favoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_searchViewModel, &SearchViewModel::moreRequested, - this, [this](const MediaItem &item, const QPoint &localPos) - { - handleMoreMenuRequested(item, m_host->mapToGlobal(localPos)); - }); - - connect(m_categoryViewModel, &CategoryViewModel::itemActivated, - this, &HomeQuickRouterView::forwardItemActivation); - connect(m_categoryViewModel, &CategoryViewModel::playRequested, - this, &BaseView::handlePlayRequested); - connect(m_categoryViewModel, &CategoryViewModel::favoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_categoryViewModel, &CategoryViewModel::moreRequested, - this, [this](const MediaItem &item) - { - handleMoreMenuRequested(item, QCursor::pos()); - }); - - connect(m_libraryViewModel, &LibraryViewModel::itemActivated, - this, &HomeQuickRouterView::forwardItemActivation); - connect(m_libraryViewModel, &LibraryViewModel::playRequested, - this, &BaseView::handlePlayRequested); - connect(m_libraryViewModel, &LibraryViewModel::favoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_libraryViewModel, &LibraryViewModel::currentFavoriteRequested, - this, &BaseView::handleFavoriteRequested); - connect(m_libraryViewModel, &LibraryViewModel::moreRequested, - this, [this](const MediaItem &item) - { - handleMoreMenuRequested(item, QCursor::pos()); - }); -} - -void HomeQuickRouterView::refreshHome() -{ - m_dashboardViewModel->refresh(); -} - -void HomeQuickRouterView::refreshFavorites() -{ - m_favoritesViewModel->refresh(); -} - -void HomeQuickRouterView::scrollToTop() -{ - if (!m_navigation) - return; - - const QString routeName = - m_navigation->currentRoute().value(QStringLiteral("name")).toString(); - if (routeName == QLatin1String("home")) - m_dashboardViewModel->scrollToTop(); - else if (routeName == QLatin1String("favorites")) - m_favoritesViewModel->scrollToTop(); - else if (m_host && m_host->quickWidget()->rootObject()) - QMetaObject::invokeMethod(m_host->quickWidget()->rootObject(), - "scrollToTop"); -} - -void HomeQuickRouterView::onMediaItemUpdated(const MediaItem &item) -{ - m_dashboardViewModel->updateItem(item); - m_favoritesViewModel->updateItem(item); - m_searchViewModel->updateItem(item); - m_categoryViewModel->updateItem(item); - m_libraryViewModel->updateItem(item); -} - -void HomeQuickRouterView::onMediaItemRemoved(const QString &itemId) -{ - m_dashboardViewModel->removeItem(itemId); - m_favoritesViewModel->removeItem(itemId); - m_categoryViewModel->removeItem(itemId); - m_libraryViewModel->removeItem(itemId); -} - -CardContextMenuRequest HomeQuickRouterView::showCardContextMenu( - const MediaItem &item, const QPoint &globalPos) -{ - const bool canShowLibraryMenu = - isManageableDashboardLibraryCard(item) && m_core && - m_core->serverManager() && - m_core->serverManager()->activeProfile().isValid() && - m_core->serverManager()->activeProfile().isAdmin; - if (!canShowLibraryMenu) - return BaseView::showCardContextMenu(item, globalPos); - - VirtualFolder folder; - folder.itemId = item.id; - folder.name = item.name; - LibraryActionMenu menu(folder, false, this); - const LibraryContextMenuRequest libraryRequest = menu.execRequest(globalPos); - - CardContextMenuRequest request; - switch (libraryRequest.action) - { - case LibraryContextMenuAction::EditImages: - request.action = CardContextMenuAction::EditImages; - break; - case LibraryContextMenuAction::RefreshMetadata: - request.action = CardContextMenuAction::RefreshMetadata; - break; - case LibraryContextMenuAction::ScanLibraryFiles: - request.action = CardContextMenuAction::ScanLibraryFiles; - break; - case LibraryContextMenuAction::None: - break; - } - return request; -} - -void HomeQuickRouterView::dispatchCardContextMenuRequest( - const MediaItem &item, const CardContextMenuRequest &request) -{ - if (!request.isValid()) - return; - - if (isManageableDashboardLibraryCard(item)) - { - switch (request.action) - { - case CardContextMenuAction::EditImages: - openDashboardLibraryImageEditor(item); - return; - case CardContextMenuAction::RefreshMetadata: - launchDashboardTask( - executeDashboardLibraryRefresh(item, true, true, true)); - return; - case CardContextMenuAction::ScanLibraryFiles: - launchDashboardTask( - executeDashboardLibraryRefresh(item, false, false, false)); - return; - default: - break; - } - } - - BaseView::dispatchCardContextMenuRequest(item, request); -} - -void HomeQuickRouterView::updateRouteActivity() -{ - const QVariantMap route = m_navigation - ? m_navigation->currentRoute() - : QVariantMap{}; - const QString routeName = route.value(QStringLiteral("name")).toString(); - const bool hostActive = m_host && m_host->isActive(); - - m_dashboardViewModel->setActive(hostActive && - routeName == QLatin1String("home")); - m_favoritesViewModel->setActive(hostActive && - routeName == QLatin1String("favorites")); - - updateSearchRoute(route, hostActive); - updateCategoryRoute(route, hostActive); - updateLibraryRoute(route, hostActive); -} - -void HomeQuickRouterView::updateSearchRoute(const QVariantMap &route, - bool hostActive) -{ - const QString routeName = route.value(QStringLiteral("name")).toString(); - const bool searchActive = hostActive && routeName == QLatin1String("search"); - if (!searchActive) - { - m_searchViewModel->setActive(false); - return; - } - - const QVariantMap params = route.value(QStringLiteral("params")).toMap(); - const QString query = params.value(QStringLiteral("query")) - .toString().trimmed(); - const QString routeKey = route.value(QStringLiteral("key")).toString(); - const bool routeChanged = routeKey != m_searchRouteKey || - query != m_searchQuery; - if (!query.isEmpty() && routeChanged) - { - m_searchRouteKey = routeKey; - m_searchQuery = query; - - // SearchViewModel deliberately remembers a query while inactive. - // Queue the new query before activation so an interrupted previous - // query cannot be restarted immediately before this one. - QCoro::connect(m_searchViewModel->search(query), this, []() {}); - } - - m_searchViewModel->setActive(true); -} - -void HomeQuickRouterView::updateCategoryRoute(const QVariantMap &route, - bool hostActive) -{ - const QString routeName = route.value(QStringLiteral("name")).toString(); - const bool categoryActive = hostActive && - routeName == QLatin1String("category"); - if (!categoryActive) - { - m_categoryViewModel->setActive(false); - return; - } - - const QVariantMap params = route.value(QStringLiteral("params")).toMap(); - QString categoryId = params.value(QStringLiteral("categoryId")) - .toString().trimmed(); - if (params.value(QStringLiteral("favorite")).toBool()) - categoryId.prepend(QStringLiteral("Favorite_")); - const QString title = params.value(QStringLiteral("title")).toString(); - const QString routeKey = route.value(QStringLiteral("key")).toString(); - const QString routeIdentity = routeKey + QLatin1Char('\n') + title; - if (!categoryId.isEmpty() && routeIdentity != m_categoryRouteKey) - { - m_categoryViewModel->setActive(false); - m_categoryRouteKey = routeIdentity; - QCoro::connect(m_categoryViewModel->loadCategory(categoryId, title), - this, []() {}); - } - m_categoryViewModel->setActive(true); -} - -void HomeQuickRouterView::updateLibraryRoute(const QVariantMap &route, - bool hostActive) -{ - const QString routeName = route.value(QStringLiteral("name")).toString(); - const bool libraryActive = hostActive && - (routeName == QLatin1String("library") || - routeName == QLatin1String("person") || - routeName == QLatin1String("filtered")); - if (!libraryActive) - { - m_libraryViewModel->setActive(false); - return; - } - - const QVariantMap params = route.value(QStringLiteral("params")).toMap(); - const QString routeKey = route.value(QStringLiteral("key")).toString(); - const QString title = params.value(QStringLiteral("title")).toString(); - const QString routeIdentity = routeKey + QLatin1Char('\n') + title; - if (routeIdentity != m_libraryRouteKey) - { - m_libraryViewModel->setActive(false); - m_libraryRouteKey = routeIdentity; - if (routeName == QLatin1String("library")) - { - QCoro::connect(m_libraryViewModel->loadLibrary( - params.value(QStringLiteral("libraryId")).toString(), - title), - this, []() {}); - } - else if (routeName == QLatin1String("person")) - { - QCoro::connect(m_libraryViewModel->loadPerson( - params.value(QStringLiteral("personId")).toString(), - title), - this, []() {}); - } - else - { - QCoro::connect(m_libraryViewModel->loadFiltered( - params.value(QStringLiteral("filterType")).toString(), - params.value(QStringLiteral("filterValue")).toString()), - this, []() {}); - } - } - m_libraryViewModel->setActive(true); -} - -void HomeQuickRouterView::deactivateRoutes() -{ - m_dashboardViewModel->setActive(false); - m_favoritesViewModel->setActive(false); - m_searchViewModel->setActive(false); - m_categoryViewModel->setActive(false); - m_libraryViewModel->setActive(false); -} - -void HomeQuickRouterView::forwardItemActivation(const MediaItem &item) -{ - if (item.type == QLatin1String("BoxSet") || - item.type == QLatin1String("Playlist") || - item.type == QLatin1String("Folder")) - { - emit navigateToFolder(item.id, item.name); - } - else if (item.type == QLatin1String("Person")) - { - emit navigateToPerson(item.id, item.name); - } - else - { - emit navigateToDetail(item.id, item.name, item); - } -} - -void HomeQuickRouterView::launchDashboardTask(QCoro::Task &&task) -{ - QCoro::connect(std::move(task), this, []() {}); -} - -bool HomeQuickRouterView::isManageableDashboardLibraryCard( - const MediaItem &item) const -{ - const QString routeName = m_navigation - ? m_navigation->currentRoute().value(QStringLiteral("name")).toString() - : QString(); - if (routeName != QLatin1String("home") || !m_dashboardViewModel || - item.id.trimmed().isEmpty() || - !m_dashboardViewModel->isLibraryItem(item)) - { - return false; - } - - const QString collectionType = item.collectionType.trimmed().toLower(); - const QString type = item.type.trimmed().toLower(); - return collectionType != QLatin1String("playlists") && - collectionType != QLatin1String("boxsets") && - type != QLatin1String("playlist") && - type != QLatin1String("boxset"); -} - -void HomeQuickRouterView::openDashboardLibraryImageEditor( - const MediaItem &item) -{ - if (!m_core || !m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) - { - ModernToast::showMessage( - tr("This action requires administrator privileges")); - return; - } - - MediaImageEditTarget target; - target.itemId = item.id; - target.imageItemId = item.images.primaryImageItemId.isEmpty() - ? item.id - : item.images.primaryImageItemId; - target.displayName = item.name; - target.itemType = item.type; - target.mediaType = item.mediaType; - target.collectionType = item.collectionType; - target.isLibrary = true; - - MediaImageEditDialog dialog(m_core, target, this); - dialog.exec(); - if (!dialog.hasChanges()) - return; - - if (m_core->mediaService()) - m_core->mediaService()->clearUserViewsCache(); - m_dashboardViewModel->refresh(); -} - -QCoro::Task HomeQuickRouterView::executeDashboardLibraryRefresh( - MediaItem item, bool replaceAllMetadata, bool replaceAllImages, - bool isMetadataRefresh) -{ - const QString itemId = item.id.trimmed(); - const QString itemName = item.name.trimmed().isEmpty() - ? tr("this item") - : item.name.trimmed(); - if (itemId.isEmpty() || !m_core || !m_core->adminService() || - !m_core->mediaService()) - { - co_return; - } - if (!m_core->serverManager() || - !m_core->serverManager()->activeProfile().isValid() || - !m_core->serverManager()->activeProfile().isAdmin) - { - ModernToast::showMessage( - tr("This action requires administrator privileges")); - co_return; - } - - QPointer guard(this); - try - { - co_await m_core->adminService()->refreshItemMetadata( - itemId, replaceAllMetadata, replaceAllImages); - if (!guard) - co_return; - - ModernToast::showMessage( - isMetadataRefresh - ? tr("Refreshing metadata for \"%1\"...").arg(itemName) - : tr("Scanning \"%1\"...").arg(itemName), - 2000); - } - catch (const std::exception &error) - { - if (guard) - { - ModernToast::showMessage( - isMetadataRefresh - ? tr("Metadata refresh failed: %1") - .arg(QString::fromUtf8(error.what())) - : tr("Scan failed: %1") - .arg(QString::fromUtf8(error.what())), - 3000); - } - co_return; - } - - try - { - const MediaItem latest = - co_await m_core->mediaService()->getItemDetail(itemId); - if (guard) - m_dashboardViewModel->updateItem(latest); - } - catch (const std::exception &error) - { - if (guard) - { - qWarning() << "[HomeQuickRouterView] failed to refresh library card" - << itemId << error.what(); - } - } - - if (guard) - { - QTimer::singleShot(2000, this, [this]() - { - if (m_core && m_core->mediaService()) - { - m_core->mediaService()->clearUserViewsCache(); - m_dashboardViewModel->refresh(); - } - }); - } -} diff --git a/src/qEmbyApp/views/user/homequickrouterview.h b/src/qEmbyApp/views/user/homequickrouterview.h deleted file mode 100644 index 63bacfa..0000000 --- a/src/qEmbyApp/views/user/homequickrouterview.h +++ /dev/null @@ -1,68 +0,0 @@ -#pragma once - -#include "../baseview.h" - -class FavoritesViewModel; -class CategoryViewModel; -class LibraryViewModel; -class NavigationController; -class QuickRouterHost; -class SearchViewModel; -class UserDashboardViewModel; - -// Bridges the first application-level QML routes to the existing BaseView -// action and navigation contracts. Home and Favorites remain session-scoped -// ViewModels while Router.qml owns their visual lifetime. -class HomeQuickRouterView final : public BaseView -{ - Q_OBJECT - -public: - explicit HomeQuickRouterView(QEmbyCore *core, - NavigationController *navigation, - QWidget *parent = nullptr); - -public Q_SLOTS: - void refreshHome(); - void refreshFavorites(); - void scrollToTop() override; - -Q_SIGNALS: - void navigateToLibrary(const QString &libraryId, - const QString &libraryName); - -protected: - void onMediaItemUpdated(const MediaItem &item) override; - void onMediaItemRemoved(const QString &itemId) override; - CardContextMenuRequest showCardContextMenu( - const MediaItem &item, const QPoint &globalPos) override; - void dispatchCardContextMenuRequest( - const MediaItem &item, - const CardContextMenuRequest &request) override; - -private: - void updateRouteActivity(); - void updateSearchRoute(const QVariantMap &route, bool hostActive); - void updateCategoryRoute(const QVariantMap &route, bool hostActive); - void updateLibraryRoute(const QVariantMap &route, bool hostActive); - void deactivateRoutes(); - void forwardItemActivation(const MediaItem &item); - void launchDashboardTask(QCoro::Task &&task); - bool isManageableDashboardLibraryCard(const MediaItem &item) const; - void openDashboardLibraryImageEditor(const MediaItem &item); - QCoro::Task executeDashboardLibraryRefresh( - MediaItem item, bool replaceAllMetadata, bool replaceAllImages, - bool isMetadataRefresh); - - NavigationController *m_navigation = nullptr; - QuickRouterHost *m_host = nullptr; - UserDashboardViewModel *m_dashboardViewModel = nullptr; - FavoritesViewModel *m_favoritesViewModel = nullptr; - SearchViewModel *m_searchViewModel = nullptr; - CategoryViewModel *m_categoryViewModel = nullptr; - LibraryViewModel *m_libraryViewModel = nullptr; - QString m_searchRouteKey; - QString m_searchQuery; - QString m_categoryRouteKey; - QString m_libraryRouteKey; -}; diff --git a/src/qEmbyApp/views/user/homeview.cpp b/src/qEmbyApp/views/user/homeview.cpp deleted file mode 100644 index 3ad46db..0000000 --- a/src/qEmbyApp/views/user/homeview.cpp +++ /dev/null @@ -1,2438 +0,0 @@ -#include "homeview.h" -#include "../../components/searchcompleterpopup.h" -#include "../../components/downloadmanagerdialog.h" -#include "../../components/elidedlabel.h" -#include "../../components/moderntoast.h" -#include "../../components/shadowoverlay.h" -#include "../../components/slidingstackedwidget.h" -#include "../../components/webdavsyncdialog.h" -#include "../../managers/thememanager.h" -#include "../../managers/searchhistorymanager.h" -#include "../../managers/playbackmanager.h" -#include "../../quick/navigationcontroller.h" -#include "../../utils/smoothscrollcontroller.h" -#include "../../tokens/AnimationTokens.h" -#include "../admin/manageview.h" -#include "../media/detailview.h" -#include "../media/libraryview.h" -#include "../media/playerview.h" -#include "../media/seasonview.h" -#include "../search/searchview.h" -#include "../settings/settingsview.h" -#include "categoryview.h" -#include "config/config_keys.h" -#include "config/configstore.h" -#include "config/webdavprofilestore.h" -#include "dashboardview.h" -#include "favoritesview.h" -#include "homequickrouterview.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -constexpr int kFloatingSidebarWidth = 240; -constexpr int kPinnedSidebarWidth = 136; -constexpr int kSidebarHiddenOffset = 30; -constexpr int kLeftEdgeTriggerWidth = 15; -constexpr int kRightEdgeTriggerWidth = 20; -constexpr int kSidebarLibraryNameRole = Qt::UserRole + 1; - -QVariantMap routeRequestForWidget(const QWidget *widget) -{ - if (!widget) - return {}; - - const QString legacyType = widget->property("routeType").toString(); - const QString id = widget->property("routeId").toString(); - const QString title = widget->property("routeTitle").toString(); - const QString extraId = widget->property("routeExtraId").toString(); - QVariantMap params; - QString name; - - if (legacyType == QLatin1String("DashboardView")) - name = QStringLiteral("home"); - else if (legacyType == QLatin1String("FavoritesView")) - name = QStringLiteral("favorites"); - else if (legacyType == QLatin1String("DetailView")) - { - name = QStringLiteral("detail"); - params.insert(QStringLiteral("itemId"), id); - } - else if (legacyType == QLatin1String("CategoryView")) - { - name = QStringLiteral("category"); - const bool favorite = id.startsWith(QLatin1String("Favorite_")); - params.insert(QStringLiteral("categoryId"), favorite ? id.mid(9) : id); - if (favorite) - params.insert(QStringLiteral("favorite"), true); - } - else if (legacyType == QLatin1String("LibraryView")) - { - name = QStringLiteral("library"); - params.insert(QStringLiteral("libraryId"), id); - } - else if (legacyType == QLatin1String("PersonView")) - { - name = QStringLiteral("person"); - params.insert(QStringLiteral("personId"), id); - } - else if (legacyType == QLatin1String("SearchView")) - { - name = QStringLiteral("search"); - params.insert(QStringLiteral("query"), id); - } - else if (legacyType == QLatin1String("FilteredView")) - { - name = QStringLiteral("filtered"); - params.insert(QStringLiteral("filterType"), extraId); - params.insert(QStringLiteral("filterValue"), title); - } - else if (legacyType == QLatin1String("SeasonView")) - { - name = QStringLiteral("season"); - params.insert(QStringLiteral("seriesId"), extraId); - params.insert(QStringLiteral("seasonId"), id); - } - else if (legacyType == QLatin1String("PlayerView")) - { - name = QStringLiteral("player"); - params.insert(QStringLiteral("mediaId"), id); - params.insert(QStringLiteral("startPositionTicks"), - widget->property("startPositionTicks")); - } - else if (legacyType == QLatin1String("SettingsView")) - name = QStringLiteral("settings"); - else if (legacyType == QLatin1String("ManageView")) - name = QStringLiteral("manage"); - - if (name.isEmpty()) - return {}; - if (!title.isEmpty()) - params.insert(QStringLiteral("title"), title); - return {{QStringLiteral("name"), name}, - {QStringLiteral("title"), title}, - {QStringLiteral("params"), params}}; -} -} - -HomeView::HomeView(QEmbyCore *core, QWidget *parent) : QWidget(parent), m_core(core) -{ - setAttribute(Qt::WA_StyledBackground, true); - setObjectName("home-view"); - - - QString pos = ConfigStore::instance()->get(ConfigKeys::SidebarPosition, "left"); - m_sidebarOnRight = (pos == "right"); - m_sidebarPinned = ConfigStore::instance()->get(ConfigKeys::SidebarPinned, false); - - m_navigationController = new NavigationController(this); - connect(m_navigationController, &NavigationController::navigationRejected, this, - [](const QString &code, const QString &message, const QVariantMap &request) - { - qWarning() << "[NavigationController] rejected" << code << message - << request; - }); - - setupUi(); - if (!m_sidebarPinned) - { - hideSidebar(); - } - - connect(ThemeManager::instance(), &ThemeManager::themeChanged, this, - [this](ThemeManager::Theme) { applySidebarIcons(); }); - - - PlaybackManager::instance()->init(m_core); - connect(PlaybackManager::instance(), &PlaybackManager::requestEmbeddedPlay, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { pushView(createPlayerView(id, title, url, ticks, extraData)); }); - - - - connect(PlaybackManager::instance(), &PlaybackManager::playbackFinished, this, - [this]() - { - QWidget *current = m_contentSwitcher->currentWidget(); - if (current) - { - QShowEvent showEvent; - QCoreApplication::sendEvent(current, &showEvent); - } - }); -} - -HomeView::~HomeView() -{ - m_isDestroying = true; - - if (m_contentSwitcher) - { - disconnect(m_contentSwitcher, nullptr, this, nullptr); - const auto playerViews = m_contentSwitcher->findChildren(); - for (PlayerView *playerView : playerViews) - { - disconnect(playerView, nullptr, this, nullptr); - } - } -} - - -PlayerView *HomeView::activePlayerView() const -{ - if (m_isDestroying || !m_contentSwitcher) - { - return nullptr; - } - - QWidget *current = m_contentSwitcher->currentWidget(); - if (current && current->property("routeType").toString() == "PlayerView") - { - return qobject_cast(current); - } - return nullptr; -} - -void HomeView::beginAuthenticatedSession() -{ - if (!m_navigationController) - return; - const bool administrator = m_core && m_core->serverManager() && - m_core->serverManager()->activeProfile().isAdmin; - m_navigationController->enterAuthenticatedSession(administrator); -} - -void HomeView::clearAuthenticatedSession() -{ - if (m_navigationController) - m_navigationController->leaveAuthenticatedSession(); -} - -void HomeView::setupUi() -{ - this->setProperty("showGlobalSearch", true); - this->setProperty("viewTitle", qApp->applicationName()); - this->setProperty("showGlobalBack", true); - this->setProperty("showGlobalHome", true); - this->setProperty("showGlobalFav", true); - - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - - - m_contentLayout = new QHBoxLayout(); - m_contentLayout->setContentsMargins(0, 0, 0, 0); - m_contentLayout->setSpacing(0); - - m_contentSwitcher = new SlidingStackedWidget(this); - m_contentSwitcher->setObjectName("home-content"); - - auto navigateToDetailSlot = [this](const QString &itemId, const QString &itemName, const MediaItem &seedItem) - { pushView(createDetailView(itemId, itemName, seedItem)); }; - auto navigateToFolderSlot = [this](const QString &id, const QString &name) - { openLibraryRoute(id, name); }; - auto navigateToPersonSlot = [this](const QString &id, const QString &name) - { openPersonRoute(id, name); }; - auto navigateToPlayerSlot = [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }; - auto navigateToSeasonSlot = [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }; - - const auto openDashboardLibrary = [this](const QString &id, const QString &name) - { - m_libraryList->clearSelection(); - for (int i = 0; i < m_libraryList->count(); ++i) - { - if (m_libraryList->item(i)->data(Qt::UserRole).toString() == id) - { - m_libraryList->item(i)->setSelected(true); - break; - } - } - openLibraryRoute(id, name); - }; - const auto openCategory = [this](const QString &categoryId, const QString &title) - { - m_libraryList->clearSelection(); - openCategoryRoute(categoryId, title); - }; - - m_useQuickHomeRouter = qEnvironmentVariableIntValue("QEMBY_USE_LEGACY_HOME_PAGES") != 1; - if (m_useQuickHomeRouter) - { - m_homeQuickRouterView = new HomeQuickRouterView(m_core, m_navigationController, this); - m_homeQuickRouterView->setProperty("routeType", "HomeQuickRouterView"); - connect(m_homeQuickRouterView, &HomeQuickRouterView::navigateToLibrary, - this, openDashboardLibrary); - connect(m_homeQuickRouterView, &BaseView::navigateToCategory, - this, openCategory); - connect(m_homeQuickRouterView, &BaseView::navigateToDetail, - this, navigateToDetailSlot); - connect(m_homeQuickRouterView, &BaseView::navigateToFolder, - this, navigateToFolderSlot); - connect(m_homeQuickRouterView, &BaseView::navigateToPerson, - this, navigateToPersonSlot); - connect(m_homeQuickRouterView, &BaseView::navigateToPlayer, - this, navigateToPlayerSlot); - connect(m_homeQuickRouterView, &BaseView::navigateToSeason, - this, navigateToSeasonSlot); - m_contentSwitcher->addWidget(m_homeQuickRouterView); - } - else - { - m_dashboardView = new DashboardView(m_core, this); - m_favoritesView = new FavoritesView(m_core, this); - m_dashboardView->setProperty("routeType", "DashboardView"); - m_favoritesView->setProperty("routeType", "FavoritesView"); - - connect(m_dashboardView, &DashboardView::navigateToLibrary, - this, openDashboardLibrary); - connect(m_dashboardView, &DashboardView::navigateToCategory, - this, openCategory); - connect(m_favoritesView, &FavoritesView::navigateToCategory, this, - [openCategory](const QString &categoryId, const QString &title) - { openCategory(QStringLiteral("Favorite_") + categoryId, title); }); - connect(m_dashboardView, &DashboardView::navigateToDetail, - this, navigateToDetailSlot); - connect(m_favoritesView, &FavoritesView::navigateToDetail, - this, navigateToDetailSlot); - connect(m_favoritesView, &FavoritesView::navigateToFolder, - this, navigateToFolderSlot); - connect(m_favoritesView, &FavoritesView::navigateToPerson, - this, navigateToPersonSlot); - connect(m_dashboardView, &BaseView::navigateToPlayer, - this, navigateToPlayerSlot); - connect(m_favoritesView, &BaseView::navigateToPlayer, - this, navigateToPlayerSlot); - connect(m_dashboardView, &BaseView::navigateToSeason, - this, navigateToSeasonSlot); - connect(m_favoritesView, &BaseView::navigateToSeason, - this, navigateToSeasonSlot); - m_contentSwitcher->addWidget(m_dashboardView); - m_contentSwitcher->addWidget(m_favoritesView); - } - m_lastRouteType = m_contentSwitcher->currentWidget() - ? m_contentSwitcher->currentWidget()->property("routeType").toString() - : QString(); - - connect(m_contentSwitcher, &QStackedWidget::currentChanged, this, - [this](int ) - { - QWidget *current = m_contentSwitcher->currentWidget(); - const QString currentRouteType = current ? current->property("routeType").toString() : QString(); - - if (m_lastRouteType == "ManageView" && currentRouteType != "ManageView") - { - qDebug() << "[HomeView] Left management view — refreshing sidebar library list"; - scheduleProfileRefresh(); - } - m_lastRouteType = currentRouteType; - - Q_EMIT homeContentSwitched(); - - if (current) - { - bool isImmersive = current->property("isImmersive").toBool(); - syncSidebarVisibility(); - - Q_EMIT immersiveStateChanged(isImmersive); - } - }); - - m_contentLayout->addWidget(m_contentSwitcher, 1); - mainLayout->addLayout(m_contentLayout); - - - m_navStack.clear(); - - setupSidebar(); - - m_edgeTrigger = new QWidget(this); - m_edgeTrigger->setFixedWidth(15); - m_edgeTrigger->setCursor(Qt::PointingHandCursor); - m_edgeTrigger->setAttribute(Qt::WA_TransparentForMouseEvents, false); - m_edgeTrigger->installEventFilter(this); - - - - - if (m_sidebarPinned) - { - - m_sidebar->hide(); - m_edgeTrigger->hide(); - } -} - - -QWidget *HomeView::createDetailView(const QString &itemId, const QString &itemName, const MediaItem &seedItem) -{ - auto *view = new DetailView(m_core, this); - - view->setProperty("isDynamic", true); - view->setProperty("routeType", "DetailView"); - view->setProperty("routeId", itemId); - view->setProperty("routeTitle", itemName); - - - - view->loadItem(itemId, seedItem); - - connect(view, &DetailView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - connect(view, &DetailView::navigateToFolder, this, - [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); - connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) - { - openPersonRoute(id, name); - }); - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - connect(view, &DetailView::triggerSearch, this, &HomeView::triggerSearch); - connect(view, &BaseView::navigateToFilteredView, this, - [this](const QString &type, const QString &value) - { openFilteredRoute(type, value); }); - - return view; -} - -QWidget *HomeView::createCategoryView(const QString &categoryId, const QString &title) -{ - auto *view = new CategoryView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "CategoryView"); - view->setProperty("routeId", categoryId); - view->setProperty("routeTitle", title); - - QTimer::singleShot(0, view, [view, categoryId, title]() - { QCoro::connect(view->loadCategory(categoryId, title), view, []() {}); }); - - connect(view, &CategoryView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - - connect(view, &CategoryView::navigateToFolder, this, - [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); - - connect(view, &CategoryView::navigateToPerson, this, - [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); - - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - return view; -} - -QWidget *HomeView::createLibraryView(const QString &libraryId, const QString &title) -{ - auto *view = new LibraryView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "LibraryView"); - view->setProperty("routeId", libraryId); - view->setProperty("routeTitle", title); - - view->loadLibrary(libraryId, title); - - connect(view, &LibraryView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - - connect(view, &LibraryView::navigateToFolder, this, - [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); - - connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); - - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - return view; -} - - -QWidget *HomeView::createPersonView(const QString &personId, const QString &personName) -{ - - auto *view = new LibraryView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "PersonView"); - view->setProperty("routeId", personId); - view->setProperty("routeTitle", personName); - - - - - connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); - - - connect(view, &BaseView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - - - connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); - - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - - connect(view, &BaseView::triggerSearch, this, [this](const QString &query) { triggerSearch(query); }); - - - - - view->loadPerson(personId, personName); - - return view; -} - -QWidget *HomeView::createSearchView(const QString &query) -{ - auto *view = new SearchView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "SearchView"); - view->setProperty("routeId", query); - - view->performSearch(query); - - connect(view, &SearchView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - - connect(view, &SearchView::navigateToFolder, this, - [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); - - connect(view, &SearchView::navigateToPerson, this, - [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); - - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - return view; -} - - - - -QWidget *HomeView::createFilteredView(const QString &filterType, const QString &filterValue) -{ - auto *view = new LibraryView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "FilteredView"); - view->setProperty("routeId", filterType + ":" + filterValue); - view->setProperty("routeTitle", filterValue); - view->setProperty("routeExtraId", filterType); - - connect(view, &BaseView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - connect(view, &BaseView::navigateToFolder, this, - [this](const QString &id, const QString &name) { openLibraryRoute(id, name); }); - connect(view, &BaseView::navigateToPerson, this, - [this](const QString &id, const QString &name) { openPersonRoute(id, name); }); - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - connect(view, &BaseView::navigateToSeason, this, - [this](const QString &seriesId, const QString &seasonId, const QString &seasonName) - { pushView(createSeasonView(seriesId, seasonId, seasonName)); }); - - view->loadFiltered(filterType, filterValue); - - return view; -} - - - - -QWidget *HomeView::createSeasonView(const QString &seriesId, const QString &seasonId, const QString &seasonName) -{ - auto *view = new SeasonView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "SeasonView"); - view->setProperty("routeId", seasonId); - - view->setProperty("routeExtraId", seriesId); - view->setProperty("routeTitle", seasonName); - - - view->loadSeason(seriesId, seasonId, seasonName); - - connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); - - - connect(view, &BaseView::navigateToDetail, this, - [this](const QString &id, const QString &name, const MediaItem &seed) { pushView(createDetailView(id, name, seed)); }); - - - connect(view, &BaseView::navigateToPlayer, this, - [this](const QString &id, const QString &title, const QString &url, long long ticks, - const QVariant &extraData) { launchPlayer(id, title, url, ticks, extraData); }); - - return view; -} - -QWidget *HomeView::createPlayerView(const QString &mediaId, const QString &title, const QString &streamUrl, - long long startPositionTicks, const QVariant &extraData) -{ - auto *view = new PlayerView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "PlayerView"); - view->setProperty("routeId", mediaId); - view->setProperty("routeTitle", title); - view->setProperty("startPositionTicks", startPositionTicks); - - - connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); - connect(view, &PlayerView::playerChromeVisibilityChanged, this, - [this, view](bool visible) - { - if (m_isDestroying || !m_contentSwitcher) - { - return; - } - - if (activePlayerView() == view) - { - Q_EMIT playerChromeVisibilityChanged(visible); - } - }); - - - - - - QPointer safeView(view); - auto launchPlay = [safeView, mediaId, title, streamUrl, startPositionTicks, extraData]() - { - if (safeView) - { - safeView->playMedia(mediaId, title, streamUrl, startPositionTicks, extraData); - } - }; - - if (m_contentSwitcher) - { - - - - connect(m_contentSwitcher, &SlidingStackedWidget::animationFinished, view, - launchPlay, Qt::SingleShotConnection); - } - else - { - - launchPlay(); - } - - return view; -} - - - - -void HomeView::launchPlayer(const QString &mediaId, const QString &title, const QString &streamUrl, - long long startPositionTicks, const QVariant &extraData) -{ - PlaybackManager::instance()->startPlayback(mediaId, title, streamUrl, startPositionTicks, extraData); -} - -QWidget *HomeView::createSettingsView() -{ - auto *view = new SettingsView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "SettingsView"); - view->setProperty("routeId", "settings_global"); - view->setProperty("routeTitle", tr("Settings")); - - - connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); - - return view; -} - -QWidget *HomeView::createManageView() -{ - auto *view = new ManageView(m_core, this); - view->setProperty("isDynamic", true); - view->setProperty("routeType", "ManageView"); - view->setProperty("routeId", "manage_global"); - view->setProperty("routeTitle", tr("Server Management")); - - connect(view, &BaseView::navigateBack, this, &HomeView::navigateBack); - - return view; -} - -void HomeView::setupSidebar() -{ - m_sidebar = new QWidget(this); - m_sidebar->setObjectName("floating-sidebar"); - m_sidebar->setProperty("sidebarSide", m_sidebarOnRight ? "right" : "left"); - - - // Drop shadow as a mouse-transparent sibling painted via a pre-baked - // 9-patch, instead of QGraphicsDropShadowEffect. The effect would force the - // sidebar through an off-screen software blur on every frame of its slide - // animation; the overlay just follows the sidebar's geometry (see - // syncSidebarShadow) with no per-frame blur cost. - m_sidebarShadow = new ShadowOverlay(this); - m_sidebarShadow->setShadow(25, 0, QColor(0, 0, 0, 30)); - m_sidebarShadow->hide(); - - auto *layout = new QVBoxLayout(m_sidebar); - layout->setContentsMargins(16, 20, 0, 20); - layout->setSpacing(6); - - - auto *serverInfoWidget = new QWidget(m_sidebar); - m_serverInfoLayout = new QBoxLayout(QBoxLayout::LeftToRight, serverInfoWidget); - m_serverInfoLayout->setContentsMargins(8, 0, 8, 10); - m_serverInfoLayout->setSpacing(10); - - m_serverIconLabel = new QLabel(serverInfoWidget); - m_serverIconLabel->setFixedSize(32, 32); - m_serverIconLabel->setScaledContents(true); - - m_serverNameLayout = new QVBoxLayout(); - m_serverNameLayout->setContentsMargins(0, 0, 0, 0); - m_serverNameLayout->setSpacing(0); - m_serverNameLayout->setAlignment(Qt::AlignVCenter); - - m_serverNameLabel = new ElidedLabel(serverInfoWidget); - m_serverNameLabel->setObjectName("sidebar-server-name"); - - m_serverAddressLabel = new ElidedLabel(serverInfoWidget); - m_serverAddressLabel->setObjectName("sidebar-server-address"); - - m_serverNameLayout->addWidget(m_serverNameLabel); - m_serverNameLayout->addWidget(m_serverAddressLabel); - - m_serverInfoLayout->addWidget(m_serverIconLabel, 0, Qt::AlignVCenter); - m_serverInfoLayout->addLayout(m_serverNameLayout); - layout->addWidget(serverInfoWidget); - - - m_navArea = new QWidget(m_sidebar); - auto *navLayout = new QVBoxLayout(m_navArea); - navLayout->setContentsMargins(0, 0, 16, 0); - navLayout->setSpacing(0); - - m_searchBox = new QLineEdit(m_navArea); - m_searchBox->setObjectName("sidebar-search"); - m_searchBox->setPlaceholderText(tr("Search...")); - m_searchAction = new QAction(this); - m_searchAction->setText(tr("Search")); - m_searchBox->addAction(m_searchAction, QLineEdit::LeadingPosition); - navLayout->addWidget(m_searchBox); - m_searchSpacer = new QWidget(m_navArea); - m_searchSpacer->setFixedHeight(10); - navLayout->addWidget(m_searchSpacer); - - - connect(m_searchBox, &QLineEdit::returnPressed, this, - [this]() - { - if (m_searchCompleter && m_searchCompleter->popup()) { - m_searchCompleter->popup()->hide(); - } - triggerSearch(m_searchBox->text()); - m_searchBox->clear(); - if (m_searchCompleter && m_searchCompleter->popup()) { - m_searchCompleter->popup()->hide(); - } - }); - setupSearchHistory(); - - m_btnHome = new QPushButton(tr("Home"), m_navArea); - m_btnFavorites = new QPushButton(tr("Favorites"), m_navArea); - - m_btnHome->setObjectName("sidebar-btn"); - - m_btnFavorites->setObjectName("sidebar-btn"); - - navLayout->addWidget(m_btnHome); - navLayout->addWidget(m_btnFavorites); - - auto *sep1 = new QFrame(m_navArea); - sep1->setObjectName("sidebar-sep"); - navLayout->addSpacing(3); - navLayout->addWidget(sep1); - navLayout->addSpacing(3); - - layout->addWidget(m_navArea); - - - auto *libTitle = new QLabel(tr("MEDIA"), m_sidebar); - libTitle->setObjectName("sidebar-title"); - layout->addWidget(libTitle); - - m_libraryList = new QListWidget(m_sidebar); - m_libraryList->setObjectName("sidebar-list"); - m_libraryList->setFocusPolicy(Qt::NoFocus); - m_libraryList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_libraryList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); - m_libraryList->setTextElideMode(Qt::ElideRight); - m_libraryList->setWordWrap(false); - m_sidebarLibraryScrollController = - new SmoothScrollController(m_libraryList->verticalScrollBar(), this); - m_sidebarLibraryScrollController->setDuration(AnimationTokens::DurationMs::kFast); - m_libraryList->viewport()->installEventFilter(this); - layout->addWidget(m_libraryList, 1); - - - - auto *sep2 = new QFrame(m_sidebar); - sep2->setObjectName("sidebar-sep"); - layout->addSpacing(3); - layout->addWidget(sep2); - layout->addSpacing(3); - - - auto *userInfoWidget = new QWidget(m_sidebar); - m_userInfoLayout = new QHBoxLayout(userInfoWidget); - m_userInfoLayout->setContentsMargins(0, 0, 0, 10); - m_userInfoLayout->setSpacing(10); - m_userInfoLayout->setAlignment(Qt::AlignVCenter); - - m_userAvatarLabel = new QLabel(userInfoWidget); - m_userAvatarLabel->setFixedSize(20, 20); - m_userAvatarLabel->setScaledContents(true); - - m_userNameLabel = new ElidedLabel(userInfoWidget); - m_userNameLabel->setObjectName("sidebar-user-name"); - - m_btnCloudSync = new QPushButton(userInfoWidget); - m_btnCloudSync->setObjectName("sidebar-icon-btn"); - m_btnCloudSync->setCursor(Qt::PointingHandCursor); - m_btnCloudSync->setToolTip(tr("Cloud Sync (WebDAV)")); - - m_btnDownloads = new QPushButton(userInfoWidget); - m_btnDownloads->setObjectName("sidebar-icon-btn"); - m_btnDownloads->setCursor(Qt::PointingHandCursor); - m_btnDownloads->setToolTip(tr("Downloads")); - - m_userInfoLayout->addWidget(m_userAvatarLabel); - m_userInfoLayout->addWidget(m_userNameLabel, 1); - m_userInfoLayout->addWidget(m_btnCloudSync, 0, Qt::AlignVCenter); - m_userInfoLayout->addWidget(m_btnDownloads, 0, Qt::AlignVCenter); - layout->addWidget(userInfoWidget); - - auto *footerActionsWidget = new QWidget(m_sidebar); - m_sidebarFooterActionsLayout = new QVBoxLayout(footerActionsWidget); - m_sidebarFooterActionsLayout->setContentsMargins(0, 0, 16, 0); - m_sidebarFooterActionsLayout->setSpacing(6); - - m_btnSettings = new QPushButton(tr("Settings"), footerActionsWidget); - m_btnManage = new QPushButton(tr("Manage"), footerActionsWidget); - m_btnLogout = new QPushButton(tr("Logout"), footerActionsWidget); - - m_btnSettings->setObjectName("sidebar-btn"); - - m_btnManage->setObjectName("sidebar-btn"); - - m_btnLogout->setObjectName("sidebar-btn-danger"); - - m_sidebarFooterActionsLayout->addWidget(m_btnSettings); - m_sidebarFooterActionsLayout->addWidget(m_btnManage); - m_sidebarFooterActionsLayout->addWidget(m_btnLogout); - layout->addWidget(footerActionsWidget); - - - connect(m_btnLogout, &QPushButton::clicked, this, - [this]() - { - - if (m_contentSwitcher->currentWidget() != homeRootWidget()) - { - resetToView(homeRootWidget(), QStringLiteral("home")); - } - Q_EMIT logoutRequested(); - }); - - - connect(m_btnHome, &QPushButton::clicked, this, - [this]() - { - if (!canGoHome()) - { - ModernToast::showMessage(tr("Refreshing Home..."), 1000); - } - goHome(); - }); - - connect(m_btnFavorites, &QPushButton::clicked, this, - [this]() - { - if (!canGoFav()) - { - ModernToast::showMessage(tr("Refreshing Favorites..."), 1000); - } - goFav(); - }); - - connect(m_btnSettings, &QPushButton::clicked, this, - [this]() - { - m_libraryList->clearSelection(); - - - QWidget *current = m_contentSwitcher->currentWidget(); - if (current && current->property("routeType").toString() == "SettingsView") - { - return; - } - - - pushView(createSettingsView()); - }); - connect(m_btnManage, &QPushButton::clicked, this, - [this]() - { - m_libraryList->clearSelection(); - - - QWidget *current = m_contentSwitcher->currentWidget(); - if (current && current->property("routeType").toString() == "ManageView") - { - return; - } - - pushView(createManageView()); - }); - connect(m_btnCloudSync, &QPushButton::clicked, this, - [this]() - { - if (m_libraryList) - { - m_libraryList->clearSelection(); - } - openCloudSyncDialog(); - }); - connect(m_btnDownloads, &QPushButton::clicked, this, - [this]() - { - m_libraryList->clearSelection(); - DownloadManagerDialog::showManager(m_core, this); - }); - - connect(m_libraryList, &QListWidget::itemClicked, this, - [this](QListWidgetItem *item) - { - QString viewId = item->data(Qt::UserRole).toString(); - QString cleanName = item->data(kSidebarLibraryNameRole).toString(); - if (cleanName.isEmpty()) - { - cleanName = item->data(Qt::ToolTipRole).toString(); - } - if (cleanName.isEmpty()) - { - cleanName = item->text(); - } - - QWidget *current = m_contentSwitcher->currentWidget(); - - if (current && current->property("routeId").toString() == viewId) - { - return; - } - openLibraryRoute(viewId, cleanName); - }); - - m_sidebarAnim = new QPropertyAnimation(m_sidebar, "pos", this); - m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow); - m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::enter()); - - m_sidebar->installEventFilter(this); - m_sidebar->setMouseTracking(true); - - - m_sidebarAutoHideTimer = new QTimer(this); - m_sidebarAutoHideTimer->setSingleShot(true); - m_sidebarAutoHideTimer->setInterval(5000); - connect(m_sidebarAutoHideTimer, &QTimer::timeout, this, - [this]() - { - - QPoint globalPos = QCursor::pos(); - QPoint localPos = m_sidebar->mapFromGlobal(globalPos); - if (!m_sidebar->rect().contains(localPos)) - { - hideSidebar(); - } - else - { - - m_sidebarAutoHideTimer->start(); - } - }); - - - connect(ConfigStore::instance(), &ConfigStore::valueChanged, this, - [this](const QString &key, const QVariant &value) - { - if (key == ConfigKeys::SidebarPosition) - { - m_sidebarOnRight = (value.toString() == "right"); - applySidebarPosition(); - } - else if (key == ConfigKeys::SidebarPinned) - { - bool pinned = value.toBool(); - m_sidebarPinned = pinned; - applySidebarPinned(pinned); - } - else if (key == ConfigKeys::SidebarCustomEnabled || - key == ConfigKeys::SidebarHideSearch || - key == ConfigKeys::SidebarHideHome || - key == ConfigKeys::SidebarHideFavorites) - { - applySidebarCustomVisibility(); - } - }); - - applySidebarMetrics(m_sidebarPinned); - applySidebarIcons(); - applySidebarCustomVisibility(); -} - -void HomeView::openCloudSyncDialog() -{ - if (!m_webdavStore) - { - m_webdavStore = new WebdavProfileStore(this); - m_webdavStore->load(); - qInfo() << "[HomeView] WebdavProfileStore loaded | hasProfile:" - << m_webdavStore->hasProfile(); - } - - ServerManager *serverManager = m_core ? m_core->serverManager() : nullptr; - WebdavSyncDialog dialog(m_webdavStore, serverManager, this); - dialog.exec(); -} - - -void HomeView::triggerSearch(const QString &query) -{ - const QString trimmedQuery = query.trimmed(); - if (trimmedQuery.isEmpty()) - return; - - SearchHistoryManager::instance()->recordSearch(currentSearchServerId(), - trimmedQuery); - - - m_libraryList->clearSelection(); - const QVariantMap searchRequest{ - {QStringLiteral("name"), QStringLiteral("search")}, - {QStringLiteral("params"), - QVariantMap{{QStringLiteral("query"), trimmedQuery}}}}; - if (!pushQuickRoute(searchRequest)) - pushView(createSearchView(trimmedQuery)); -} - -void HomeView::setupSearchHistory() -{ - if (!m_searchBox) { - return; - } - - m_searchHistoryModel = new QStringListModel(this); - m_searchCompleter = new QCompleter(m_searchHistoryModel, this); - m_searchCompleter->setCaseSensitivity(Qt::CaseInsensitive); - m_searchCompleter->setFilterMode(Qt::MatchContains); - m_searchCompleter->setCompletionMode(QCompleter::PopupCompletion); - m_searchCompleter->setMaxVisibleItems(8); - m_searchCompleter->setPopup(new SearchCompleterPopup()); - if (auto *popup = - qobject_cast(m_searchCompleter->popup())) { - popup->setMaxVisibleRows(m_searchCompleter->maxVisibleItems()); - } - m_searchBox->setCompleter(m_searchCompleter); - - connect(m_searchBox, &QLineEdit::textEdited, this, - [this](const QString &text) { updateSearchCompleter(text); }); - - connect(SearchHistoryManager::instance(), &SearchHistoryManager::historyChanged, - this, [this](const QString &serverId) { - if (serverId == currentSearchServerId()) { - updateSearchCompleter(m_searchBox ? m_searchBox->text() - : QString()); - } - }); - connect(SearchHistoryManager::instance(), &SearchHistoryManager::enabledChanged, - this, [this](bool ) { - updateSearchCompleter(m_searchBox ? m_searchBox->text() - : QString()); - }); - connect(SearchHistoryManager::instance(), - &SearchHistoryManager::autocompleteEnabledChanged, this, - [this](bool ) { - updateSearchCompleter(m_searchBox ? m_searchBox->text() - : QString()); - }); - - if (m_core && m_core->serverManager()) { - connect(m_core->serverManager(), &ServerManager::activeServerChanged, this, - [this](const ServerProfile &profile) { - Q_UNUSED(profile); - updateSearchCompleter(m_searchBox ? m_searchBox->text() - : QString()); - }); - } - - updateSearchCompleter(); -} - -void HomeView::updateSearchCompleter(const QString &text) -{ - if (!m_searchHistoryModel || !m_searchCompleter || !m_searchBox) { - return; - } - - const QStringList suggestions = - SearchHistoryManager::instance()->completionSuggestions( - currentSearchServerId(), text, 8); - m_searchHistoryModel->setStringList(suggestions); - - if (!SearchHistoryManager::instance()->isAutocompleteEnabled() || - text.trimmed().isEmpty() || - suggestions.isEmpty() || !m_searchBox->hasFocus()) { - if (m_searchCompleter->popup()) { - m_searchCompleter->popup()->hide(); - } - return; - } - - if (auto *popup = - qobject_cast(m_searchCompleter->popup())) { - popup->setHighlightText(text); - popup->syncWidthToAnchor(m_searchBox); - } - - m_searchCompleter->setCompletionPrefix(text); - if (auto *popup = - qobject_cast(m_searchCompleter->popup())) { - m_searchCompleter->complete(popup->popupRectForAnchor(m_searchBox)); - return; - } - - m_searchCompleter->complete(); -} - -QString HomeView::currentSearchServerId() const -{ - if (!m_core || !m_core->serverManager()) { - return {}; - } - return m_core->serverManager()->activeProfile().id; -} - - - - - -void HomeView::pushView(QWidget *view) -{ - if (!view) - return; - - QWidget *current = m_contentSwitcher->currentWidget(); - if (current == view) - return; - - const QVariantMap routeRequest = routeRequestForWidget(view); - if (routeRequest.isEmpty() || !m_navigationController->push(routeRequest)) - { - qWarning() << "[HomeView] Refusing legacy view without a valid route" - << view->property("routeType"); - if (m_contentSwitcher->indexOf(view) == -1 && view->property("isDynamic").toBool()) - view->deleteLater(); - return; - } - - if (m_contentSwitcher->indexOf(view) == -1) - { - m_contentSwitcher->addWidget(view); - } - - - if (current) - { - RouteInfo info; - info.widget = current; - info.isDynamic = current->property("isDynamic").toBool(); - info.routeType = current->property("routeType").toString(); - info.routeId = current->property("routeId").toString(); - info.routeTitle = current->property("routeTitle").toString(); - - info.routeExtraId = current->property("routeExtraId").toString(); - m_navStack.push(info); - } - - - const int MAX_ACTIVE_VIEWS = 12; - int activeDynamicCount = 0; - - - for (int i = m_navStack.size() - 1; i >= 0; --i) - { - if (m_navStack[i].isDynamic && m_navStack[i].widget) - { - activeDynamicCount++; - if (activeDynamicCount > MAX_ACTIVE_VIEWS) - { - - m_contentSwitcher->removeWidget(m_navStack[i].widget); - m_navStack[i].widget->deleteLater(); - - - } - } - } - - - QMetaObject::invokeMethod(view, "scrollToTop", Qt::QueuedConnection); - - if (view->property("routeType").toString() == QLatin1String("PlayerView")) - { - // 播放器有自己的入场动画(HUD 淡入等),瞬切避免与翻页动画叠加 - m_contentSwitcher->jumpToWgt(view); - } - else - { - m_contentSwitcher->slideInWgt(view, SlidingStackedWidget::RightToLeft); - } - Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); -} - -bool HomeView::pushQuickRoute(const QVariantMap &request) -{ - if (!m_useQuickHomeRouter || !m_homeQuickRouterView || - !m_navigationController || !m_contentSwitcher) - return false; - - QWidget *current = m_contentSwitcher->currentWidget(); - if (!m_navigationController->push(request)) - return false; - - if (current) - { - RouteInfo info; - info.widget = current; - info.isDynamic = current->property("isDynamic").toBool(); - info.routeType = current->property("routeType").toString(); - info.routeId = current->property("routeId").toString(); - info.routeTitle = current->property("routeTitle").toString(); - info.routeExtraId = current->property("routeExtraId").toString(); - m_navStack.push(info); - } - - if (current != m_homeQuickRouterView) - { - if (m_contentSwitcher->indexOf(m_homeQuickRouterView) == -1) - m_contentSwitcher->addWidget(m_homeQuickRouterView); - m_contentSwitcher->slideInWgt(m_homeQuickRouterView, - SlidingStackedWidget::RightToLeft); - } - Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); - return true; -} - -void HomeView::openCategoryRoute(const QString &categoryId, const QString &title) -{ - const bool favorite = categoryId.startsWith(QLatin1String("Favorite_")); - const QString normalizedId = favorite ? categoryId.mid(9) : categoryId; - const QVariantMap request{ - {QStringLiteral("name"), QStringLiteral("category")}, - {QStringLiteral("title"), title}, - {QStringLiteral("params"), - QVariantMap{{QStringLiteral("categoryId"), normalizedId}, - {QStringLiteral("title"), title}, - {QStringLiteral("favorite"), favorite}}}}; - if (!pushQuickRoute(request)) - pushView(createCategoryView(categoryId, title)); -} - -void HomeView::openLibraryRoute(const QString &libraryId, const QString &title) -{ - const QVariantMap request{ - {QStringLiteral("name"), QStringLiteral("library")}, - {QStringLiteral("title"), title}, - {QStringLiteral("params"), - QVariantMap{{QStringLiteral("libraryId"), libraryId}, - {QStringLiteral("title"), title}}}}; - if (!pushQuickRoute(request)) - pushView(createLibraryView(libraryId, title)); -} - -void HomeView::openPersonRoute(const QString &personId, const QString &title) -{ - const QVariantMap request{ - {QStringLiteral("name"), QStringLiteral("person")}, - {QStringLiteral("title"), title}, - {QStringLiteral("params"), - QVariantMap{{QStringLiteral("personId"), personId}, - {QStringLiteral("title"), title}}}}; - if (!pushQuickRoute(request)) - pushView(createPersonView(personId, title)); -} - -void HomeView::openFilteredRoute(const QString &filterType, - const QString &filterValue) -{ - const QVariantMap request{ - {QStringLiteral("name"), QStringLiteral("filtered")}, - {QStringLiteral("title"), filterValue}, - {QStringLiteral("params"), - QVariantMap{{QStringLiteral("filterType"), filterType}, - {QStringLiteral("filterValue"), filterValue}, - {QStringLiteral("title"), filterValue}}}}; - if (!pushQuickRoute(request)) - pushView(createFilteredView(filterType, filterValue)); -} - -void HomeView::navigateBack() -{ - QWidget *current = m_contentSwitcher->currentWidget(); - - - - - if (auto *baseView = qobject_cast(current)) - { - if (baseView->handleBackNavigation()) - { - qDebug() << "[HomeView] Back navigation consumed by current view" - << "| routeType=" - << current->property("routeType").toString(); - return; - } - - qDebug() << "[HomeView] Preparing current view for back navigation" - << "| routeType=" - << current->property("routeType").toString(); - baseView->prepareForStackLeave(); - } - - if (!m_navStack.isEmpty()) - { - if (!m_navigationController->back()) - { - qWarning() << "[HomeView] Navigation stacks drifted before back" - << "legacyDepth=" << m_navStack.size() + 1 - << "controllerDepth=" << m_navigationController->depth(); - return; - } - RouteInfo prevInfo = m_navStack.pop(); - QWidget *targetView = prevInfo.widget; - - - if (!targetView && prevInfo.isDynamic) - { - if (prevInfo.routeType == "DetailView") - { - targetView = createDetailView(prevInfo.routeId, prevInfo.routeTitle); - } - else if (prevInfo.routeType == "LibraryView") - { - targetView = createLibraryView(prevInfo.routeId, prevInfo.routeTitle); - } - else if (prevInfo.routeType == "CategoryView") - { - targetView = createCategoryView(prevInfo.routeId, prevInfo.routeTitle); - } - else if (prevInfo.routeType == "SearchView") - { - targetView = createSearchView(prevInfo.routeId); - } - else if (prevInfo.routeType == "SettingsView") - { - targetView = createSettingsView(); - } - else if (prevInfo.routeType == "PersonView") - { - targetView = createPersonView(prevInfo.routeId, prevInfo.routeTitle); - } - else if (prevInfo.routeType == "SeasonView") - { - - targetView = createSeasonView(prevInfo.routeExtraId, prevInfo.routeId, prevInfo.routeTitle); - } - else if (prevInfo.routeType == "ManageView") - { - targetView = createManageView(); - } - else if (prevInfo.routeType == "FilteredView") - { - targetView = createFilteredView(prevInfo.routeExtraId, prevInfo.routeTitle); - } - - if (targetView && m_contentSwitcher->indexOf(targetView) == -1) - { - m_contentSwitcher->addWidget(targetView); - } - } - - - if (!targetView) - targetView = homeRootWidget(); - - if (targetView == current) - { - Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); - return; - } - - - - - - - if (current && current->property("routeType").toString() == QLatin1String("PlayerView")) - { - // 与进入播放器对称:退出时瞬切,避免黑色播放器表面滑走 - m_contentSwitcher->jumpToWgt(targetView); - } - else - { - m_contentSwitcher->slideInWgt(targetView, SlidingStackedWidget::LeftToRight); - } - Q_EMIT canNavigateBackChanged(!m_navStack.isEmpty()); - - - - - if (current && current->property("isDynamic").toBool()) - { - m_contentSwitcher->disposeWidgetWhenSafe(current); - } - } -} - -void HomeView::goHome() -{ - const QString currentRoute = m_navigationController->currentRoute() - .value(QStringLiteral("name")).toString(); - if (m_contentSwitcher->currentWidget() == homeRootWidget() && - currentRoute == QLatin1String("home")) - { - m_navigationController->home(); - if (m_dashboardView) - m_dashboardView->loadDashboardData(); - return; - } - m_libraryList->clearSelection(); - resetToView(homeRootWidget(), QStringLiteral("home")); -} - -void HomeView::goFav() -{ - const QString currentRoute = m_navigationController->currentRoute() - .value(QStringLiteral("name")).toString(); - if (m_contentSwitcher->currentWidget() == homeRootWidget() && - currentRoute == QLatin1String("favorites")) - { - if (m_homeQuickRouterView) - m_homeQuickRouterView->refreshFavorites(); - else if (m_favoritesView) - m_favoritesView->loadFavoritesData(); - return; - } - m_libraryList->clearSelection(); - resetToView(m_useQuickHomeRouter ? homeRootWidget() : m_favoritesView, - QStringLiteral("favorites")); -} - -void HomeView::resetToView(QWidget *view, const QString &rootRoute) -{ - if (!view) - return; - - const QVariantMap rootRequest = routeRequestForWidget(view); - const QString rootName = rootRoute.isEmpty() - ? rootRequest.value(QStringLiteral("name")).toString() - : rootRoute; - if (!m_navigationController->resetToRoot(rootName)) - { - qWarning() << "[HomeView] Refusing invalid legacy navigation root" << rootName; - return; - } - - - QList> widgetsToDelete; - while (!m_navStack.isEmpty()) - { - RouteInfo info = m_navStack.pop(); - if (info.isDynamic && info.widget && info.widget != view) - { - m_contentSwitcher->removeWidget(info.widget); - widgetsToDelete.append(info.widget); - } - } - - if (m_contentSwitcher->currentWidget() == view) - { - QMetaObject::invokeMethod(view, "scrollToTop", Qt::QueuedConnection); - Q_EMIT canNavigateBackChanged(false); - return; - } - - QWidget *current = m_contentSwitcher->currentWidget(); - if (auto *baseView = qobject_cast(current)) - { - qDebug() << "[HomeView] Preparing current view for reset navigation" - << "| routeType=" - << current->property("routeType").toString(); - baseView->prepareForStackLeave(); - } - - if (m_contentSwitcher->indexOf(view) == -1) - { - m_contentSwitcher->addWidget(view); - } - - - QMetaObject::invokeMethod(view, "scrollToTop", Qt::QueuedConnection); - - m_contentSwitcher->slideInWgt(view, SlidingStackedWidget::Automatic); - Q_EMIT canNavigateBackChanged(false); - - - if (current && current->property("isDynamic").toBool() && current != view) - { - widgetsToDelete.append(QPointer(current)); - } - - if (!widgetsToDelete.isEmpty()) - { - for (const QPointer &widget : widgetsToDelete) - { - if (widget) - { - m_contentSwitcher->disposeWidgetWhenSafe(widget); - } - } - } -} - -bool HomeView::canNavigateBack() const -{ - return !m_navStack.isEmpty(); -} - -bool HomeView::canGoHome() const -{ - if (!m_contentSwitcher || !homeRootWidget() || !m_navigationController) - return false; - return m_contentSwitcher->currentWidget() != homeRootWidget() || - m_navigationController->currentRoute().value(QStringLiteral("name")) != - QLatin1String("home"); -} - -bool HomeView::canGoFav() const -{ - if (!m_contentSwitcher || !homeRootWidget() || !m_navigationController) - return false; - const QWidget *favoritesRoot = m_useQuickHomeRouter - ? homeRootWidget() - : static_cast(m_favoritesView); - return m_contentSwitcher->currentWidget() != favoritesRoot || - m_navigationController->currentRoute().value(QStringLiteral("name")) != - QLatin1String("favorites"); -} - -QWidget *HomeView::homeRootWidget() const -{ - return m_useQuickHomeRouter - ? static_cast(m_homeQuickRouterView) - : static_cast(m_dashboardView); -} - -void HomeView::scheduleProfileRefresh() -{ - m_pendingProfileRefreshTask = refreshProfile(); -} - - -QCoro::Task HomeView::refreshProfile() -{ - - QPointer guard(this); - const int refreshGeneration = ++m_profileRefreshGeneration; - - ServerProfile activeProfile = m_core->serverManager()->activeProfile(); - const bool canReuseExistingLibraries = - !m_sidebarLibraryServerId.isEmpty() && - m_sidebarLibraryServerId == activeProfile.id && - m_sidebarLibraryUserId == activeProfile.userId; - const int previousLibraryCount = m_libraryList ? m_libraryList->count() : 0; - auto isRefreshStillCurrent = [this, guard, refreshGeneration, activeProfile]() - { - if (!guard) - return false; - - const ServerProfile currentProfile = m_core->serverManager()->activeProfile(); - return refreshGeneration == m_profileRefreshGeneration && - currentProfile.id == activeProfile.id && - currentProfile.userId == activeProfile.userId; - }; - - if (activeProfile.type == ServerProfile::Jellyfin) - { - m_serverIconLabel->setPixmap(QPixmap(":/svg/jellyfin.svg")); - } - else - { - if (!activeProfile.iconBase64.isEmpty()) - { - QPixmap pix; - pix.loadFromData(QByteArray::fromBase64(activeProfile.iconBase64.toUtf8())); - m_serverIconLabel->setPixmap(pix); - } - else - { - m_serverIconLabel->setPixmap(QPixmap(":/svg/emby.svg")); - } - } - - QString displayName = activeProfile.name.isEmpty() ? tr("My Server") : activeProfile.name; - m_serverNameLabel->setFullText(displayName); - m_serverAddressLabel->setFullText(activeProfile.url); - - applySidebarIcons(); - - - m_btnManage->setVisible(activeProfile.isAdmin); - if (m_navigationController) - m_navigationController->setAdministrator(activeProfile.isAdmin); - m_btnDownloads->setVisible(activeProfile.canDownloadMedia); - - const QString userRoleText = activeProfile.isAdmin ? tr("Administrator") : tr("User"); - QString displayUserName = activeProfile.userName.isEmpty() ? userRoleText : activeProfile.userName; - m_userNameLabel->setFullText(displayUserName); - m_userNameLabel->setProperty("userRoleText", userRoleText); - applySidebarMetrics(m_sidebarPinned); - - try - { - QList views = co_await m_core->mediaService()->getUserViews(); - if (!isRefreshStillCurrent()) - { - qDebug() << "[HomeView] Ignoring stale sidebar library refresh after initial fetch" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount; - co_return; - } - - if (views.isEmpty()) - { - qDebug() << "[HomeView] Sidebar library refresh returned empty views, clearing cache and retrying" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount - << "| canReuseExistingLibraries=" << canReuseExistingLibraries; - m_core->mediaService()->clearUserViewsCache(); - views = co_await m_core->mediaService()->getUserViews(); - - if (!isRefreshStillCurrent()) - { - qDebug() << "[HomeView] Ignoring stale sidebar library refresh after retry" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount; - co_return; - } - - qDebug() << "[HomeView] Sidebar library retry completed" - << "| generation=" << refreshGeneration - << "| retriedViewCount=" << views.size(); - } - - QWidget *currentView = m_contentSwitcher ? m_contentSwitcher->currentWidget() : nullptr; - const QString currentRouteType = currentView ? currentView->property("routeType").toString() : QString(); - const QString currentRouteId = currentView ? currentView->property("routeId").toString() : QString(); - QListWidgetItem *selectedLibraryItem = nullptr; - const bool shouldKeepExistingLibraries = - views.isEmpty() && previousLibraryCount > 0 && canReuseExistingLibraries; - - if (!shouldKeepExistingLibraries) - { - m_libraryList->clear(); - for (const auto &view : views) - { - QString iconStr = "📁 "; - if (view.collectionType == "movies") - iconStr = "🎬 "; - else if (view.collectionType == "tvshows") - iconStr = "📺 "; - else if (view.collectionType == "music") - iconStr = "🎵 "; - else if (view.collectionType == "homevideos" || view.collectionType == "photos") - iconStr = "🎞️ "; - - auto *item = new QListWidgetItem(iconStr + view.name); - item->setData(Qt::UserRole, view.id); - item->setData(kSidebarLibraryNameRole, view.name); - item->setData(Qt::ToolTipRole, view.name); - m_libraryList->addItem(item); - - if (currentRouteType == "LibraryView" && view.id == currentRouteId) - { - selectedLibraryItem = item; - } - } - - m_sidebarLibraryServerId = activeProfile.id; - m_sidebarLibraryUserId = activeProfile.userId; - } - else - { - qDebug() << "[HomeView] Keeping existing sidebar library list because refreshed views are unexpectedly empty" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount - << "| routeType=" << currentRouteType - << "| routeId=" << currentRouteId; - } - - if (!selectedLibraryItem && currentRouteType == "LibraryView") - { - for (int i = 0; i < m_libraryList->count(); ++i) - { - QListWidgetItem *item = m_libraryList->item(i); - if (item && item->data(Qt::UserRole).toString() == currentRouteId) - { - selectedLibraryItem = item; - break; - } - } - } - - if (selectedLibraryItem) - { - m_libraryList->setCurrentItem(selectedLibraryItem); - selectedLibraryItem->setSelected(true); - } - else - { - m_libraryList->clearSelection(); - } - - qDebug() << "[HomeView] Sidebar library refresh applied" - << "| generation=" << refreshGeneration - << "| fetchedViewCount=" << views.size() - << "| sidebarCount=" << m_libraryList->count() - << "| preservedExisting=" << shouldKeepExistingLibraries - << "| routeType=" << currentRouteType - << "| routeId=" << currentRouteId; - } - catch (const std::exception &e) - { - if (!isRefreshStillCurrent()) - { - qDebug() << "[HomeView] Ignoring stale sidebar library refresh failure" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount - << "| error=" << e.what(); - co_return; - } - qDebug() << "[HomeView] Failed to load library views for sidebar" - << "| generation=" << refreshGeneration - << "| previousLibraryCount=" << previousLibraryCount - << "| error=" << e.what(); - } -} - -void HomeView::showEvent(QShowEvent *event) -{ - QWidget::showEvent(event); - - - - if (m_sidebarPinned && !m_sidebarPinnedApplied) - { - m_sidebarPinnedApplied = true; - applySidebarPinned(true); - } - else if (m_sidebarPinned && m_sidebarPinnedApplied) - { - - if (m_contentLayout->indexOf(m_sidebar) == -1) - { - if (m_sidebarOnRight) - { - m_contentLayout->addWidget(m_sidebar); - } - else - { - m_contentLayout->insertWidget(0, m_sidebar); - } - m_sidebar->show(); - } - } - - syncSidebarVisibility(); - scheduleProfileRefresh(); -} - -void HomeView::hideEvent(QHideEvent *event) -{ - - - if (m_sidebarPinned && m_sidebarPinnedApplied) - { - m_contentLayout->removeWidget(m_sidebar); - m_sidebar->hide(); - } - - QWidget::hideEvent(event); -} - -void HomeView::resizeEvent(QResizeEvent *event) -{ - QWidget::resizeEvent(event); - - - if (m_sidebarPinned) - return; - - const int sidebarW = m_sidebar->width(); - - if (m_sidebarOnRight) - { - - m_edgeTrigger->setGeometry(width() - kRightEdgeTriggerWidth, 0, kRightEdgeTriggerWidth, height()); - - - bool isShown = (m_sidebar->x() < width() && m_sidebar->x() >= width() - sidebarW); - if (isShown) - { - m_sidebar->setGeometry(width() - sidebarW, 0, sidebarW, height()); - } - else - { - m_sidebar->setGeometry(width() + kSidebarHiddenOffset, 0, sidebarW, height()); - } - } - else - { - - m_edgeTrigger->setGeometry(0, 0, kLeftEdgeTriggerWidth, height()); - - if (m_sidebar->x() < 0) - { - m_sidebar->setGeometry(-sidebarW - kSidebarHiddenOffset, 0, sidebarW, height()); - } - else - { - m_sidebar->setGeometry(0, 0, sidebarW, height()); - } - } -} - -bool HomeView::eventFilter(QObject *watched, QEvent *event) -{ - if (m_libraryList && watched == m_libraryList->viewport() && - event->type() == QEvent::Wheel) - { - auto *wheelEvent = static_cast(event); - if (m_sidebarLibraryScrollController && - m_sidebarLibraryScrollController->scrollByWheelEvent(wheelEvent, Qt::Vertical)) - { - return true; - } - } - - - if (m_sidebarPinned) - { - return QWidget::eventFilter(watched, event); - } - - if (watched == m_edgeTrigger && event->type() == QEvent::Enter) - { - showSidebar(); - return true; - } - - if (watched == m_sidebar) - { - // The drop-shadow overlay tracks the sidebar's geometry. move()/ - // setGeometry() anywhere (resize, slide animation, reposition) lands - // here as a Move/Resize event, so the shadow follows every frame - // without any per-frame blur. - if (event->type() == QEvent::Move || event->type() == QEvent::Resize || - event->type() == QEvent::Show || event->type() == QEvent::Hide) - { - syncSidebarShadow(); - } - - if (event->type() == QEvent::Leave) - { - QPoint globalPos = QCursor::pos(); - QPoint localPos = m_sidebar->mapFromGlobal(globalPos); - if (!m_sidebar->rect().contains(localPos)) - { - hideSidebar(); - } - return false; - } - - if (event->type() == QEvent::MouseMove) - { - if (m_sidebarAutoHideTimer->isActive()) - { - m_sidebarAutoHideTimer->start(); - } - } - } - return QWidget::eventFilter(watched, event); -} - -void HomeView::showSidebar() -{ - - if (m_sidebarPinned || isCurrentViewImmersive()) - return; - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (reduceAnimations) - { - if (m_sidebarOnRight) - { - m_sidebar->move(width() - m_sidebar->width(), 0); - } - else - { - m_sidebar->move(0, 0); - } - m_sidebarAutoHideTimer->start(); - return; - } - - if (m_sidebarAnim->state() == QAbstractAnimation::Running) - m_sidebarAnim->stop(); - m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kSlow); - m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::enter()); - m_sidebarAnim->setStartValue(m_sidebar->pos()); - - if (m_sidebarOnRight) - { - m_sidebarAnim->setEndValue(QPoint(width() - m_sidebar->width(), 0)); - } - else - { - m_sidebarAnim->setEndValue(QPoint(0, 0)); - } - m_sidebarAnim->start(); - m_sidebarAutoHideTimer->start(); -} - -void HomeView::hideSidebar() -{ - - if (m_sidebarPinned) - return; - - - bool reduceAnimations = ConfigStore::instance()->get(ConfigKeys::UiAnimations, false); - if (reduceAnimations) - { - if (m_sidebarOnRight) - { - m_sidebar->move(width() + kSidebarHiddenOffset, 0); - } - else - { - m_sidebar->move(-m_sidebar->width() - kSidebarHiddenOffset, 0); - } - return; - } - - m_sidebarAutoHideTimer->stop(); - if (m_sidebarAnim->state() == QAbstractAnimation::Running) - m_sidebarAnim->stop(); - // Dismiss reads snappier and more natural with an ease-in exit over kNormal. - m_sidebarAnim->setDuration(AnimationTokens::DurationMs::kNormal); - m_sidebarAnim->setEasingCurve(AnimationTokens::Curve::exit()); - m_sidebarAnim->setStartValue(m_sidebar->pos()); - - if (m_sidebarOnRight) - { - m_sidebarAnim->setEndValue(QPoint(width() + kSidebarHiddenOffset, 0)); - } - else - { - m_sidebarAnim->setEndValue(QPoint(-m_sidebar->width() - kSidebarHiddenOffset, 0)); - } - m_sidebarAnim->start(); -} - -bool HomeView::isCurrentViewImmersive() const -{ - QWidget *current = m_contentSwitcher ? m_contentSwitcher->currentWidget() : nullptr; - return current && current->property("isImmersive").toBool(); -} - -int HomeView::sidebarWidthForMode(bool pinned) const -{ - return pinned ? kPinnedSidebarWidth : kFloatingSidebarWidth; -} - -void HomeView::applySidebarMetrics(bool pinned) -{ - if (!m_sidebar) - { - return; - } - - m_sidebar->setFixedWidth(sidebarWidthForMode(pinned)); - - auto *layout = qobject_cast(m_sidebar->layout()); - const int horizontalInset = pinned ? 12 : 16; - if (layout) - { - if (pinned) - { - layout->setContentsMargins(horizontalInset, 18, 0, 18); - layout->setSpacing(4); - } - else - { - layout->setContentsMargins(horizontalInset, 20, 0, 20); - layout->setSpacing(6); - } - } - - if (m_navArea && m_navArea->layout()) - { - m_navArea->layout()->setContentsMargins(0, 0, horizontalInset, 0); - } - - if (m_sidebarFooterActionsLayout) - { - m_sidebarFooterActionsLayout->setContentsMargins(0, 0, horizontalInset, 0); - m_sidebarFooterActionsLayout->setSpacing(pinned ? 4 : 6); - } - - if (m_serverInfoLayout) - { - m_serverInfoLayout->setDirection(pinned ? QBoxLayout::TopToBottom : QBoxLayout::LeftToRight); - m_serverInfoLayout->setContentsMargins(pinned ? QMargins(4, 0, horizontalInset + 4, 10) - : QMargins(8, 0, horizontalInset + 8, 10)); - m_serverInfoLayout->setSpacing(pinned ? 8 : 10); - m_serverInfoLayout->setAlignment(m_serverIconLabel, pinned ? Qt::AlignHCenter : Qt::AlignVCenter); - if (m_serverNameLayout) - { - m_serverInfoLayout->setAlignment(m_serverNameLayout, pinned ? Qt::AlignHCenter : Qt::AlignVCenter); - } - } - - if (m_serverNameLayout) - { - m_serverNameLayout->setAlignment(pinned ? Qt::AlignHCenter : Qt::AlignVCenter); - } - - if (m_serverNameLabel) - { - m_serverNameLabel->setAlignment(pinned ? Qt::AlignHCenter : Qt::AlignLeft); - const QString serverName = m_serverNameLabel->fullText(); - const QString serverAddress = m_serverAddressLabel ? m_serverAddressLabel->fullText() : QString(); - - if (pinned && !serverAddress.isEmpty()) - { - m_serverNameLabel->setToolTip(serverName.isEmpty() ? serverAddress : serverName + "\n" + serverAddress); - } - else - { - m_serverNameLabel->setToolTip(serverName); - } - } - - if (m_serverAddressLabel) - { - m_serverAddressLabel->setAlignment(pinned ? Qt::AlignHCenter : Qt::AlignLeft); - m_serverAddressLabel->setVisible(!pinned); - if (!pinned) - { - m_serverAddressLabel->setToolTip(m_serverAddressLabel->fullText()); - } - } - - if (m_userInfoLayout) - { - m_userInfoLayout->setContentsMargins(pinned ? QMargins(4, 0, horizontalInset + 4, 8) - : QMargins(0, 0, horizontalInset, 10)); - m_userInfoLayout->setSpacing(pinned ? 4 : 8); - m_userInfoLayout->setAlignment(Qt::AlignVCenter); - } - - if (m_userAvatarLabel) - { - const int avatarSize = pinned ? 12 : 20; - m_userAvatarLabel->setFixedSize(avatarSize, avatarSize); - m_userAvatarLabel->setVisible(true); - } - - const int sidebarActionIconSize = pinned ? 19 : 22; - auto applySidebarActionIconSize = [sidebarActionIconSize](QPushButton* button) - { - if (button) - { - button->setIconSize(QSize(sidebarActionIconSize, sidebarActionIconSize)); - } - }; - applySidebarActionIconSize(m_btnHome); - applySidebarActionIconSize(m_btnFavorites); - applySidebarActionIconSize(m_btnSettings); - applySidebarActionIconSize(m_btnManage); - applySidebarActionIconSize(m_btnLogout); - - const int utilityButtonSize = pinned ? 16 : 20; - const int utilityIconSize = pinned ? 11 : 13; - auto applySidebarUtilityMetrics = [utilityButtonSize, utilityIconSize](QPushButton* button) - { - if (button) - { - button->setFixedSize(utilityButtonSize, utilityButtonSize); - button->setIconSize(QSize(utilityIconSize, utilityIconSize)); - } - }; - applySidebarUtilityMetrics(m_btnCloudSync); - applySidebarUtilityMetrics(m_btnDownloads); - - if (m_userNameLabel) - { - m_userNameLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - const QString userName = m_userNameLabel->fullText(); - const QString userRole = m_userNameLabel->property("userRoleText").toString(); - if (pinned && !userRole.isEmpty()) - { - m_userNameLabel->setToolTip(userName.isEmpty() ? userRole : userName + "\n" + userRole); - } - else - { - m_userNameLabel->setToolTip(userName); - } - } -} - -void HomeView::applySidebarIcons() -{ - if (m_searchAction) - { - m_searchAction->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/search.svg")); - } - if (m_btnHome) - { - m_btnHome->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/home.svg")); - } - if (m_btnFavorites) - { - m_btnFavorites->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/heart.svg")); - } - if (m_btnSettings) - { - m_btnSettings->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/settings.svg")); - } - if (m_btnManage) - { - m_btnManage->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/server.svg")); - } - if (m_btnCloudSync) - { - m_btnCloudSync->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/cloud-sync.svg")); - } - if (m_btnDownloads) - { - m_btnDownloads->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/download-sidebar.svg")); - } - if (m_btnLogout) - { - m_btnLogout->setIcon( - ThemeManager::getAdaptiveIcon(":/svg/light/logout.svg")); - } - if (m_userAvatarLabel && m_core && m_core->serverManager()) - { - const ServerProfile activeProfile = m_core->serverManager()->activeProfile(); - const QString avatarIconPath = activeProfile.isAdmin - ? QStringLiteral(":/svg/light/user-admin.svg") - : QStringLiteral(":/svg/light/user.svg"); - m_userAvatarLabel->setPixmap( - ThemeManager::getAdaptiveIcon(avatarIconPath).pixmap(20, 20)); - } -} - -void HomeView::applySidebarCustomVisibility() -{ - const bool customEnabled = ConfigStore::instance()->get(ConfigKeys::SidebarCustomEnabled, false); - - if (!customEnabled) - { - if (m_searchBox) m_searchBox->setVisible(true); - if (m_searchSpacer) m_searchSpacer->setVisible(true); - if (m_btnHome) m_btnHome->setVisible(true); - if (m_btnFavorites) m_btnFavorites->setVisible(true); - return; - } - - const bool hideSearch = ConfigStore::instance()->get(ConfigKeys::SidebarHideSearch, false); - const bool hideHome = ConfigStore::instance()->get(ConfigKeys::SidebarHideHome, false); - const bool hideFav = ConfigStore::instance()->get(ConfigKeys::SidebarHideFavorites, false); - - if (m_searchBox) m_searchBox->setVisible(!hideSearch); - if (m_searchSpacer) m_searchSpacer->setVisible(!hideSearch); - if (m_btnHome) m_btnHome->setVisible(!hideHome); - if (m_btnFavorites) m_btnFavorites->setVisible(!hideFav); -} - -void HomeView::syncSidebarVisibility() -{ - if (!m_sidebar || !m_edgeTrigger) - { - return; - } - - const bool isImmersive = isCurrentViewImmersive(); - qDebug() << "[HomeView] Sync sidebar visibility:" - << "pinned =" << m_sidebarPinned << "immersive =" << isImmersive; - - if (m_sidebarAnim && m_sidebarAnim->state() == QAbstractAnimation::Running) - { - m_sidebarAnim->stop(); - } - if (m_sidebarAutoHideTimer) - { - m_sidebarAutoHideTimer->stop(); - } - - if (isImmersive) - { - m_sidebar->hide(); - m_edgeTrigger->hide(); - return; - } - - if (m_sidebarPinned) - { - m_sidebar->show(); - m_edgeTrigger->hide(); - return; - } - - const int sidebarW = m_sidebar->width(); - if (m_sidebarOnRight) - { - m_sidebar->setGeometry(width() + kSidebarHiddenOffset, 0, sidebarW, height()); - m_edgeTrigger->setGeometry(width() - kRightEdgeTriggerWidth, 0, kRightEdgeTriggerWidth, height()); - } - else - { - m_sidebar->setGeometry(-sidebarW - kSidebarHiddenOffset, 0, sidebarW, height()); - m_edgeTrigger->setGeometry(0, 0, kLeftEdgeTriggerWidth, height()); - } - - m_sidebar->show(); - m_edgeTrigger->show(); -} - -void HomeView::syncSidebarShadow() -{ - if (!m_sidebarShadow || !m_sidebar) - { - return; - } - - // No floating shadow when pinned (docked in layout) or while the sidebar is - // hidden (e.g. immersive views). The sidebar staying on-screen but slid off - // to a negative/overflow position keeps the overlay shown but equally - // off-screen, so it correctly tracks slide-in/out. - if (m_sidebarPinned || !m_sidebar->isVisible()) - { - m_sidebarShadow->hide(); - return; - } - - const int reach = m_sidebarShadow->reach(); - m_sidebarShadow->setGeometry( - m_sidebar->geometry().adjusted(-reach, -reach, reach, reach)); - m_sidebarShadow->stackUnder(m_sidebar); - m_sidebarShadow->show(); -} - -void HomeView::applySidebarPosition() -{ - m_sidebar->setProperty("sidebarSide", m_sidebarOnRight ? "right" : "left"); - m_sidebar->style()->unpolish(m_sidebar); - m_sidebar->style()->polish(m_sidebar); - - - if (m_sidebarPinned) - { - m_contentLayout->removeWidget(m_sidebar); - if (m_sidebarOnRight) - { - m_contentLayout->addWidget(m_sidebar); - } - else - { - m_contentLayout->insertWidget(0, m_sidebar); - } - syncSidebarVisibility(); - return; - } - - const int sidebarW = m_sidebar->width(); - - // Shadow side follows the sidebar automatically (overlay tracks geometry in - // syncSidebarShadow); the baked shadow is symmetric, so no per-side offset. - if (m_sidebarOnRight) - { - m_edgeTrigger->setGeometry(width() - kRightEdgeTriggerWidth, 0, kRightEdgeTriggerWidth, height()); - } - else - { - m_edgeTrigger->setGeometry(0, 0, kLeftEdgeTriggerWidth, height()); - } - - - if (m_sidebarAnim->state() == QAbstractAnimation::Running) - m_sidebarAnim->stop(); - if (m_sidebarOnRight) - { - m_sidebar->setGeometry(width() + kSidebarHiddenOffset, 0, sidebarW, height()); - } - else - { - m_sidebar->setGeometry(-sidebarW - kSidebarHiddenOffset, 0, sidebarW, height()); - } -} - - - - -void HomeView::applySidebarPinned(bool pinned) -{ - qDebug() << "[HomeView] Sidebar pinned mode changed:" << pinned - << "position:" << (m_sidebarOnRight ? "right" : "left"); - - if (pinned) - { - - if (m_sidebarAnim->state() == QAbstractAnimation::Running) - m_sidebarAnim->stop(); - m_sidebarAutoHideTimer->stop(); - - // Pinned sidebar is docked into the layout and casts no floating shadow. - if (m_sidebarShadow) - m_sidebarShadow->hide(); - - - m_sidebar->setProperty("pinned", true); - m_sidebar->style()->unpolish(m_sidebar); - m_sidebar->style()->polish(m_sidebar); - - - applySidebarMetrics(true); - m_sidebar->setMinimumHeight(0); - m_sidebar->setMaximumHeight(QWIDGETSIZE_MAX); - - - if (m_sidebarOnRight) - { - m_contentLayout->addWidget(m_sidebar); - } - else - { - m_contentLayout->insertWidget(0, m_sidebar); - } - syncSidebarVisibility(); - m_sidebar->raise(); - } - else - { - - m_contentLayout->removeWidget(m_sidebar); - m_sidebar->setParent(this); - - // Floating sidebar regains its drop shadow (painted by the overlay - // sibling; geometry is synced in syncSidebarShadow / eventFilter). - - - m_sidebar->setProperty("pinned", false); - m_sidebar->style()->unpolish(m_sidebar); - m_sidebar->style()->polish(m_sidebar); - - - applySidebarMetrics(false); - syncSidebarVisibility(); - } -} diff --git a/src/qEmbyApp/views/user/homeview.h b/src/qEmbyApp/views/user/homeview.h deleted file mode 100644 index 5a21808..0000000 --- a/src/qEmbyApp/views/user/homeview.h +++ /dev/null @@ -1,200 +0,0 @@ -#ifndef HOMEVIEW_H -#define HOMEVIEW_H - -#include -#include -#include -#include -#include -#include -#include -#include - -class QTimer; -class QEmbyCore; -class SlidingStackedWidget; -class ShadowOverlay; -class QLabel; -class QPushButton; -class QAction; -class QLineEdit; -class QListWidget; -class QCompleter; -class QStringListModel; -class ElidedLabel; -class QBoxLayout; -class QVBoxLayout; -class DashboardView; -class FavoritesView; -class HomeQuickRouterView; -class SettingsView; -class SmoothScrollController; -class SeasonView; -class PlaybackManager; -class PlayerView; -class ManageView; -class WebdavProfileStore; -class NavigationController; - - -struct RouteInfo { - QPointer widget; - bool isDynamic; - QString routeType; - QString routeId; - QString routeTitle; - QString routeExtraId; -}; - -class HomeView : public QWidget -{ - Q_OBJECT -public: - explicit HomeView(QEmbyCore* core, QWidget *parent = nullptr); - ~HomeView() override; - - - QCoro::Task refreshProfile(); - - - void triggerSearch(const QString& query); - - - bool canNavigateBack() const; - - bool canGoHome() const; - - bool canGoFav() const; - - - PlayerView* activePlayerView() const; - - void beginAuthenticatedSession(); - void clearAuthenticatedSession(); - -public Q_SLOTS: - - void navigateBack(); - void goHome(); - void goFav(); - -protected: - bool eventFilter(QObject *watched, QEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override; - -signals: - void logoutRequested(); - void canNavigateBackChanged(bool canBack); - void homeContentSwitched(); - - - void immersiveStateChanged(bool isImmersive); - void playerChromeVisibilityChanged(bool visible); - -private: - void scheduleProfileRefresh(); - void setupUi(); - void setupSidebar(); - - void showSidebar(); - void hideSidebar(); - void applySidebarPosition(); - void applySidebarPinned(bool pinned); - bool isCurrentViewImmersive() const; - int sidebarWidthForMode(bool pinned) const; - void applySidebarMetrics(bool pinned); - void applySidebarIcons(); - void applySidebarCustomVisibility(); - void syncSidebarVisibility(); - void syncSidebarShadow(); - void openCloudSyncDialog(); - void setupSearchHistory(); - void updateSearchCompleter(const QString &text = QString()); - QString currentSearchServerId() const; - - - void pushView(QWidget* view); - bool pushQuickRoute(const QVariantMap &request); - void openCategoryRoute(const QString &categoryId, const QString &title); - void openLibraryRoute(const QString &libraryId, const QString &title); - void openPersonRoute(const QString &personId, const QString &title); - void openFilteredRoute(const QString &filterType, const QString &filterValue); - void resetToView(QWidget* view, const QString &rootRoute = QString()); - QWidget* homeRootWidget() const; - - QWidget* createDetailView(const QString& itemId, const QString& itemName = "", const MediaItem& seedItem = {}); - QWidget* createCategoryView(const QString& categoryId, const QString& title = ""); - QWidget* createLibraryView(const QString& libraryId, const QString& title = ""); - QWidget* createPersonView(const QString& personId, const QString& personName = ""); - QWidget* createSearchView(const QString& query); - QWidget* createFilteredView(const QString& filterType, const QString& filterValue); - QWidget* createPlayerView(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant &extraData); - QWidget* createSettingsView(); - QWidget* createManageView(); - - - QWidget* createSeasonView(const QString& seriesId, const QString& seasonId, const QString& seasonName); - - - void launchPlayer(const QString& mediaId, const QString& title, const QString& streamUrl, long long startPositionTicks, const QVariant& extraData); - - QEmbyCore* m_core; - NavigationController* m_navigationController = nullptr; - - SlidingStackedWidget* m_contentSwitcher = nullptr; - bool m_isDestroying = false; - QStack m_navStack; - - DashboardView* m_dashboardView = nullptr; - FavoritesView* m_favoritesView = nullptr; - HomeQuickRouterView* m_homeQuickRouterView = nullptr; - bool m_useQuickHomeRouter = true; - - QWidget* m_sidebar = nullptr; - ShadowOverlay* m_sidebarShadow = nullptr; - QWidget* m_edgeTrigger = nullptr; - QPropertyAnimation* m_sidebarAnim = nullptr; - QTimer* m_sidebarAutoHideTimer = nullptr; - bool m_sidebarOnRight = false; - bool m_sidebarPinned = false; - bool m_sidebarPinnedApplied = false; - QHBoxLayout* m_contentLayout = nullptr; - - QLabel* m_serverIconLabel = nullptr; - QBoxLayout* m_serverInfoLayout = nullptr; - QVBoxLayout* m_serverNameLayout = nullptr; - QVBoxLayout* m_sidebarFooterActionsLayout = nullptr; - ElidedLabel* m_serverNameLabel = nullptr; - ElidedLabel* m_serverAddressLabel = nullptr; - QLineEdit* m_searchBox = nullptr; - QAction* m_searchAction = nullptr; - QCompleter* m_searchCompleter = nullptr; - QStringListModel* m_searchHistoryModel = nullptr; - - QPushButton* m_btnHome = nullptr; - QPushButton* m_btnFavorites = nullptr; - QWidget* m_navArea = nullptr; - QWidget* m_searchSpacer = nullptr; - QListWidget* m_libraryList = nullptr; - SmoothScrollController* m_sidebarLibraryScrollController = nullptr; - - QLabel* m_userAvatarLabel = nullptr; - QHBoxLayout* m_userInfoLayout = nullptr; - ElidedLabel* m_userNameLabel = nullptr; - QPushButton* m_btnCloudSync = nullptr; - QPushButton* m_btnSettings = nullptr; - QPushButton* m_btnManage = nullptr; - QPushButton* m_btnDownloads = nullptr; - QPushButton* m_btnLogout = nullptr; - WebdavProfileStore* m_webdavStore = nullptr; - - std::optional> m_pendingProfileRefreshTask; - int m_profileRefreshGeneration = 0; - QString m_sidebarLibraryServerId; - QString m_sidebarLibraryUserId; - QString m_lastRouteType; -}; - -#endif diff --git a/src/qEmbyCore/services/media/mediaservice.cpp b/src/qEmbyCore/services/media/mediaservice.cpp index acfbccc..d88c842 100644 --- a/src/qEmbyCore/services/media/mediaservice.cpp +++ b/src/qEmbyCore/services/media/mediaservice.cpp @@ -404,7 +404,14 @@ QCoro::Task> MediaService::getUserViews(bool includeHidden) qDebug() << "[MediaService] getUserViews cache hit" << "| includeHidden=" << includeHidden << "| count=" << m_userViewsCache.views.size(); - co_return m_userViewsCache.views; + // NB: co_return of a *non-const member lvalue* binds QCoro's + // forwarding-reference return_value(U&&) overload (U deduces to T&), + // whose body is `mValue = std::move(value)` — it would MOVE the list + // out of the cache, silently emptying m_userViewsCache after the very + // first hit. With two consumers now sharing this cache (dashboard + + // sidebar), the second used to get an empty list. Copy explicitly so + // the cached views survive repeated hits. + co_return QList(m_userViewsCache.views); }